Adding files.
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -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
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
run_*/
|
||||||
|
*.docx
|
||||||
|
*.epub
|
||||||
|
data/
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -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"]
|
||||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# BookApp Modules
|
||||||
61
config.py
Normal file
61
config.py
Normal file
@@ -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}
|
||||||
|
}
|
||||||
1
credentials.json
Normal file
1
credentials.json
Normal file
@@ -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"]}}
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -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}
|
||||||
320
main.py
Normal file
320
main.py
Normal file
@@ -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)
|
||||||
19
make_admin.py
Normal file
19
make_admin.py
Normal file
@@ -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 <username>")
|
||||||
|
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.")
|
||||||
215
modules/ai.py
Normal file
215
modules/ai.py
Normal file
@@ -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}")
|
||||||
74
modules/export.py
Normal file
74
modules/export.py
Normal file
@@ -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"<h1>{header}</h1>{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)
|
||||||
350
modules/marketing.py
Normal file
350
modules/marketing.py
Normal file
@@ -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)
|
||||||
15
modules/requirements_web.txt
Normal file
15
modules/requirements_web.txt
Normal file
@@ -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
|
||||||
626
modules/story.py
Normal file
626
modules/story.py
Normal file
@@ -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
|
||||||
202
modules/utils.py
Normal file
202
modules/utils.py
Normal file
@@ -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
|
||||||
1132
modules/web_app.py
Normal file
1132
modules/web_app.py
Normal file
File diff suppressed because it is too large
Load Diff
47
modules/web_db.py
Normal file
47
modules/web_db.py
Normal file
@@ -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)
|
||||||
218
modules/web_tasks.py
Normal file
218
modules/web_tasks.py
Normal file
@@ -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
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
google-generativeai
|
||||||
|
google-cloud-aiplatform
|
||||||
|
google-auth-oauthlib
|
||||||
|
python-docx
|
||||||
|
EbookLib
|
||||||
|
python-dotenv
|
||||||
|
rich
|
||||||
|
Pillow
|
||||||
|
requests
|
||||||
|
markdown
|
||||||
104
templates/admin_dashboard.html
Normal file
104
templates/admin_dashboard.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h2><i class="fas fa-shield-alt me-2 text-warning"></i>Admin Dashboard</h2>
|
||||||
|
<p class="text-muted">System management and user administration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<a href="{{ url_for('admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- User Management -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-users me-2"></i>Users ({{ users|length }})</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.id }}</td>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.is_admin %}<span class="badge bg-warning text-dark">Admin</span>{% else %}<span class="badge bg-secondary">User</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.id != current_user.id %}
|
||||||
|
<form action="/admin/user/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Delete user {{ u.username }} and ALL their projects? This cannot be undone.');">
|
||||||
|
<a href="{{ url_for('impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
||||||
|
<i class="fas fa-user-secret"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">Current</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Stats & Reset -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<h3>{{ projects|length }}</h3>
|
||||||
|
<span class="text-muted">Total Projects</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<h3>{{ users|length }}</h3>
|
||||||
|
<span class="text-muted">Total Users</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-danger">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-exclamation-triangle me-2"></i>Danger Zone</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p><strong>Factory Reset:</strong> This will delete <strong>ALL</strong> projects, books, and users (except your admin account). It resets the system to a fresh state.</p>
|
||||||
|
|
||||||
|
<form action="/admin/reset" method="POST" onsubmit="return confirmReset()">
|
||||||
|
<button type="submit" class="btn btn-danger w-100">
|
||||||
|
<i class="fas fa-radiation me-2"></i>Perform Factory Reset
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function confirmReset() {
|
||||||
|
return confirm("⚠️ WARNING: This will wipe ALL data on the server except your account.\n\nAre you absolutely sure?");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
60
templates/admin_spend.html
Normal file
60
templates/admin_spend.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h2><i class="fas fa-chart-line me-2 text-success"></i>Spend Report</h2>
|
||||||
|
<p class="text-muted">Aggregate cost analysis per user.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Period Overview</h5>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="?days=7" class="btn btn-outline-secondary {{ 'active' if days == 7 }}">7 Days</a>
|
||||||
|
<a href="?days=30" class="btn btn-outline-secondary {{ 'active' if days == 30 }}">30 Days</a>
|
||||||
|
<a href="?days=90" class="btn btn-outline-secondary {{ 'active' if days == 90 }}">90 Days</a>
|
||||||
|
<a href="?days=0" class="btn btn-outline-secondary {{ 'active' if days == 0 }}">All Time</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h2 class="text-success fw-bold">${{ "%.4f"|format(total) }}</h2>
|
||||||
|
<span class="text-muted">Total Spend ({{ 'Last ' ~ days ~ ' Days' if days > 0 else 'All Time' }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th class="text-center">Runs Generated</th>
|
||||||
|
<th class="text-end">Total Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in report %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">{{ row.username }}</td>
|
||||||
|
<td class="text-center"><span class="badge bg-secondary">{{ row.runs }}</span></td>
|
||||||
|
<td class="text-end font-monospace">${{ "%.4f"|format(row.cost) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="text-center py-4 text-muted">No spending data found for this period.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
86
templates/base.html
Normal file
86
templates/base.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BookApp AI</title>
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- FontAwesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
body { background-color: #f8f9fa; }
|
||||||
|
.navbar-brand { font-weight: bold; letter-spacing: 1px; }
|
||||||
|
.card { border: none; box-shadow: 0 4px 6px rgba(0,0,0,0.1); transition: transform 0.2s; }
|
||||||
|
.card:hover { transform: translateY(-2px); }
|
||||||
|
.btn-primary { background-color: #2c3e50; border-color: #2c3e50; }
|
||||||
|
.btn-primary:hover { background-color: #1a252f; border-color: #1a252f; }
|
||||||
|
.console-log {
|
||||||
|
background-color: #1e1e1e; color: #00ff00;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
padding: 15px; border-radius: 5px;
|
||||||
|
height: 400px; overflow-y: scroll;
|
||||||
|
white-space: pre-wrap; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if session.get('original_admin_id') %}
|
||||||
|
<div class="bg-danger text-white text-center py-2 shadow-sm" style="position: sticky; top: 0; z-index: 1050;">
|
||||||
|
<strong><i class="fas fa-user-secret me-2"></i>Viewing site as {{ current_user.username }}</strong>
|
||||||
|
<a href="{{ url_for('stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/"><i class="fas fa-book-open me-2"></i>BookApp AI</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-center">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<li class="nav-item me-3">
|
||||||
|
<a class="nav-link text-warning" href="/admin"><i class="fas fa-shield-alt me-1"></i> Admin</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item me-3">
|
||||||
|
<a class="nav-link" href="/personas"><i class="fas fa-users me-1"></i> Personas</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item me-3">
|
||||||
|
<span class="text-light small">
|
||||||
|
<i class="fas fa-coins text-warning"></i> Spend: ${{ "%.2f"|format(current_user.total_spend) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span class="text-light me-3">Hello, {{ current_user.username }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="/logout">Logout</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
98
templates/dashboard.html
Normal file
98
templates/dashboard.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h2><i class="fas fa-layer-group me-2"></i>Your Projects</h2>
|
||||||
|
<p class="text-muted">Manage your book series and generation tasks.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<a href="/personas" class="btn btn-outline-secondary me-2">
|
||||||
|
<i class="fas fa-users me-2"></i>Personas
|
||||||
|
</a>
|
||||||
|
<a href="/system/status" class="btn btn-outline-secondary me-2">
|
||||||
|
<i class="fas fa-server me-2"></i>System Status
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-info me-2" onclick="optimizeModels()">
|
||||||
|
<i class="fas fa-sync me-2"></i>Find New Models
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newProjectModal">
|
||||||
|
<i class="fas fa-plus me-2"></i>New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{% for p in projects %}
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ p.name }}</h5>
|
||||||
|
<p class="card-text text-muted small">Created: {{ p.created_at.strftime('%Y-%m-%d') }}</p>
|
||||||
|
<a href="/project/{{ p.id }}" class="btn btn-outline-primary stretched-link">Open Project</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12 text-center py-5">
|
||||||
|
<h4 class="text-muted">No projects yet. Start writing!</h4>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Project Modal -->
|
||||||
|
<div class="modal fade" id="newProjectModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" action="/project/setup" method="POST" onsubmit="showLoading(this)">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">New Project Wizard</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted">Describe your story idea, and the AI will suggest a title, genre, and structure for you.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Story Concept / Idea</label>
|
||||||
|
<textarea name="concept" class="form-control" rows="6" placeholder="e.g. A detective in 1920s London discovers magic is real..." required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="analyzeBtn">Analyze & Next <i class="fas fa-arrow-right ms-2"></i></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function showLoading(form) {
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>AI Analyzing...';
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimizeModels() {
|
||||||
|
if (!confirm("This will check for rate limits and re-select the best available models. Continue?")) return;
|
||||||
|
|
||||||
|
const btn = event.target.closest('button');
|
||||||
|
const originalHtml = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Optimizing...';
|
||||||
|
|
||||||
|
fetch('/system/optimize_models', { method: 'POST' })
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload(); // Reload to see flashed message
|
||||||
|
} else {
|
||||||
|
alert('Optimization request failed.');
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
alert('Network error during optimization.');
|
||||||
|
}).finally(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
27
templates/login.html
Normal file
27
templates/login.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center mt-5">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card p-4">
|
||||||
|
<h3 class="text-center mb-4">Login</h3>
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" name="username" class="form-control" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<small>Don't have an account? <a href="/register">Register here</a></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
121
templates/persona_edit.html
Normal file
121
templates/persona_edit.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ url_for('save_persona') }}" method="POST">
|
||||||
|
<input type="hidden" name="old_name" value="{{ name }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name / Pseudonym</label>
|
||||||
|
<input type="text" name="name" class="form-control" value="{{ persona.get('name', '') }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Age</label>
|
||||||
|
<input type="text" name="age" class="form-control" value="{{ persona.get('age', '') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Gender</label>
|
||||||
|
<input type="text" name="gender" class="form-control" value="{{ persona.get('gender', '') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Race / Ethnicity</label>
|
||||||
|
<input type="text" name="race" class="form-control" value="{{ persona.get('race', '') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Nationality</label>
|
||||||
|
<input type="text" name="nationality" class="form-control" value="{{ persona.get('nationality', '') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Primary Language</label>
|
||||||
|
<input type="text" name="language" class="form-control" value="{{ persona.get('language', 'Standard English') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Voice Keywords</label>
|
||||||
|
<input type="text" name="voice_keywords" id="voiceKeywords" class="form-control" value="{{ persona.get('voice_keywords', '') }}" placeholder="e.g. Sarcastic, Fast-paced, Minimalist">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Style Inspirations</label>
|
||||||
|
<input type="text" name="style_inspirations" id="styleInspirations" class="form-control" value="{{ persona.get('style_inspirations', '') }}" placeholder="e.g. Hemingway, Noir Fiction">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Bio / Writing Style</label>
|
||||||
|
<textarea name="bio" id="bioField" class="form-control" rows="4">{{ persona.get('bio', '') }}</textarea>
|
||||||
|
<div class="form-text">Describe the voice, tone, and stylistic quirks.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<label class="form-label mb-0">Sample Text (Manual)</label>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success" onclick="analyzePersona()">
|
||||||
|
<i class="fas fa-magic me-1"></i> Analyze & Auto-Fill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea name="sample_text" id="sampleText" class="form-control" rows="6">{{ persona.get('sample_text', '') }}</textarea>
|
||||||
|
<div class="form-text">Paste a paragraph of text. The AI can analyze this to fill the fields above.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Persona</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function analyzePersona() {
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Analyzing...';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: document.querySelector('input[name="name"]').value,
|
||||||
|
age: document.querySelector('input[name="age"]').value,
|
||||||
|
gender: document.querySelector('input[name="gender"]').value,
|
||||||
|
nationality: document.querySelector('input[name="nationality"]').value,
|
||||||
|
sample_text: document.getElementById('sampleText').value
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/persona/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(resp => {
|
||||||
|
if (resp.error) {
|
||||||
|
alert("Error: " + resp.error);
|
||||||
|
} else {
|
||||||
|
if (resp.bio) document.getElementById('bioField').value = resp.bio;
|
||||||
|
if (resp.voice_keywords) document.getElementById('voiceKeywords').value = resp.voice_keywords;
|
||||||
|
if (resp.style_inspirations) document.getElementById('styleInspirations').value = resp.style_inspirations;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert("Request failed."))
|
||||||
|
.finally(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
32
templates/personas.html
Normal file
32
templates/personas.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="fas fa-users me-2"></i>Author Personas</h2>
|
||||||
|
<a href="{{ url_for('new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{% for name, p in personas.items() %}
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ p.name }}</h5>
|
||||||
|
<h6 class="card-subtitle mb-2 text-muted">{{ p.age }} {{ p.gender }} | {{ p.nationality }}</h6>
|
||||||
|
<p class="card-text small">{{ p.bio[:150] }}...</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white border-top-0 d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||||
|
<form action="{{ url_for('delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">No personas found. Create one to define a writing voice.</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
515
templates/project.html
Normal file
515
templates/project.html
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<h1 class="mb-0 me-3">{{ project.name }}</h1>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editProjectModal"><i class="fas fa-edit"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="badge bg-secondary">{{ bible.project_metadata.genre }}</span>
|
||||||
|
<span class="badge bg-info text-dark">{{ bible.project_metadata.target_audience }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<form action="/project/{{ project.id }}/run" method="POST" class="d-inline">
|
||||||
|
<button class="btn btn-success shadow px-4 py-2" {% if active_run and active_run.status in ['running', 'queued'] %}disabled{% endif %}>
|
||||||
|
<i class="fas fa-play me-2"></i>{{ 'Generating...' if runs and runs[0].status in ['running', 'queued'] else 'Generate New Book' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if runs and runs[0].status in ['running', 'queued'] %}
|
||||||
|
<form action="/run/{{ runs[0].id }}/stop" method="POST" class="d-inline ms-2">
|
||||||
|
<button class="btn btn-danger shadow px-3 py-2" title="Stop/Cancel Run" onclick="return confirm('Are you sure you want to stop this job? If the server restarted, this will simply unlock the UI.')">
|
||||||
|
<i class="fas fa-stop"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- LATEST RUN CARD -->
|
||||||
|
<div class="card mb-4 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-4 px-4">
|
||||||
|
<h4 class="card-title"><i class="fas fa-bolt text-warning me-2"></i>Active Run (ID: {{ active_run.id if active_run else '-' }})</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
{% if active_run %}
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<span class="badge bg-{{ 'success' if active_run.status == 'completed' else 'warning' if active_run.status in ['running', 'queued'] else 'danger' if active_run.status in ['failed', 'cancelled', 'interrupted'] else 'secondary' }} fs-6 me-3 status-badge-{{ active_run.id }}">
|
||||||
|
{{ active_run.status|upper }}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small">Run #{{ active_run.id }} started at {{ active_run.start_time.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
<span class="ms-auto text-muted fw-bold">Cost: $<span class="cost-{{ active_run.id }}">{{ "%.4f"|format(active_run.cost) }}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DOWNLOADS -->
|
||||||
|
{% if artifacts or cover_image %}
|
||||||
|
<div class="row mt-3">
|
||||||
|
{% if cover_image %}
|
||||||
|
<div class="col-md-4 text-center mb-3">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<img src="/project/{{ active_run.id }}/download?file={{ cover_image }}&t={{ active_run.end_time.timestamp() if active_run.end_time else 0 }}" class="card-img-top" alt="Book Cover">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-md-{{ '8' if cover_image else '12' }}">
|
||||||
|
<div class="alert alert-{{ 'success' if active_run.status == 'completed' else 'warning' }} h-100">
|
||||||
|
<h5 class="alert-heading"><i class="fas fa-{{ 'check-circle' if active_run.status == 'completed' else 'folder-open' }} me-2"></i>{{ 'Book Generated!' if active_run.status == 'completed' else 'Files Available' }}</h5>
|
||||||
|
<p>{{ 'Your manuscript and ebook files are ready.' if active_run.status == 'completed' else 'Files generated during this run (may be partial).' }}</p>
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for file in artifacts %}
|
||||||
|
<a href="/project/{{ active_run.id }}/download?file={{ file.path }}" class="btn btn-success">
|
||||||
|
<i class="fas fa-download me-1"></i> Download {{ file.type }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
<button class="btn btn-outline-dark" data-bs-toggle="modal" data-bs-target="#regenerateModal">
|
||||||
|
<i class="fas fa-paint-brush me-1"></i> Regenerate Cover / Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% if blurb_content %}
|
||||||
|
<div class="card mt-3 border-0 bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title fw-bold text-muted">Back Cover Blurb</h6>
|
||||||
|
<p class="card-text fst-italic">{{ blurb_content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- STATUS BAR -->
|
||||||
|
{% if active_run and active_run.status in ['running', 'queued'] %}
|
||||||
|
<div class="card bg-light border-0 mb-3" id="statusBar">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="spinner-border text-primary spinner-border-sm me-2" role="status"></div>
|
||||||
|
<strong class="text-primary" id="statusPhase">Initializing...</strong>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title mb-3" id="statusMessage">Preparing environment...</h5>
|
||||||
|
<div class="progress" style="height: 10px;">
|
||||||
|
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: 100%"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted mt-2 d-block" id="statusTime"></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- CONSOLE -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none p-0 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#consoleCollapse">
|
||||||
|
<i class="fas fa-terminal me-1"></i> {{ 'Hide' if active_run.status in ['running', 'queued'] else 'Show' }} Console Logs
|
||||||
|
</button>
|
||||||
|
<div class="collapse {{ 'show' if active_run.status in ['running', 'queued'] else '' }}" id="consoleCollapse">
|
||||||
|
<div id="consoleOutput" class="console-log bg-dark text-success p-3 rounded" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">Loading logs...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4 text-muted">
|
||||||
|
<p>No books generated yet.</p>
|
||||||
|
<p>Click the <strong>Generate New Book</strong> button to start writing.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RUN HISTORY -->
|
||||||
|
<div class="card mb-4 border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-history me-2"></i>Run History</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Cost</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in runs %}
|
||||||
|
<tr class="{{ 'table-primary' if active_run and r.id == active_run.id else '' }}">
|
||||||
|
<td>#{{ r.id }}</td>
|
||||||
|
<td>{{ r.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if r.status == 'completed' else 'warning' if r.status in ['running', 'queued'] else 'danger' if r.status in ['failed', 'cancelled', 'interrupted'] else 'secondary' }}">
|
||||||
|
{{ r.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${{ "%.4f"|format(r.cost) }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/project/{{ project.id }}/run/{{ r.id }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
{{ 'View Active' if active_run and r.id == active_run.id else 'View' }}
|
||||||
|
</a>
|
||||||
|
{% if r.status in ['failed', 'cancelled', 'interrupted'] %}
|
||||||
|
<form action="/run/{{ r.id }}/restart" method="POST" class="d-inline ms-1">
|
||||||
|
<input type="hidden" name="mode" value="resume">
|
||||||
|
<button class="btn btn-sm btn-outline-success" title="Resume from last step">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if r.status not in ['running', 'queued'] %}
|
||||||
|
<form action="/run/{{ r.id }}/restart" method="POST" class="d-inline ms-1" onsubmit="return confirm('This will delete all files for this run and start over. Are you sure?');">
|
||||||
|
<input type="hidden" name="mode" value="restart">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Re-run (Wipe & Restart)">
|
||||||
|
<i class="fas fa-redo"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted">No runs found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SERIES OVERVIEW -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="fas fa-{{ 'layer-group' if bible.project_metadata.is_series else 'book' }} me-2"></i>
|
||||||
|
{{ 'Series Overview' if bible.project_metadata.is_series else 'Book Overview' }}
|
||||||
|
</h4>
|
||||||
|
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addBookModal">
|
||||||
|
<i class="fas fa-plus me-1"></i> {{ 'Add Book' if bible.project_metadata.is_series else 'Extend to Series' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row flex-nowrap overflow-auto pb-3" style="gap: 1rem;">
|
||||||
|
{% for book in bible.books %}
|
||||||
|
<div class="col-md-4 col-lg-3" style="min-width: 300px;">
|
||||||
|
<div class="card h-100 shadow-sm border-top border-4 {{ 'border-success' if generated_books.get(book.book_number) else 'border-secondary' }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<span class="badge bg-light text-dark border">Book {{ book.book_number }}</span>
|
||||||
|
{% if generated_books.get(book.book_number) %}
|
||||||
|
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Generated</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Planned</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title text-truncate" title="{{ book.title }}">{{ book.title }}</h5>
|
||||||
|
<p class="card-text small text-muted" style="height: 60px; overflow: hidden;">
|
||||||
|
{{ book.manual_instruction or "No summary provided." }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editBookModal{{ book.book_number }}" title="Edit Details">
|
||||||
|
<i class="fas fa-edit"></i> Edit
|
||||||
|
</button>
|
||||||
|
{% if not generated_books.get(book.book_number) %}
|
||||||
|
<form action="/project/{{ project.id }}/delete_book/{{ book.book_number }}" method="POST" onsubmit="return confirm('Remove this book from the plan?');">
|
||||||
|
<button class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Book Modal (Specific to this book) -->
|
||||||
|
<div class="modal fade" id="editBookModal{{ book.book_number }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" action="/project/{{ project.id }}/book/{{ book.book_number }}/update" method="POST">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit Book {{ book.book_number }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Title</label>
|
||||||
|
<input type="text" name="title" class="form-control" value="{{ book.title }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Plot Summary / Instruction</label>
|
||||||
|
<textarea name="instruction" class="form-control" rows="6">{{ book.manual_instruction }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Add Book Card -->
|
||||||
|
<div class="col-md-4 col-lg-3" style="min-width: 200px;">
|
||||||
|
<div class="card h-100 border-dashed d-flex align-items-center justify-content-center bg-light" style="border: 2px dashed #ccc; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#addBookModal">
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="fas fa-plus-circle fa-2x mb-2"></i>
|
||||||
|
<br>Add Next Book
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WORLD BIBLE & LINKED SERIES -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-globe me-2"></i>World Bible & Characters</h5>
|
||||||
|
<div>
|
||||||
|
<a href="/project/{{ project.id }}/review" class="btn btn-sm btn-outline-info me-1">
|
||||||
|
<i class="fas fa-list-alt me-1"></i> Full Review
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#importCharModal">
|
||||||
|
<i class="fas fa-link me-1"></i> Link / Import Series
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary ms-1" data-bs-toggle="modal" data-bs-target="#refineBibleModal">
|
||||||
|
<i class="fas fa-magic me-1"></i> Refine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 border-end">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold mb-3">Project Metadata</h6>
|
||||||
|
<dl class="row small mb-0">
|
||||||
|
<dt class="col-4">Author</dt><dd class="col-8">{{ bible.project_metadata.author }}</dd>
|
||||||
|
<dt class="col-4">Genre</dt><dd class="col-8">{{ bible.project_metadata.genre }}</dd>
|
||||||
|
<dt class="col-4">Tone</dt><dd class="col-8">{{ bible.project_metadata.style.tone }}</dd>
|
||||||
|
<dt class="col-4">Series</dt><dd class="col-8">{{ 'Yes' if bible.project_metadata.is_series else 'No' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold mb-3">Characters ({{ bible.characters|length }})</h6>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for c in bible.characters %}
|
||||||
|
<span class="badge bg-light text-dark border" title="{{ c.description }}">
|
||||||
|
<i class="fas fa-user me-1 text-secondary"></i> {{ c.name }}
|
||||||
|
<span class="text-muted fw-normal">| {{ c.role }}</span>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">No characters defined.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div class="modal fade" id="editProjectModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" action="/project/{{ project.id }}/update" method="POST">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Edit Project Details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3"><label class="form-label">Book Title</label><input type="text" name="title" class="form-control" value="{{ bible.project_metadata.title }}" required></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Author Name</label><input type="text" name="author" class="form-control" value="{{ bible.project_metadata.author }}" required></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Book Modal -->
|
||||||
|
<div class="modal fade" id="addBookModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" action="/project/{{ project.id }}/add_book" method="POST">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Add Book to Series</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Book Title</label>
|
||||||
|
<input type="text" name="title" class="form-control" placeholder="e.g. The Two Towers" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Plot Summary / Instruction</label>
|
||||||
|
<textarea name="instruction" class="form-control" rows="4" placeholder="Briefly describe the plot. Mention if it follows the main cast or shifts to side characters/new settings."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Book</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Characters Modal -->
|
||||||
|
<div class="modal fade" id="importCharModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" action="/project/{{ project.id }}/import_characters" method="POST">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Link Related Series</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted small">Import characters from another project to establish a shared universe.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Source Project</label>
|
||||||
|
<select name="source_project_id" class="form-select" required>
|
||||||
|
<option value="">-- Select Project --</option>
|
||||||
|
{% for p in other_projects %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-success">Import Characters</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Regenerate/Feedback Modal -->
|
||||||
|
<div class="modal fade" id="regenerateModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" action="/project/{{ active_run.id if active_run else 0 }}/regenerate_artifacts" method="POST">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Update Files & Cover</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted small">This will regenerate the EPUB/DOCX files with any metadata changes. You can also provide feedback to fix the cover.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Cover Feedback (Optional)</label>
|
||||||
|
<textarea name="feedback" class="form-control" rows="3" placeholder="e.g. 'Change the title color to gold', 'The text is hard to read', or 'I don't like the image, make it darker'."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-warning">Regenerate</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refine Bible Modal -->
|
||||||
|
<div class="modal fade" id="refineBibleModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<form class="modal-content" action="/project/{{ project.id }}/refine_bible" method="POST">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Refine Bible with AI</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted small">Ask the AI to change characters, plot points, or settings.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Instruction</label>
|
||||||
|
<textarea name="instruction" class="form-control" rows="3" placeholder="e.g. 'Change the ending of Book 1', 'Add a character named Bob', 'Make the tone darker'." required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Update Bible</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full Bible JSON Modal -->
|
||||||
|
<div class="modal fade" id="fullBibleModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Full Bible JSON</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body bg-light">
|
||||||
|
<pre class="small">{{ bible | tojson(indent=2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let activeInterval = null;
|
||||||
|
// Only auto-poll if we have a latest run
|
||||||
|
let currentRunId = {{ active_run.id if active_run else 'null' }};
|
||||||
|
|
||||||
|
function fetchLog() {
|
||||||
|
if (!currentRunId) return;
|
||||||
|
|
||||||
|
fetch(`/run/${currentRunId}/status`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const consoleDiv = document.getElementById('consoleOutput');
|
||||||
|
if (consoleDiv) {
|
||||||
|
consoleDiv.innerText = data.log || "Waiting for logs...";
|
||||||
|
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Sidebar Badge & Cost
|
||||||
|
const badge = document.querySelector(`.status-badge-${currentRunId}`);
|
||||||
|
if (badge) {
|
||||||
|
badge.innerText = data.status.toUpperCase();
|
||||||
|
badge.className = `badge fs-6 me-3 status-badge-${currentRunId} bg-${data.status === 'completed' ? 'success' : (data.status === 'running' || data.status === 'queued') ? 'warning' : (data.status === 'failed' || data.status === 'interrupted' || data.status === 'cancelled') ? 'danger' : 'secondary'}`;
|
||||||
|
}
|
||||||
|
const costSpan = document.querySelector(`.cost-${currentRunId}`);
|
||||||
|
if (costSpan) costSpan.innerText = parseFloat(data.cost).toFixed(4);
|
||||||
|
|
||||||
|
// Update Status Bar
|
||||||
|
if (data.progress && data.progress.message) {
|
||||||
|
const phaseEl = document.getElementById('statusPhase');
|
||||||
|
const msgEl = document.getElementById('statusMessage');
|
||||||
|
const timeEl = document.getElementById('statusTime');
|
||||||
|
|
||||||
|
if (phaseEl) {
|
||||||
|
// Map phases to icons
|
||||||
|
const icons = {
|
||||||
|
"WRITER": "✍️", "ARCHITECT": "🏗️", "MARKETING": "🎨",
|
||||||
|
"ENRICHER": "🧠", "SYSTEM": "⚙️", "TIMING": "⏱️", "TRACKER": "🔎"
|
||||||
|
};
|
||||||
|
const icon = icons[data.progress.phase] || "🔄";
|
||||||
|
phaseEl.innerText = `${icon} ${data.progress.phase}`;
|
||||||
|
}
|
||||||
|
if (msgEl) msgEl.innerText = data.progress.message;
|
||||||
|
if (timeEl) {
|
||||||
|
const date = new Date(data.progress.timestamp * 1000);
|
||||||
|
timeEl.innerText = "Last update: " + date.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If running, keep polling
|
||||||
|
if (data.status === 'running' || data.status === 'queued') {
|
||||||
|
if (!activeInterval) activeInterval = setInterval(fetchLog, 2000);
|
||||||
|
} else {
|
||||||
|
if (activeInterval) clearInterval(activeInterval);
|
||||||
|
activeInterval = null;
|
||||||
|
// Reload page on completion to show download buttons
|
||||||
|
if (data.status === 'completed' && !document.querySelector('.alert-success')) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if active_run %}
|
||||||
|
fetchLog();
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
92
templates/project_review.html
Normal file
92
templates/project_review.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
|
||||||
|
<h4 class="mb-0"><i class="fas fa-check-circle me-2"></i>Review Bible & Story Beats</h4>
|
||||||
|
<a href="/project/{{ project.id }}" class="btn btn-light btn-sm fw-bold">Looks Good, Start Writing <i class="fas fa-arrow-right ms-1"></i></a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">The AI has generated your characters and plot structure. Review them below and refine if needed.</p>
|
||||||
|
|
||||||
|
<!-- Refinement Bar -->
|
||||||
|
<form action="/project/{{ project.id }}/refine_bible" method="POST" class="mb-4" onsubmit="showLoading(this)">
|
||||||
|
<div class="input-group shadow-sm">
|
||||||
|
<span class="input-group-text bg-warning text-dark"><i class="fas fa-magic"></i></span>
|
||||||
|
<input type="text" name="instruction" class="form-control" placeholder="AI Instruction: e.g. 'Change the ending of Book 1', 'Add a plot point about the ring', 'Make the tone darker'" required>
|
||||||
|
<button type="submit" class="btn btn-warning">Refine with AI</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Characters Section -->
|
||||||
|
<h5 class="text-primary border-bottom pb-2 mb-3">👥 Characters</h5>
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table table-hover table-bordered">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 20%">Name</th>
|
||||||
|
<th style="width: 20%">Role</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in bible.characters %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">{{ c.name }}</td>
|
||||||
|
<td><span class="badge bg-secondary">{{ c.role }}</span></td>
|
||||||
|
<td class="small">{{ c.description }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="text-center text-muted">No characters generated.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Books & Beats Section -->
|
||||||
|
<h5 class="text-primary border-bottom pb-2 mb-3">📚 Plot Structure</h5>
|
||||||
|
{% for book in bible.books %}
|
||||||
|
<div class="card mb-4 border-light bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-dark">
|
||||||
|
<span class="badge bg-dark me-2">Book {{ book.book_number }}</span>
|
||||||
|
{{ book.title }}
|
||||||
|
</h5>
|
||||||
|
<p class="card-text text-muted fst-italic border-start border-4 border-warning ps-3 mb-3">
|
||||||
|
{{ book.manual_instruction }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 class="fw-bold mt-3">Plot Beats:</h6>
|
||||||
|
<ol class="list-group list-group-numbered">
|
||||||
|
{% for beat in book.plot_beats %}
|
||||||
|
<li class="list-group-item bg-white">{{ beat }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="list-group-item text-muted">No plot beats generated.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 mt-4">
|
||||||
|
<a href="/project/{{ project.id }}" class="btn btn-success btn-lg">
|
||||||
|
<i class="fas fa-check me-2"></i>Finalize & Go to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function showLoading(form) {
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Refining...';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
161
templates/project_setup.html
Normal file
161
templates/project_setup.html
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0"><i class="fas fa-magic me-2"></i>Project Setup</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">The AI has analyzed your concept. Review and edit the suggestions below before creating your project.</p>
|
||||||
|
|
||||||
|
<form method="POST" onsubmit="showLoading(this)">
|
||||||
|
<input type="hidden" name="concept" value="{{ concept }}">
|
||||||
|
<input type="hidden" name="persona_key" id="persona_key_input" value="{{ persona_key|default('') }}">
|
||||||
|
|
||||||
|
<!-- Refinement Bar -->
|
||||||
|
<div class="input-group mb-4 shadow-sm">
|
||||||
|
<span class="input-group-text bg-warning text-dark"><i class="fas fa-magic"></i></span>
|
||||||
|
<input type="text" name="refine_instruction" class="form-control" placeholder="AI Instruction: e.g. 'Make it a trilogy', 'Change genre to Cyberpunk', 'Make the tone darker'">
|
||||||
|
<button type="submit" formaction="/project/setup/refine" class="btn btn-warning">Refine with AI</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Core Metadata -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-bold">Book Title</label>
|
||||||
|
<input type="text" name="title" class="form-control form-control-lg" value="{{ s.title }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Genre</label>
|
||||||
|
<input type="text" name="genre" class="form-control" value="{{ s.genre }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Target Audience</label>
|
||||||
|
<input type="text" name="audience" class="form-control" value="{{ s.target_audience }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold text-primary">Select Author Persona (Optional)</label>
|
||||||
|
<select class="form-select" onchange="applyPersona(this)">
|
||||||
|
<option value="">-- Select a Persona to Auto-fill --</option>
|
||||||
|
{% for key, p in personas.items() %}
|
||||||
|
<option value="{{ key }}" data-name="{{ p.name }}" data-bio="{{ p.bio }}">{{ key }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Author Name</label>
|
||||||
|
<input type="text" name="author" class="form-control" value="{{ current_user.username }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Author Bio / Persona</label>
|
||||||
|
<textarea name="author_bio" class="form-control" rows="2">{{ s.author_bio }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Structure -->
|
||||||
|
<h5 class="text-primary mb-3">Structure & Length</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12 mb-2">
|
||||||
|
<label class="form-label">Length Category</label>
|
||||||
|
<select name="length_category" class="form-select">
|
||||||
|
{% for key, val in lengths.items() %}
|
||||||
|
<option value="{{ key }}" {% if key == s.length_category %}selected{% endif %}>
|
||||||
|
{{ val.label }} ({{ val.words }} words)
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Est. Chapters</label>
|
||||||
|
<input type="number" name="chapters" class="form-control" value="{{ s.estimated_chapters }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Est. Word Count</label>
|
||||||
|
<input type="text" name="words" class="form-control" value="{{ s.estimated_word_count }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="include_prologue" {% if s.include_prologue %}checked{% endif %}>
|
||||||
|
<label class="form-check-label">Include Prologue</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="include_epilogue" {% if s.include_epilogue %}checked{% endif %}>
|
||||||
|
<label class="form-check-label">Include Epilogue</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_series" {% if s.is_series %}checked{% endif %}>
|
||||||
|
<label class="form-check-label">Is Series</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<label class="form-label ms-2 small">Book Count:</label>
|
||||||
|
<input type="number" name="series_count" class="form-control d-inline-block py-0 px-2" style="width: 60px; height: 25px;" value="{{ s.series_count|default(1) }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Style -->
|
||||||
|
<h5 class="text-primary mb-3">Style & Tone</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Tone</label><input type="text" name="tone" class="form-control" value="{{ s.tone }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">POV Style</label><input type="text" name="pov_style" class="form-control" value="{{ s.pov_style }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Time Period</label><input type="text" name="time_period" class="form-control" value="{{ s.time_period }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Spice Level</label><input type="text" name="spice" class="form-control" value="{{ s.spice }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Violence</label><input type="text" name="violence" class="form-control" value="{{ s.violence }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Narrative Tense</label><input type="text" name="narrative_tense" class="form-control" value="{{ s.narrative_tense }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Language Style</label><input type="text" name="language_style" class="form-control" value="{{ s.language_style }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Dialogue Style</label><input type="text" name="dialogue_style" class="form-control" value="{{ s.dialogue_style }}"></div>
|
||||||
|
<div class="col-md-6 mb-2"><label class="form-label">Page Orientation</label>
|
||||||
|
<select name="page_orientation" class="form-select"><option value="Portrait" {% if s.page_orientation == 'Portrait' %}selected{% endif %}>Portrait</option><option value="Landscape" {% if s.page_orientation == 'Landscape' %}selected{% endif %}>Landscape</option><option value="Square" {% if s.page_orientation == 'Square' %}selected{% endif %}>Square</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Tropes (comma separated)</label>
|
||||||
|
<input type="text" name="tropes" class="form-control" value="{{ s.tropes|join(', ') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Formatting Rules (comma separated)</label>
|
||||||
|
<input type="text" name="formatting_rules" class="form-control" value="{{ s.formatting_rules|join(', ') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" formaction="/project/create" class="btn btn-success btn-lg">
|
||||||
|
<i class="fas fa-check me-2"></i>Create Project & Generate Bible
|
||||||
|
</button>
|
||||||
|
<a href="/" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function showLoading(form) {
|
||||||
|
// We don't disable buttons here because formaction needs the specific button click
|
||||||
|
// But we can show a global spinner or change cursor
|
||||||
|
document.body.style.cursor = 'wait';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPersona(select) {
|
||||||
|
const opt = select.options[select.selectedIndex];
|
||||||
|
if (opt.value) {
|
||||||
|
document.querySelector('input[name="author"]').value = opt.dataset.name || opt.value;
|
||||||
|
document.querySelector('textarea[name="author_bio"]').value = opt.dataset.bio || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
27
templates/register.html
Normal file
27
templates/register.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center mt-5">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card p-4">
|
||||||
|
<h3 class="text-center mb-4">Register</h3>
|
||||||
|
<form method="POST" action="/register">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" name="username" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-success">Create Account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<small>Already have an account? <a href="/login">Login here</a></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
298
templates/run_details.html
Normal file
298
templates/run_details.html
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
|
||||||
|
<p class="text-muted mb-0">Project: <a href="{{ url_for('view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
|
||||||
|
<i class="fas fa-scroll me-2"></i>Show Bible
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible Bible Viewer -->
|
||||||
|
<div class="collapse mb-4" id="bibleCollapse">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-book-open me-2"></i>Project Bible</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if bible %}
|
||||||
|
<ul class="nav nav-tabs" id="bibleTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="meta-tab" data-bs-toggle="tab" data-bs-target="#meta" type="button" role="tab">Metadata</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="chars-tab" data-bs-toggle="tab" data-bs-target="#chars" type="button" role="tab">Characters</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="plot-tab" data-bs-toggle="tab" data-bs-target="#plot" type="button" role="tab">Plot</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content p-3 border border-top-0">
|
||||||
|
<!-- Metadata Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="meta" role="tabpanel">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
{% for k, v in bible.project_metadata.items() %}
|
||||||
|
{% if k not in ['style', 'length_settings', 'author_details'] %}
|
||||||
|
<dt class="col-sm-3 text-capitalize">{{ k.replace('_', ' ') }}</dt>
|
||||||
|
<dd class="col-sm-9">{{ v }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if bible.project_metadata.style %}
|
||||||
|
<dt class="col-sm-12 mt-3 border-bottom pb-2">Style Settings</dt>
|
||||||
|
{% for k, v in bible.project_metadata.style.items() %}
|
||||||
|
<dt class="col-sm-3 text-capitalize mt-2">{{ k.replace('_', ' ') }}</dt>
|
||||||
|
<dd class="col-sm-9 mt-2">{{ v|join(', ') if v is sequence and v is not string else v }}</dd>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Characters Tab -->
|
||||||
|
<div class="tab-pane fade" id="chars" role="tabpanel">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle">
|
||||||
|
<thead class="table-light"><tr><th>Name</th><th>Role</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in bible.characters %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">{{ c.name }}</td>
|
||||||
|
<td><span class="badge bg-secondary">{{ c.role }}</span></td>
|
||||||
|
<td class="small">{{ c.description }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plot Tab -->
|
||||||
|
<div class="tab-pane fade" id="plot" role="tabpanel">
|
||||||
|
{% for book in bible.books %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="fw-bold text-primary">Book {{ book.book_number }}: {{ book.title }}</h6>
|
||||||
|
<p class="fst-italic small text-muted">{{ book.manual_instruction }}</p>
|
||||||
|
<ol class="list-group list-group-numbered">
|
||||||
|
{% for beat in book.plot_beats %}
|
||||||
|
<li class="list-group-item list-group-item-action border-0 py-1">{{ beat }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning">Bible data not found for this project.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="fw-bold" id="status-text">Status: {{ run.status|title }}</span>
|
||||||
|
<span class="text-muted" id="run-duration">{{ run.duration() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 20px;">
|
||||||
|
<div id="status-bar" class="progress-bar {% if run.status == 'running' %}progress-bar-striped progress-bar-animated{% elif run.status == 'failed' %}bg-danger{% else %}bg-success{% endif %}"
|
||||||
|
role="progressbar" style="width: {% if run.status == 'completed' %}100%{% elif run.status == 'running' %}100%{% else %}5%{% endif %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Left Column: Cover Art -->
|
||||||
|
<div class="col-md-4 mb-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-image me-2"></i>Cover Art</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
{% if has_cover %}
|
||||||
|
<img src="{{ url_for('download_artifact', run_id=run.id, file='cover.png') }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover">
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-secondary py-5">
|
||||||
|
<i class="fas fa-image fa-3x mb-3"></i><br>
|
||||||
|
No cover generated yet.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST">
|
||||||
|
<label class="form-label text-start w-100 small fw-bold">Regenerate Art & Files</label>
|
||||||
|
<textarea name="feedback" class="form-control mb-2" rows="2" placeholder="Feedback (e.g. 'Make the font larger', 'Use a darker theme')..."></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="fas fa-sync me-2"></i>Regenerate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Blurb & Stats -->
|
||||||
|
<div class="col-md-8 mb-4">
|
||||||
|
<!-- Blurb -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-align-left me-2"></i>Blurb</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if blurb %}
|
||||||
|
<p class="card-text" style="white-space: pre-wrap;">{{ blurb }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted fst-italic">Blurb not generated yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="card shadow-sm text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted">Total Cost</h6>
|
||||||
|
<h3 class="text-success" id="run-cost">${{ "%.4f"|format(run.cost) }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="card shadow-sm text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted">Artifacts</h6>
|
||||||
|
<h3>
|
||||||
|
{% if has_cover %}<i class="fas fa-check text-success me-2"></i>{% endif %}
|
||||||
|
{% if blurb %}<i class="fas fa-check text-success"></i>{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tracking & Warnings -->
|
||||||
|
{% if tracking and (tracking.content_warnings or tracking.characters) %}
|
||||||
|
<div class="card shadow-sm mb-4 border-warning">
|
||||||
|
<div class="card-header bg-warning-subtle">
|
||||||
|
<h5 class="mb-0 text-warning-emphasis"><i class="fas fa-exclamation-triangle me-2"></i>Story Tracking & Warnings</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tracking.content_warnings %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="fw-bold">Content Warnings Detected:</h6>
|
||||||
|
<div>
|
||||||
|
{% for w in tracking.content_warnings %}
|
||||||
|
<span class="badge bg-danger me-1 mb-1">{{ w }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
<h6 class="fw-bold">Major Character Events:</h6>
|
||||||
|
<div class="accordion" id="charEventsAcc">
|
||||||
|
{% for name, data in tracking.characters.items() %}
|
||||||
|
{% if data.major_events %}
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#ce-{{ loop.index }}">
|
||||||
|
{{ name }}
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="ce-{{ loop.index }}" class="accordion-collapse collapse" data-bs-parent="#charEventsAcc">
|
||||||
|
<div class="accordion-body small py-2">
|
||||||
|
<ul class="mb-0 ps-3">
|
||||||
|
{% for evt in data.major_events %}
|
||||||
|
<li>{{ evt }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Collapsible Log -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#logCollapse">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-terminal me-2"></i>System Log</h5>
|
||||||
|
<span class="badge bg-secondary" id="log-badge">Click to Toggle</span>
|
||||||
|
</div>
|
||||||
|
<div id="logCollapse" class="collapse {% if run.status == 'running' %}show{% endif %}">
|
||||||
|
<div class="card-body bg-dark p-0">
|
||||||
|
<pre id="console-log" class="console-log m-0 p-3" style="color: #00ff00; background-color: #1e1e1e; height: 400px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">{{ log_content or "Waiting for logs..." }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const runId = {{ run.id }};
|
||||||
|
const consoleEl = document.getElementById('console-log');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const statusBar = document.getElementById('status-bar');
|
||||||
|
const costEl = document.getElementById('run-cost');
|
||||||
|
|
||||||
|
function updateLog() {
|
||||||
|
fetch(`/run/${runId}/status`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Update Status Text
|
||||||
|
statusText.innerText = "Status: " + data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
||||||
|
costEl.innerText = '$' + parseFloat(data.cost).toFixed(4);
|
||||||
|
|
||||||
|
// Update Status Bar
|
||||||
|
if (data.status === 'running' || data.status === 'queued') {
|
||||||
|
statusBar.className = "progress-bar progress-bar-striped progress-bar-animated";
|
||||||
|
statusBar.style.width = "100%";
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
statusBar.className = "progress-bar bg-danger";
|
||||||
|
statusBar.style.width = "100%";
|
||||||
|
} else {
|
||||||
|
statusBar.className = "progress-bar bg-success";
|
||||||
|
statusBar.style.width = "100%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Log (only if changed to avoid scroll jitter)
|
||||||
|
if (consoleEl.innerText !== data.log) {
|
||||||
|
const isScrolledToBottom = consoleEl.scrollHeight - consoleEl.clientHeight <= consoleEl.scrollTop + 50;
|
||||||
|
consoleEl.innerText = data.log;
|
||||||
|
if (isScrolledToBottom) {
|
||||||
|
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll if running
|
||||||
|
if (data.status === 'running' || data.status === 'queued') {
|
||||||
|
setTimeout(updateLog, 2000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
updateLog();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
124
templates/system_status.html
Normal file
124
templates/system_status.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h2><i class="fas fa-server me-2"></i>System Status</h2>
|
||||||
|
<p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
||||||
|
<form action="{{ url_for('optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-sync me-2"></i>Refresh & Optimize
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-robot me-2"></i>AI Model Selection</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 15%">Role</th>
|
||||||
|
<th style="width: 25%">Selected Model</th>
|
||||||
|
<th>Selection Reasoning</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if models %}
|
||||||
|
{% for role, info in models.items() %}
|
||||||
|
{% if role != 'ranking' %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold text-uppercase">{{ role }}</td>
|
||||||
|
<td>
|
||||||
|
{% if info is mapping %}
|
||||||
|
<span class="badge bg-info text-dark">{{ info.model }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ info }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">
|
||||||
|
{% if info is mapping %}
|
||||||
|
{{ info.reason }}
|
||||||
|
{% else %}
|
||||||
|
<em>Legacy format. Please refresh models.</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-4 text-muted">
|
||||||
|
<i class="fas fa-exclamation-circle me-2"></i>No model configuration found.
|
||||||
|
<br>Click <strong>Refresh & Optimize</strong> to scan available Gemini models.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ranked Models -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-list-ol me-2"></i>All Models Ranked</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 10%">Rank</th>
|
||||||
|
<th style="width: 30%">Model Name</th>
|
||||||
|
<th>Reasoning</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if models and models.ranking %}
|
||||||
|
{% for item in models.ranking %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-bold">{{ loop.index }}</td>
|
||||||
|
<td><span class="badge bg-secondary">{{ item.model }}</span></td>
|
||||||
|
<td class="small text-muted">{{ item.reason }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-4 text-muted">
|
||||||
|
No ranking data available. Refresh models to generate.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache Info -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-clock me-2"></i>Cache Status</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Last Scan:</strong>
|
||||||
|
{% if cache and cache.timestamp %}
|
||||||
|
{{ datetime.fromtimestamp(cache.timestamp).strftime('%Y-%m-%d %H:%M:%S') }}
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted small mb-0">Model selection is cached for 24 hours to save API calls.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
1
token.json
Normal file
1
token.json
Normal file
@@ -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"}
|
||||||
858
wizard.py
Normal file
858
wizard.py
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import config
|
||||||
|
import google.generativeai as genai
|
||||||
|
from flask import Flask
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.prompt import Prompt, IntPrompt, Confirm
|
||||||
|
from rich.table import Table
|
||||||
|
from modules import ai, utils
|
||||||
|
from modules.web_db import db, User, Project
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
genai.configure(api_key=config.API_KEY)
|
||||||
|
|
||||||
|
# Validate Key on Launch
|
||||||
|
try:
|
||||||
|
list(genai.list_models(page_size=1))
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]❌ CRITICAL: Gemini API Key check failed.[/bold red]")
|
||||||
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
|
console.print("Please check your .env file and ensure GEMINI_API_KEY is correct.")
|
||||||
|
Prompt.ask("Press Enter to exit...")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logic_name = ai.get_optimal_model("pro") if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
||||||
|
model = genai.GenerativeModel(logic_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||||
|
|
||||||
|
# --- DB SETUP FOR WIZARD ---
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# --- DEFINITIONS ---
|
||||||
|
if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
|
||||||
|
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||||
|
|
||||||
|
class BookWizard:
|
||||||
|
def __init__(self):
|
||||||
|
self.project_name = "New_Project"
|
||||||
|
self.project_path = None
|
||||||
|
self.user = None
|
||||||
|
self.data = {
|
||||||
|
"project_metadata": {},
|
||||||
|
"characters": [],
|
||||||
|
"books": []
|
||||||
|
}
|
||||||
|
utils.create_default_personas()
|
||||||
|
|
||||||
|
def _get_or_create_wizard_user(self):
|
||||||
|
# Find or create a default user for CLI operations
|
||||||
|
wizard_user = User.query.filter_by(username="wizard").first()
|
||||||
|
if not wizard_user:
|
||||||
|
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]")
|
||||||
|
wizard_user = User(username="wizard", password="!", is_admin=True) # Password not used for CLI
|
||||||
|
db.session.add(wizard_user)
|
||||||
|
db.session.commit()
|
||||||
|
return wizard_user
|
||||||
|
|
||||||
|
def clear(self): os.system('cls' if os.name == 'nt' else 'clear')
|
||||||
|
|
||||||
|
def ask_gemini_json(self, prompt):
|
||||||
|
text = None
|
||||||
|
try:
|
||||||
|
response = model.generate_content(prompt + "\nReturn ONLY valid JSON.")
|
||||||
|
text = utils.clean_json(response.text)
|
||||||
|
return json.loads(text)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]AI Error: {e}[/red]")
|
||||||
|
if text: console.print(f"[dim]Raw output: {text[:150]}...[/dim]")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def ask_gemini_text(self, prompt):
|
||||||
|
try:
|
||||||
|
response = model.generate_content(prompt)
|
||||||
|
return response.text.strip()
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]AI Error: {e}[/red]")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def manage_personas(self):
|
||||||
|
while True:
|
||||||
|
self.clear()
|
||||||
|
personas = {}
|
||||||
|
if os.path.exists(config.PERSONAS_FILE):
|
||||||
|
try:
|
||||||
|
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
console.print(Panel("[bold cyan]🎭 Manage Author Personas[/bold cyan]"))
|
||||||
|
options = list(personas.keys())
|
||||||
|
|
||||||
|
for i, name in enumerate(options):
|
||||||
|
console.print(f"[{i+1}] {name}")
|
||||||
|
|
||||||
|
console.print(f"[{len(options)+1}] Create New Persona")
|
||||||
|
console.print(f"[{len(options)+2}] Back")
|
||||||
|
console.print(f"[{len(options)+3}] Exit")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, len(options)+4)]))
|
||||||
|
|
||||||
|
if choice == len(options) + 2: break
|
||||||
|
elif choice == len(options) + 3: sys.exit()
|
||||||
|
|
||||||
|
selected_key = None
|
||||||
|
details = {}
|
||||||
|
|
||||||
|
if choice == len(options) + 1:
|
||||||
|
# Create
|
||||||
|
console.print("[yellow]Define New Persona[/yellow]")
|
||||||
|
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
|
||||||
|
else:
|
||||||
|
# Edit/Delete Menu for specific persona
|
||||||
|
selected_key = options[choice-1]
|
||||||
|
details = personas[selected_key]
|
||||||
|
if isinstance(details, str): details = {"bio": details}
|
||||||
|
|
||||||
|
console.print(f"\n[bold]Selected: {selected_key}[/bold]")
|
||||||
|
console.print("1. Edit")
|
||||||
|
console.print("2. Delete")
|
||||||
|
console.print("3. Cancel")
|
||||||
|
|
||||||
|
sub = int(Prompt.ask("Action", choices=["1", "2", "3"], default="1"))
|
||||||
|
if sub == 2:
|
||||||
|
if Confirm.ask(f"Delete '{selected_key}'?", default=False):
|
||||||
|
del personas[selected_key]
|
||||||
|
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||||
|
continue
|
||||||
|
elif sub == 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Edit Fields
|
||||||
|
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
|
||||||
|
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
|
||||||
|
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
|
||||||
|
details['race'] = Prompt.ask("Race/Ethnicity", default=details.get('race', "Unknown"))
|
||||||
|
details['nationality'] = Prompt.ask("Nationality/Country", default=details.get('nationality', "Unknown"))
|
||||||
|
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
|
||||||
|
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
|
||||||
|
|
||||||
|
# Samples
|
||||||
|
console.print("\n[bold]Style Samples[/bold]")
|
||||||
|
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
|
||||||
|
|
||||||
|
curr_files = details.get('sample_files', [])
|
||||||
|
files_str = ",".join(curr_files)
|
||||||
|
new_files = Prompt.ask("Sample Text Files (comma sep filenames)", default=files_str)
|
||||||
|
details['sample_files'] = [x.strip() for x in new_files.split(',') if x.strip()]
|
||||||
|
|
||||||
|
details['sample_text'] = Prompt.ask("Manual Sample Paragraph", default=details.get('sample_text', ""))
|
||||||
|
|
||||||
|
if Confirm.ask("Save Persona?", default=True):
|
||||||
|
personas[selected_key] = details
|
||||||
|
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||||
|
|
||||||
|
def select_mode(self):
|
||||||
|
while True:
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel("[bold blue]🧙♂️ BookApp Setup Wizard[/bold blue]"))
|
||||||
|
console.print("1. Create New Project")
|
||||||
|
console.print("2. Open Existing Project")
|
||||||
|
console.print("3. Manage Author Personas")
|
||||||
|
console.print("4. Exit")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Mode", choices=["1", "2", "3", "4"], default="1"))
|
||||||
|
|
||||||
|
if choice == 1:
|
||||||
|
self.user = self._get_or_create_wizard_user()
|
||||||
|
return self.create_new_project()
|
||||||
|
elif choice == 2:
|
||||||
|
self.user = self._get_or_create_wizard_user()
|
||||||
|
if self.open_existing_project(): return True
|
||||||
|
elif choice == 3:
|
||||||
|
# Personas don't need a user context
|
||||||
|
self.manage_personas()
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_new_project(self):
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel("[bold green]🆕 New Project Setup[/bold green]"))
|
||||||
|
|
||||||
|
# 1. Ask for Concept first to guide defaults
|
||||||
|
console.print("Tell me about your story idea (or leave empty to start from scratch).")
|
||||||
|
concept = Prompt.ask("Story Concept")
|
||||||
|
|
||||||
|
suggestions = {}
|
||||||
|
if concept:
|
||||||
|
with console.status("[bold yellow]AI is analyzing your concept...[/bold yellow]"):
|
||||||
|
prompt = f"""
|
||||||
|
Analyze this story concept and suggest metadata for a book or series.
|
||||||
|
CONCEPT: {concept}
|
||||||
|
|
||||||
|
RETURN JSON with these keys:
|
||||||
|
- title: Suggested book title
|
||||||
|
- genre: Genre
|
||||||
|
- target_audience: e.g. Adult, YA
|
||||||
|
- tone: e.g. Dark, Whimsical
|
||||||
|
- length_category: One of ["00", "0", "01", "1", "2", "2b", "3", "4", "5"] based on likely depth.
|
||||||
|
- estimated_chapters: int (suggested chapter count)
|
||||||
|
- estimated_word_count: string (e.g. "75,000")
|
||||||
|
- include_prologue: boolean
|
||||||
|
- include_epilogue: boolean
|
||||||
|
- tropes: list of strings
|
||||||
|
- pov_style: e.g. First Person
|
||||||
|
- time_period: e.g. Modern
|
||||||
|
- spice: e.g. Standard, Explicit
|
||||||
|
- violence: e.g. None, Graphic
|
||||||
|
- is_series: boolean
|
||||||
|
- series_title: string (if series)
|
||||||
|
- narrative_tense: e.g. Past, Present
|
||||||
|
- language_style: e.g. Standard, Flowery
|
||||||
|
- dialogue_style: e.g. Witty, Formal
|
||||||
|
- page_orientation: Portrait, Landscape, or Square
|
||||||
|
- formatting_rules: list of strings
|
||||||
|
"""
|
||||||
|
suggestions = self.ask_gemini_json(prompt)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel("[bold green]🤖 AI Suggestions[/bold green]"))
|
||||||
|
|
||||||
|
grid = Table.grid(padding=(0, 2))
|
||||||
|
grid.add_column(style="bold cyan")
|
||||||
|
grid.add_column()
|
||||||
|
|
||||||
|
def get_str(k):
|
||||||
|
v = suggestions.get(k, 'N/A')
|
||||||
|
if isinstance(v, list): return ", ".join(v)
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
grid.add_row("Title:", get_str('title'))
|
||||||
|
grid.add_row("Genre:", get_str('genre'))
|
||||||
|
grid.add_row("Audience:", get_str('target_audience'))
|
||||||
|
grid.add_row("Tone:", get_str('tone'))
|
||||||
|
|
||||||
|
len_cat = suggestions.get('length_category', '4')
|
||||||
|
len_label = config.LENGTH_DEFINITIONS.get(len_cat, {}).get('label', 'Novel')
|
||||||
|
grid.add_row("Length:", len_label)
|
||||||
|
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A')))
|
||||||
|
grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
|
||||||
|
|
||||||
|
grid.add_row("Tropes:", get_str('tropes'))
|
||||||
|
grid.add_row("POV:", get_str('pov_style'))
|
||||||
|
grid.add_row("Time:", get_str('time_period'))
|
||||||
|
grid.add_row("Spice:", get_str('spice'))
|
||||||
|
grid.add_row("Violence:", get_str('violence'))
|
||||||
|
grid.add_row("Series:", "Yes" if suggestions.get('is_series') else "No")
|
||||||
|
|
||||||
|
console.print(grid)
|
||||||
|
console.print("\n[dim]These will be the defaults for the next step.[/dim]")
|
||||||
|
|
||||||
|
console.print("\n1. Continue (Manual Step-through)")
|
||||||
|
console.print("2. Refine Suggestions with AI")
|
||||||
|
|
||||||
|
choice = Prompt.ask("Select Option", choices=["1", "2"], default="1")
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
instruction = Prompt.ask("Instruction (e.g. 'Make it darker', 'Change genre to Sci-Fi')")
|
||||||
|
with console.status("[bold yellow]Refining suggestions...[/bold yellow]"):
|
||||||
|
refine_prompt = f"""
|
||||||
|
Update these project suggestions based on the user instruction.
|
||||||
|
CURRENT JSON: {json.dumps(suggestions)}
|
||||||
|
INSTRUCTION: {instruction}
|
||||||
|
RETURN ONLY VALID JSON with the same keys.
|
||||||
|
"""
|
||||||
|
new_sugg = self.ask_gemini_json(refine_prompt)
|
||||||
|
if new_sugg: suggestions = new_sugg
|
||||||
|
|
||||||
|
# 2. Select Type (with AI default)
|
||||||
|
default_type = "2" if suggestions.get('is_series') else "1"
|
||||||
|
|
||||||
|
console.print("1. Standalone Book")
|
||||||
|
console.print("2. Series")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Type", choices=["1", "2"], default=default_type))
|
||||||
|
is_series = (choice == 2)
|
||||||
|
|
||||||
|
self.configure_details(suggestions, concept, is_series)
|
||||||
|
self.enrich_blueprint()
|
||||||
|
self.refine_blueprint("Review & Edit Bible")
|
||||||
|
self.save_bible()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def open_existing_project(self):
|
||||||
|
# Query projects from the database for the wizard user
|
||||||
|
projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
|
||||||
|
if not projects:
|
||||||
|
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
|
||||||
|
Prompt.ask("Press Enter to continue...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
console.print("\n[bold cyan]📂 Select Project[/bold cyan]")
|
||||||
|
for i, p in enumerate(projects):
|
||||||
|
console.print(f"[{i+1}] {p.name}")
|
||||||
|
|
||||||
|
console.print(f"[{len(projects)+1}] Back")
|
||||||
|
console.print(f"[{len(projects)+2}] Exit")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Project", choices=[str(i) for i in range(1, len(projects)+3)]))
|
||||||
|
if choice == len(projects) + 1: return False
|
||||||
|
if choice == len(projects) + 2: sys.exit()
|
||||||
|
|
||||||
|
selected_project = projects[choice-1]
|
||||||
|
self.project_name = selected_project.name
|
||||||
|
self.project_path = selected_project.folder_path
|
||||||
|
return True
|
||||||
|
|
||||||
|
def load_bible(self):
|
||||||
|
if not self.project_path:
|
||||||
|
console.print("[red]No project loaded.[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
path = os.path.join(self.project_path, "bible.json")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, 'r') as f: self.data = json.load(f)
|
||||||
|
return True
|
||||||
|
console.print("[red]Bible not found.[/red]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def configure_details(self, suggestions=None, concept="", is_series=False):
|
||||||
|
if suggestions is None: suggestions = {}
|
||||||
|
console.print("\n[bold blue]📝 Project Details[/bold blue]")
|
||||||
|
|
||||||
|
# Simplified Persona Selection (Skip creation)
|
||||||
|
personas = {}
|
||||||
|
if os.path.exists(config.PERSONAS_FILE):
|
||||||
|
try:
|
||||||
|
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
author_details = {}
|
||||||
|
if personas:
|
||||||
|
console.print("\n[bold]Select Author Persona[/bold]")
|
||||||
|
opts = list(personas.keys())
|
||||||
|
for i, p in enumerate(opts): console.print(f"[{i+1}] {p}")
|
||||||
|
console.print(f"[{len(opts)+1}] None (AI Default)")
|
||||||
|
|
||||||
|
sel = IntPrompt.ask("Option", choices=[str(i) for i in range(1, len(opts)+2)], default=len(opts)+1)
|
||||||
|
if sel <= len(opts):
|
||||||
|
author_details = personas[opts[sel-1]]
|
||||||
|
if isinstance(author_details, str): author_details = {"bio": author_details}
|
||||||
|
|
||||||
|
default_author = author_details.get('name', "AI Co-Pilot")
|
||||||
|
author = Prompt.ask("Author Name", default=default_author)
|
||||||
|
|
||||||
|
genre = Prompt.ask("Genre", default=suggestions.get('genre', "Fiction"))
|
||||||
|
|
||||||
|
# LENGTH SELECTION
|
||||||
|
table = Table(title="Target Length Options")
|
||||||
|
table.add_column("#"); table.add_column("Type"); table.add_column("Est. Words"); table.add_column("Chapters")
|
||||||
|
for k, v in config.LENGTH_DEFINITIONS.items():
|
||||||
|
table.add_row(k, v['label'], v['words'], str(v['chapters']))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
def_len = suggestions.get('length_category', "4")
|
||||||
|
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4"
|
||||||
|
|
||||||
|
len_choice = Prompt.ask("Select Target Length", choices=list(config.LENGTH_DEFINITIONS.keys()), default=def_len)
|
||||||
|
# Create a copy so we don't modify the global definition
|
||||||
|
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
|
||||||
|
|
||||||
|
# AI Defaults
|
||||||
|
def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
|
||||||
|
def_words = suggestions.get('estimated_word_count', settings['words'])
|
||||||
|
def_prologue = suggestions.get('include_prologue', False)
|
||||||
|
def_epilogue = suggestions.get('include_epilogue', False)
|
||||||
|
|
||||||
|
settings['chapters'] = IntPrompt.ask("Target Chapters", default=int(def_chapters))
|
||||||
|
settings['words'] = Prompt.ask("Target Word Count", default=str(def_words))
|
||||||
|
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
|
||||||
|
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
|
||||||
|
|
||||||
|
# --- GENRE STANDARD CHECK ---
|
||||||
|
# Parse current word count selection
|
||||||
|
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
|
||||||
|
avg_words = 0
|
||||||
|
if '-' in w_str:
|
||||||
|
parts = w_str.split('-')
|
||||||
|
try: avg_words = (int(parts[0].strip()) + int(parts[1].strip().replace('k','000'))) // 2
|
||||||
|
except: pass
|
||||||
|
else:
|
||||||
|
try: avg_words = int(w_str.replace('k', '000'))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Define rough standards
|
||||||
|
std_target = 0
|
||||||
|
g_lower = genre.lower()
|
||||||
|
if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000
|
||||||
|
elif "thriller" in g_lower or "horror" in g_lower or "historical" in g_lower: std_target = 85000
|
||||||
|
elif "romance" in g_lower or "mystery" in g_lower: std_target = 70000
|
||||||
|
elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
|
||||||
|
|
||||||
|
if std_target > 0 and avg_words > 0:
|
||||||
|
# If difference is > 25%, warn user
|
||||||
|
if abs(std_target - avg_words) / std_target > 0.25:
|
||||||
|
console.print(f"\n[bold yellow]⚠️ Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
|
||||||
|
if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
|
||||||
|
settings['words'] = f"{std_target:,}"
|
||||||
|
|
||||||
|
# TROPES
|
||||||
|
console.print("\n[italic]Note: Tropes are recurring themes (e.g. 'Chosen One').[/italic]")
|
||||||
|
def_tropes = ", ".join(suggestions.get('tropes', []))
|
||||||
|
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes)
|
||||||
|
sel_tropes = [x.strip() for x in tropes_input.split(',')] if tropes_input else []
|
||||||
|
|
||||||
|
# TITLE
|
||||||
|
# If series, this is Series Title. If book, Book Title.
|
||||||
|
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
|
||||||
|
|
||||||
|
# PROJECT NAME
|
||||||
|
default_proj = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_") if title else "New_Project"
|
||||||
|
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
|
||||||
|
|
||||||
|
# Create Project in DB and set path
|
||||||
|
user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
|
||||||
|
if not os.path.exists(user_dir): os.makedirs(user_dir)
|
||||||
|
|
||||||
|
self.project_path = os.path.join(user_dir, self.project_name)
|
||||||
|
if os.path.exists(self.project_path):
|
||||||
|
console.print(f"[yellow]Warning: Project folder '{self.project_path}' already exists.[/yellow]")
|
||||||
|
|
||||||
|
new_proj = Project(user_id=self.user.id, name=self.project_name, folder_path=self.project_path)
|
||||||
|
db.session.add(new_proj)
|
||||||
|
db.session.commit()
|
||||||
|
console.print(f"[green]Project '{self.project_name}' created in database.[/green]")
|
||||||
|
|
||||||
|
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]")
|
||||||
|
tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced"))
|
||||||
|
|
||||||
|
# POV SETTINGS
|
||||||
|
pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited"))
|
||||||
|
pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="")
|
||||||
|
pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else []
|
||||||
|
|
||||||
|
# ADVANCED STYLE
|
||||||
|
tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
|
||||||
|
|
||||||
|
console.print("\n[bold]Content Guidelines[/bold]")
|
||||||
|
spice = Prompt.ask("Spice/Romance (e.g. 'Clean', 'Fade-to-Black', 'Explicit')", default=suggestions.get('spice', "Standard"))
|
||||||
|
violence = Prompt.ask("Violence (e.g. 'None', 'Mild', 'Graphic')", default=suggestions.get('violence', "Standard"))
|
||||||
|
language = Prompt.ask("Language (e.g. 'No Swearing', 'Mild', 'Heavy')", default=suggestions.get('language_style', "Standard"))
|
||||||
|
|
||||||
|
dialogue_style = Prompt.ask("Dialogue Style (e.g. 'Witty', 'Formal', 'Slang-heavy')", default=suggestions.get('dialogue_style', "Standard"))
|
||||||
|
|
||||||
|
console.print("\n[bold]Formatting & World Rules[/bold]")
|
||||||
|
time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern"))
|
||||||
|
|
||||||
|
# Visuals
|
||||||
|
orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait"))
|
||||||
|
|
||||||
|
console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
|
||||||
|
def_fmt = ", ".join(suggestions.get('formatting_rules', ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"]))
|
||||||
|
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt)
|
||||||
|
fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
|
||||||
|
|
||||||
|
# Update book_metadata with new fields
|
||||||
|
style_data = {
|
||||||
|
"tone": tone, "tropes": sel_tropes,
|
||||||
|
"pov_style": pov_style, "pov_characters": pov_chars,
|
||||||
|
"tense": tense, "spice": spice, "violence": violence, "language": language,
|
||||||
|
"dialogue_style": dialogue_style, "time_period": time_period,
|
||||||
|
"page_orientation": orientation,
|
||||||
|
"formatting_rules": fmt_rules
|
||||||
|
}
|
||||||
|
|
||||||
|
self.data['project_metadata'] = {
|
||||||
|
"title": title,
|
||||||
|
"author": author,
|
||||||
|
"author_details": author_details,
|
||||||
|
"author_bio": author_details.get('bio', ''),
|
||||||
|
"genre": genre,
|
||||||
|
"target_audience": Prompt.ask("Audience", default=suggestions.get('target_audience', "Adult")),
|
||||||
|
"is_series": is_series,
|
||||||
|
"length_settings": settings,
|
||||||
|
"style": style_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize Books List
|
||||||
|
self.data['books'] = []
|
||||||
|
if is_series:
|
||||||
|
count = IntPrompt.ask("How many books in the series?", default=3)
|
||||||
|
for i in range(count):
|
||||||
|
self.data['books'].append({
|
||||||
|
"book_number": i+1,
|
||||||
|
"title": f"Book {i+1}",
|
||||||
|
"manual_instruction": concept if i==0 else "",
|
||||||
|
"plot_beats": []
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
self.data['books'].append({
|
||||||
|
"book_number": 1,
|
||||||
|
"title": title,
|
||||||
|
"manual_instruction": concept,
|
||||||
|
"plot_beats": []
|
||||||
|
})
|
||||||
|
|
||||||
|
def enrich_blueprint(self):
|
||||||
|
console.print("\n[bold yellow]✨ Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
You are a Creative Director.
|
||||||
|
Create a comprehensive Book Bible for the following project.
|
||||||
|
|
||||||
|
PROJECT METADATA: {json.dumps(self.data['project_metadata'])}
|
||||||
|
EXISTING BOOKS STRUCTURE: {json.dumps(self.data['books'])}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
1. Create a list of Main Characters (Global for the project).
|
||||||
|
2. For EACH book in the 'books' list:
|
||||||
|
- Generate a catchy Title (if not provided).
|
||||||
|
- Write a 'manual_instruction' (Plot Summary).
|
||||||
|
- Generate 'plot_beats' (10-15 chronological beats).
|
||||||
|
|
||||||
|
RETURN JSON in standard Bible format:
|
||||||
|
{{
|
||||||
|
"characters": [ {{ "name": "...", "role": "...", "description": "..." }} ],
|
||||||
|
"books": [
|
||||||
|
{{ "book_number": 1, "title": "...", "manual_instruction": "...", "plot_beats": ["...", "..."] }},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
new_data = self.ask_gemini_json(prompt)
|
||||||
|
if new_data:
|
||||||
|
if 'characters' in new_data:
|
||||||
|
self.data['characters'] = new_data['characters']
|
||||||
|
|
||||||
|
if 'books' in new_data:
|
||||||
|
# Merge book data carefully
|
||||||
|
ai_books = {b.get('book_number'): b for b in new_data['books']}
|
||||||
|
for i, book in enumerate(self.data['books']):
|
||||||
|
b_num = book.get('book_number', i+1)
|
||||||
|
if b_num in ai_books:
|
||||||
|
src = ai_books[b_num]
|
||||||
|
book['title'] = src.get('title', book['title'])
|
||||||
|
book['manual_instruction'] = src.get('manual_instruction', book['manual_instruction'])
|
||||||
|
book['plot_beats'] = src.get('plot_beats', [])
|
||||||
|
|
||||||
|
console.print("[green]Blueprint enriched (Missing data filled)![/green]")
|
||||||
|
|
||||||
|
def display_summary(self, data):
|
||||||
|
meta = data.get('project_metadata', {})
|
||||||
|
length = meta.get('length_settings', {})
|
||||||
|
style = meta.get('style', {})
|
||||||
|
|
||||||
|
# Metadata Grid
|
||||||
|
grid = Table.grid(padding=(0, 2))
|
||||||
|
grid.add_column(style="bold cyan")
|
||||||
|
grid.add_column()
|
||||||
|
|
||||||
|
grid.add_row("Title:", meta.get('title', 'N/A'))
|
||||||
|
grid.add_row("Author:", meta.get('author', 'N/A'))
|
||||||
|
grid.add_row("Genre:", meta.get('genre', 'N/A'))
|
||||||
|
grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
|
||||||
|
|
||||||
|
# Dynamic Style Display
|
||||||
|
# Define explicit order for common fields
|
||||||
|
ordered_keys = [
|
||||||
|
"tone", "pov_style", "pov_characters",
|
||||||
|
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
|
||||||
|
"tropes"
|
||||||
|
]
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
"tone": "Balanced",
|
||||||
|
"pov_style": "Third Person Limited",
|
||||||
|
"tense": "Past",
|
||||||
|
"spice": "Standard",
|
||||||
|
"violence": "Standard",
|
||||||
|
"language": "Standard",
|
||||||
|
"dialogue_style": "Standard",
|
||||||
|
"time_period": "Modern",
|
||||||
|
"page_orientation": "Portrait"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Show ordered keys first
|
||||||
|
for k in ordered_keys:
|
||||||
|
val = style.get(k)
|
||||||
|
if val in [None, "", "N/A"]:
|
||||||
|
val = defaults.get(k, 'N/A')
|
||||||
|
|
||||||
|
if isinstance(val, list): val = ", ".join(val)
|
||||||
|
if isinstance(val, bool): val = "Yes" if val else "No"
|
||||||
|
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
|
||||||
|
|
||||||
|
# 2. Show remaining keys
|
||||||
|
for k, v in style.items():
|
||||||
|
if k not in ordered_keys and k != 'formatting_rules':
|
||||||
|
val = ", ".join(v) if isinstance(v, list) else str(v)
|
||||||
|
grid.add_row(f"{k.replace('_', ' ').title()}:", val)
|
||||||
|
|
||||||
|
len_str = f"{length.get('label', 'N/A')} ({length.get('words', 'N/A')} words, {length.get('chapters', 'N/A')} ch)"
|
||||||
|
extras = []
|
||||||
|
if length.get('include_prologue'): extras.append("Prologue")
|
||||||
|
if length.get('include_epilogue'): extras.append("Epilogue")
|
||||||
|
if extras: len_str += f" + {', '.join(extras)}"
|
||||||
|
grid.add_row("Length:", len_str)
|
||||||
|
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No")
|
||||||
|
|
||||||
|
console.print(Panel(grid, title="[bold blue]📖 Project Metadata[/bold blue]", expand=False))
|
||||||
|
|
||||||
|
# Formatting Rules Table
|
||||||
|
fmt_rules = style.get('formatting_rules', [])
|
||||||
|
if fmt_rules:
|
||||||
|
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
|
||||||
|
for i, r in enumerate(fmt_rules):
|
||||||
|
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r))
|
||||||
|
console.print(Panel(fmt_table, title="[bold blue]🎨 Formatting[/bold blue]"))
|
||||||
|
|
||||||
|
# Characters Table
|
||||||
|
char_table = Table(title="👥 Characters", show_header=True, header_style="bold magenta", expand=True)
|
||||||
|
char_table.add_column("Name", style="green")
|
||||||
|
char_table.add_column("Role")
|
||||||
|
char_table.add_column("Description")
|
||||||
|
for c in data.get('characters', []):
|
||||||
|
# Removed truncation to show full description
|
||||||
|
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
|
||||||
|
console.print(char_table)
|
||||||
|
|
||||||
|
# Books List
|
||||||
|
for book in data.get('books', []):
|
||||||
|
console.print(f"\n[bold cyan]📘 Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
|
||||||
|
console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
|
||||||
|
|
||||||
|
beats = book.get('plot_beats', [])
|
||||||
|
if beats:
|
||||||
|
beat_table = Table(show_header=False, box=None, expand=True)
|
||||||
|
for i, b in enumerate(beats):
|
||||||
|
beat_table.add_row(f"[bold]{i+1}.[/bold]", str(b))
|
||||||
|
console.print(beat_table)
|
||||||
|
|
||||||
|
def refine_blueprint(self, title="Refine Blueprint"):
|
||||||
|
while True:
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel(f"[bold blue]🔧 {title}[/bold blue]"))
|
||||||
|
self.display_summary(self.data)
|
||||||
|
console.print("\n[dim](Full JSON loaded)[/dim]")
|
||||||
|
|
||||||
|
change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]")
|
||||||
|
if change.lower() == 'done': break
|
||||||
|
|
||||||
|
# Inner loop for refinement
|
||||||
|
current_data = self.data
|
||||||
|
instruction = change
|
||||||
|
|
||||||
|
while True:
|
||||||
|
with console.status("[bold green]AI is updating blueprint...[/bold green]"):
|
||||||
|
prompt = f"""
|
||||||
|
Act as a Book Editor.
|
||||||
|
CURRENT JSON: {json.dumps(current_data)}
|
||||||
|
USER INSTRUCTION: {instruction}
|
||||||
|
|
||||||
|
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
|
||||||
|
RETURN ONLY THE JSON.
|
||||||
|
"""
|
||||||
|
new_data = self.ask_gemini_json(prompt)
|
||||||
|
|
||||||
|
if not new_data:
|
||||||
|
console.print("[red]AI failed to generate valid JSON.[/red]")
|
||||||
|
break
|
||||||
|
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel("[bold blue]👀 Review AI Changes[/bold blue]"))
|
||||||
|
self.display_summary(new_data)
|
||||||
|
|
||||||
|
feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
|
||||||
|
|
||||||
|
if feedback.lower() == 'yes':
|
||||||
|
self.data = new_data
|
||||||
|
console.print("[green]Changes saved![/green]")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
current_data = new_data
|
||||||
|
instruction = feedback
|
||||||
|
|
||||||
|
def save_bible(self):
|
||||||
|
if not self.project_path:
|
||||||
|
console.print("[red]Project path not set. Cannot save bible.[/red]")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not os.path.exists(self.project_path): os.makedirs(self.project_path)
|
||||||
|
filename = os.path.join(self.project_path, "bible.json")
|
||||||
|
|
||||||
|
with open(filename, 'w') as f: json.dump(self.data, f, indent=2)
|
||||||
|
console.print(Panel(f"[bold green]✅ Bible saved to: {filename}[/bold green]"))
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def manage_runs(self, job_filename):
|
||||||
|
job_name = os.path.splitext(job_filename)[0]
|
||||||
|
runs_dir = os.path.join(self.project_path, "runs", job_name)
|
||||||
|
|
||||||
|
if not os.path.exists(runs_dir):
|
||||||
|
console.print("[red]No runs found for this job.[/red]")
|
||||||
|
Prompt.ask("Press Enter...")
|
||||||
|
return
|
||||||
|
|
||||||
|
runs = sorted([d for d in os.listdir(runs_dir) if os.path.isdir(os.path.join(runs_dir, d)) and d.startswith("run_")], key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0, reverse=True)
|
||||||
|
|
||||||
|
if not runs:
|
||||||
|
console.print("[red]No runs found.[/red]")
|
||||||
|
Prompt.ask("Press Enter...")
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel(f"[bold blue]Runs for: {job_name}[/bold blue]"))
|
||||||
|
for i, r in enumerate(runs):
|
||||||
|
console.print(f"[{i+1}] {r}")
|
||||||
|
console.print(f"[{len(runs)+1}] Back")
|
||||||
|
console.print(f"[{len(runs)+2}] Exit")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Run", choices=[str(i) for i in range(1, len(runs)+3)]))
|
||||||
|
if choice == len(runs) + 1: break
|
||||||
|
elif choice == len(runs) + 2: sys.exit()
|
||||||
|
|
||||||
|
selected_run = runs[choice-1]
|
||||||
|
run_path = os.path.join(runs_dir, selected_run)
|
||||||
|
|
||||||
|
self.manage_specific_run(run_path)
|
||||||
|
|
||||||
|
def manage_specific_run(self, run_path):
|
||||||
|
while True:
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]"))
|
||||||
|
|
||||||
|
# Detect sub-books (Series Run)
|
||||||
|
subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")])
|
||||||
|
|
||||||
|
if subdirs:
|
||||||
|
console.print("[italic]Series Run Detected[/italic]")
|
||||||
|
for i, s in enumerate(subdirs):
|
||||||
|
console.print(f"[{i+1}] Manage {s}")
|
||||||
|
|
||||||
|
idx_open = len(subdirs) + 1
|
||||||
|
idx_back = len(subdirs) + 2
|
||||||
|
idx_exit = len(subdirs) + 3
|
||||||
|
|
||||||
|
console.print(f"[{idx_open}] Open Run Folder")
|
||||||
|
console.print(f"[{idx_back}] Back")
|
||||||
|
console.print(f"[{idx_exit}] Exit")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, idx_exit+1)]))
|
||||||
|
|
||||||
|
if choice <= len(subdirs):
|
||||||
|
book_path = os.path.join(run_path, subdirs[choice-1])
|
||||||
|
self.manage_single_book_folder(book_path)
|
||||||
|
elif choice == idx_open:
|
||||||
|
self.open_folder(run_path)
|
||||||
|
elif choice == idx_back:
|
||||||
|
break
|
||||||
|
elif choice == idx_exit:
|
||||||
|
sys.exit()
|
||||||
|
else:
|
||||||
|
# Legacy or Flat Run
|
||||||
|
self.manage_single_book_folder(run_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
def manage_single_book_folder(self, folder_path):
|
||||||
|
while True:
|
||||||
|
self.clear()
|
||||||
|
console.print(Panel(f"[bold blue]Manage: {os.path.basename(folder_path)}[/bold blue]"))
|
||||||
|
console.print("1. Regenerate Cover & Recompile EPUB")
|
||||||
|
console.print("2. Open Folder")
|
||||||
|
console.print("3. Back")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
|
||||||
|
|
||||||
|
if choice == 1:
|
||||||
|
import main
|
||||||
|
bp_path = os.path.join(folder_path, "final_blueprint.json")
|
||||||
|
ms_path = os.path.join(folder_path, "manuscript.json")
|
||||||
|
|
||||||
|
if os.path.exists(bp_path) and os.path.exists(ms_path):
|
||||||
|
with console.status("[bold yellow]Regenerating Cover...[/bold yellow]"):
|
||||||
|
with open(bp_path, 'r') as f: bp = json.load(f)
|
||||||
|
with open(ms_path, 'r') as f: ms = json.load(f)
|
||||||
|
|
||||||
|
# Check/Generate Tracking
|
||||||
|
events_path = os.path.join(folder_path, "tracking_events.json")
|
||||||
|
chars_path = os.path.join(folder_path, "tracking_characters.json")
|
||||||
|
tracking = {"events": [], "characters": {}}
|
||||||
|
|
||||||
|
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path)
|
||||||
|
if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path)
|
||||||
|
|
||||||
|
main.ai.init_models()
|
||||||
|
|
||||||
|
if not tracking['events'] and not tracking['characters']:
|
||||||
|
# Fallback: Use Blueprint data
|
||||||
|
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
|
||||||
|
tracking['events'] = bp.get('plot_beats', [])
|
||||||
|
tracking['characters'] = {}
|
||||||
|
for c in bp.get('characters', []):
|
||||||
|
name = c.get('name', 'Unknown')
|
||||||
|
tracking['characters'][name] = {
|
||||||
|
"descriptors": [c.get('description', '')],
|
||||||
|
"likes_dislikes": [],
|
||||||
|
"last_worn": "Unknown"
|
||||||
|
}
|
||||||
|
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2)
|
||||||
|
with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
|
||||||
|
|
||||||
|
main.marketing.generate_cover(bp, folder_path, tracking)
|
||||||
|
main.export.compile_files(bp, ms, folder_path)
|
||||||
|
console.print("[green]Cover updated and EPUB recompiled![/green]")
|
||||||
|
Prompt.ask("Press Enter...")
|
||||||
|
else:
|
||||||
|
console.print("[red]Missing blueprint or manuscript in run folder.[/red]")
|
||||||
|
Prompt.ask("Press Enter...")
|
||||||
|
elif choice == 2:
|
||||||
|
self.open_folder(folder_path)
|
||||||
|
elif choice == 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
def open_folder(self, path):
|
||||||
|
if os.name == 'nt':
|
||||||
|
os.startfile(path)
|
||||||
|
else:
|
||||||
|
os.system(f"open '{path}'")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
w = BookWizard()
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
if w.select_mode():
|
||||||
|
while True:
|
||||||
|
w.clear()
|
||||||
|
console.print(Panel(f"[bold blue]📂 Project: {w.project_name}[/bold blue]"))
|
||||||
|
console.print("1. Edit Bible")
|
||||||
|
console.print("2. Run Book Generation")
|
||||||
|
console.print("3. Manage Runs")
|
||||||
|
console.print("4. Exit")
|
||||||
|
|
||||||
|
choice = int(Prompt.ask("Select Option", choices=["1", "2", "3", "4"]))
|
||||||
|
|
||||||
|
if choice == 1:
|
||||||
|
if w.load_bible():
|
||||||
|
w.refine_blueprint("Refine Bible")
|
||||||
|
w.save_bible()
|
||||||
|
elif choice == 2:
|
||||||
|
if w.load_bible():
|
||||||
|
bible_path = os.path.join(w.project_path, "bible.json")
|
||||||
|
import main
|
||||||
|
main.run_generation(bible_path)
|
||||||
|
Prompt.ask("\nGeneration complete. Press Enter...")
|
||||||
|
elif choice == 3:
|
||||||
|
# Manage runs for the bible
|
||||||
|
w.manage_runs("bible.json")
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")
|
||||||
Reference in New Issue
Block a user