Final changes and update

This commit is contained in:
2026-02-04 20:19:07 -05:00
parent 6e7ff0ae1d
commit 9f8f094564
21 changed files with 1816 additions and 645 deletions

View File

@@ -1,20 +1,22 @@
import os
import json
import html
import shutil
import markdown
from functools import wraps
from types import SimpleNamespace
from datetime import datetime, timedelta
from sqlalchemy import func
from urllib.parse import urlparse, urljoin
from sqlalchemy import func, text
from sqlalchemy.exc import IntegrityError
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, session
from flask_login import LoginManager, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from .web_db import db, User, Project, Run, LogEntry
from .web_tasks import huey, generate_book_task, regenerate_artifacts_task
from .web_tasks import huey, generate_book_task, regenerate_artifacts_task, rewrite_chapter_task
import config
from . import utils
from . import ai
from . import story
from . import export
# Calculate paths relative to this file (modules/web_app.py)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -35,85 +37,9 @@ login_manager.init_app(app)
def load_user(user_id):
return db.session.get(User, int(user_id))
def migrate_logs():
"""Parses old log files and inserts them into the database."""
runs = Run.query.all()
migrated = 0
files_to_clean = []
for run in runs:
# Check if DB logs exist
has_db_logs = LogEntry.query.filter_by(run_id=run.id).first() is not None
# Locate Log File
log_path = run.log_file
if not log_path or not os.path.exists(log_path):
# Try common fallback locations
candidates = [
os.path.join(run.project.folder_path, f"system_log_{run.id}.txt"),
os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}", "web_console.log")
]
for c in candidates:
if os.path.exists(c):
log_path = c
break
if log_path and os.path.exists(log_path):
if has_db_logs:
# Logs are already in DB (New Run or previous migration). Mark file for cleanup.
files_to_clean.append(log_path)
continue
try:
with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
entries = []
for line in lines:
# Parse standard log format: [HH:MM:SS] PHASE | Message
if '|' in line and line.strip().startswith('['):
try:
parts = line.split('|', 1)
meta = parts[0].strip()
msg = parts[1].strip()
if ']' in meta:
ts_str = meta[1:meta.find(']')]
phase = meta[meta.find(']')+1:].strip()
# Reconstruct datetime
base_date = run.start_time.date() if run.start_time else datetime.utcnow().date()
t_time = datetime.strptime(ts_str, "%H:%M:%S").time()
dt = datetime.combine(base_date, t_time)
entries.append(LogEntry(run_id=run.id, timestamp=dt, phase=phase, message=msg))
except: continue
if entries:
db.session.add_all(entries)
migrated += 1
files_to_clean.append(log_path)
except Exception as e:
print(f"Migration failed for Run {run.id}: {e}")
if migrated > 0:
db.session.commit()
print(f"✅ Migrated logs for {migrated} runs to Database.")
# Cleanup files (even if no new migrations happened)
if files_to_clean:
count = 0
for fpath in files_to_clean:
try:
os.remove(fpath)
count += 1
except: pass
if count > 0:
print(f"🧹 Cleaned up {count} redundant log files.")
# --- SETUP ---
with app.app_context():
db.create_all()
migrate_logs()
# Auto-create Admin from Environment Variables (Docker/Portainer Setup)
if config.ADMIN_USER and config.ADMIN_PASSWORD:
@@ -126,6 +52,26 @@ with app.app_context():
elif not admin.is_admin:
admin.is_admin = True
db.session.commit()
# Migration: Add 'progress' column if missing
try:
with db.engine.connect() as conn:
conn.execute(text("ALTER TABLE run ADD COLUMN progress INTEGER DEFAULT 0"))
conn.commit()
print("✅ System: Added 'progress' column to Run table.")
except: pass
# Reset stuck runs on startup
try:
stuck_runs = Run.query.filter_by(status='running').all()
if stuck_runs:
print(f"⚠️ System: Found {len(stuck_runs)} stuck runs. Resetting to 'failed'.")
for r in stuck_runs:
r.status = 'failed'
r.end_time = datetime.utcnow()
db.session.commit()
except Exception as e:
print(f"⚠️ System: Failed to clean up stuck runs: {e}")
# --- DECORATORS ---
def admin_required(f):
@@ -137,6 +83,16 @@ def admin_required(f):
return f(*args, **kwargs)
return decorated_function
def is_project_locked(project_id):
"""Returns True if the project has any completed runs (Book 1 written)."""
return Run.query.filter_by(project_id=project_id, status='completed').count() > 0
def is_safe_url(target):
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return test_url.scheme in ('http', 'https') and \
ref_url.netloc == test_url.netloc
# --- ROUTES ---
@app.route('/')
@@ -153,7 +109,10 @@ def login():
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password, password):
login_user(user)
return redirect(url_for('index'))
next_page = request.args.get('next')
if not next_page or not is_safe_url(next_page):
next_page = url_for('index')
return redirect(next_page)
flash('Invalid credentials')
return render_template('login.html')
@@ -167,10 +126,18 @@ def register():
return redirect(url_for('register'))
new_user = User(username=username, password=generate_password_hash(password, method='pbkdf2:sha256'))
db.session.add(new_user)
db.session.commit()
login_user(new_user)
return redirect(url_for('index'))
# Auto-promote if matches env var
if config.ADMIN_USER and username == config.ADMIN_USER:
new_user.is_admin = True
try:
db.session.add(new_user)
db.session.commit()
login_user(new_user)
return redirect(url_for('index'))
except IntegrityError:
db.session.rollback()
flash('Username exists')
return redirect(url_for('register'))
return render_template('register.html')
@app.route('/project/setup', methods=['POST'])
@@ -280,16 +247,16 @@ def project_setup_refine():
@login_required
def create_project_final():
title = request.form.get('title')
safe_title = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_")
safe_title = utils.sanitize_filename(title)
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
if not os.path.exists(user_dir): os.makedirs(user_dir)
os.makedirs(user_dir, exist_ok=True)
proj_path = os.path.join(user_dir, safe_title)
if os.path.exists(proj_path):
safe_title += f"_{int(datetime.utcnow().timestamp())}"
proj_path = os.path.join(user_dir, safe_title)
os.makedirs(proj_path)
os.makedirs(proj_path, exist_ok=True)
# Construct Bible from Form Data
length_cat = request.form.get('length_category')
@@ -349,12 +316,11 @@ def create_project_final():
"plot_beats": []
})
# Enrich via AI immediately if concept exists
if concept:
try:
ai.init_models()
bible = story.enrich(bible, proj_path)
except: pass
# Enrich via AI immediately (Always, to ensure Bible is full)
try:
ai.init_models()
bible = story.enrich(bible, proj_path)
except: pass
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
json.dump(bible, f, indent=2)
@@ -365,6 +331,52 @@ def create_project_final():
return redirect(url_for('view_project', id=new_proj.id))
@app.route('/project/import', methods=['POST'])
@login_required
def import_project():
if 'bible_file' not in request.files:
flash('No file part')
return redirect(url_for('index'))
file = request.files['bible_file']
if file.filename == '':
flash('No selected file')
return redirect(url_for('index'))
if file:
try:
bible = json.load(file)
# Basic validation
if 'project_metadata' not in bible or 'title' not in bible['project_metadata']:
flash("Invalid Bible format: Missing project_metadata or title.")
return redirect(url_for('index'))
title = bible['project_metadata']['title']
safe_title = utils.sanitize_filename(title)
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
os.makedirs(user_dir, exist_ok=True)
proj_path = os.path.join(user_dir, safe_title)
if os.path.exists(proj_path):
safe_title += f"_{int(datetime.utcnow().timestamp())}"
proj_path = os.path.join(user_dir, safe_title)
os.makedirs(proj_path)
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
json.dump(bible, f, indent=2)
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
db.session.add(new_proj)
db.session.commit()
flash(f"Project '{title}' imported successfully.")
return redirect(url_for('view_project', id=new_proj.id))
except Exception as e:
flash(f"Import failed: {str(e)}")
return redirect(url_for('index'))
@app.route('/project/<int:id>')
@login_required
def view_project(id):
@@ -393,11 +405,12 @@ def view_project(id):
artifacts = []
cover_image = None
generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str}
locked = is_project_locked(id)
# Scan ALL completed runs to find the latest status of each book
for r in runs:
if r.status == 'completed':
run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{r.id}")
run_dir = os.path.join(proj.folder_path, "runs", f"run_{r.id}")
if os.path.exists(run_dir):
# 1. Scan for Generated Books
for d in os.listdir(run_dir):
@@ -420,13 +433,13 @@ def view_project(id):
# Collect Artifacts from Latest Run
if latest_run:
run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}")
run_dir = os.path.join(proj.folder_path, "runs", f"run_{latest_run.id}")
if os.path.exists(run_dir):
# Find Cover Image (Root or First Book)
if os.path.exists(os.path.join(run_dir, "cover.png")):
cover_image = "cover.png"
else:
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
subdirs = utils.get_sorted_book_folders(run_dir)
for d in subdirs:
if os.path.exists(os.path.join(run_dir, d, "cover.png")):
cover_image = os.path.join(d, "cover.png").replace("\\", "/")
@@ -442,7 +455,7 @@ def view_project(id):
'type': f.split('.')[-1].upper()
})
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects)
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects, locked=locked)
@app.route('/project/<int:id>/run', methods=['POST'])
@login_required
@@ -477,6 +490,10 @@ def update_project_metadata(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('view_project', id=id))
new_title = request.form.get('title')
new_author = request.form.get('author')
@@ -495,12 +512,56 @@ def update_project_metadata(id):
return redirect(url_for('view_project', id=id))
@app.route('/project/<int:id>/clone', methods=['POST'])
@login_required
def clone_project(id):
source_proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if source_proj.user_id != current_user.id: return "Unauthorized", 403
new_name = request.form.get('new_name')
instruction = request.form.get('instruction')
# Create New Project
safe_title = utils.sanitize_filename(new_name)
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
new_path = os.path.join(user_dir, safe_title)
if os.path.exists(new_path):
safe_title += f"_{int(datetime.utcnow().timestamp())}"
new_path = os.path.join(user_dir, safe_title)
os.makedirs(new_path)
# Copy Bible
source_bible_path = os.path.join(source_proj.folder_path, "bible.json")
if os.path.exists(source_bible_path):
bible = utils.load_json(source_bible_path)
bible['project_metadata']['title'] = new_name
# Apply AI Instruction if provided
if instruction:
try:
ai.init_models()
bible = story.refine_bible(bible, instruction, new_path) or bible
except: pass
with open(os.path.join(new_path, "bible.json"), 'w') as f: json.dump(bible, f, indent=2)
new_proj = Project(user_id=current_user.id, name=new_name, folder_path=new_path)
db.session.add(new_proj)
db.session.commit()
flash(f"Project cloned as '{new_name}'.")
return redirect(url_for('view_project', id=new_proj.id))
@app.route('/project/<int:id>/refine_bible', methods=['POST'])
@login_required
def refine_bible_route(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('view_project', id=id))
instruction = request.form.get('instruction')
if not instruction:
flash("Instruction required.")
@@ -533,6 +594,10 @@ def add_book(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('view_project', id=id))
title = request.form.get('title', 'Untitled')
instruction = request.form.get('instruction', '')
@@ -566,6 +631,10 @@ def update_book_details(id, book_num):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('view_project', id=id))
new_title = request.form.get('title')
new_instruction = request.form.get('instruction')
@@ -576,7 +645,7 @@ def update_book_details(id, book_num):
for b in bible['books']:
if b.get('book_number') == book_num:
if new_title: b['title'] = new_title
if new_instruction: b['manual_instruction'] = new_instruction
if new_instruction is not None: b['manual_instruction'] = new_instruction
break
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
flash(f"Book {book_num} updated.")
@@ -589,6 +658,10 @@ def delete_book(id, book_num):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('view_project', id=id))
bible_path = os.path.join(proj.folder_path, "bible.json")
bible = utils.load_json(bible_path)
@@ -617,6 +690,10 @@ def import_characters(id):
if not target_proj or not source_proj: return "Project not found", 404
if target_proj.user_id != current_user.id or source_proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('view_project', id=id))
target_bible = utils.load_json(os.path.join(target_proj.folder_path, "bible.json"))
source_bible = utils.load_json(os.path.join(source_proj.folder_path, "bible.json"))
@@ -644,6 +721,10 @@ def set_project_persona(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('view_project', id=id))
persona_name = request.form.get('persona_name')
bible_path = os.path.join(proj.folder_path, "bible.json")
@@ -671,11 +752,14 @@ def regenerate_artifacts(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
flash("Run is already active. Please wait for it to finish.")
return redirect(url_for('view_run', id=run_id))
feedback = request.form.get('feedback')
# Reset state immediately so UI polls correctly
run.status = 'queued'
LogEntry.query.filter_by(run_id=run_id).delete()
db.session.commit()
regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback)
@@ -692,6 +776,12 @@ def stop_run(id):
run.status = 'cancelled'
run.end_time = datetime.utcnow()
db.session.commit()
# Signal the backend process to stop by creating a .stop file
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
if os.path.exists(run_dir):
with open(os.path.join(run_dir, ".stop"), 'w') as f: f.write("stop")
flash(f"Run {id} marked as cancelled.")
return redirect(url_for('view_project', id=run.project_id))
@@ -709,10 +799,14 @@ def restart_run(id):
# Check mode: 'resume' (default) vs 'restart'
mode = request.form.get('mode', 'resume')
allow_copy = (mode == 'resume')
feedback = request.form.get('feedback')
keep_cover = 'keep_cover' in request.form
force_regen = 'force_regenerate' in request.form
allow_copy = (mode == 'resume' and not force_regen)
if feedback: allow_copy = False # Force regeneration if feedback provided to ensure changes are applied
task = generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy)
flash(f"Started new Run #{new_run.id}.")
task = generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy, feedback=feedback, source_run_id=id if feedback else None, keep_cover=keep_cover)
flash(f"Started new Run #{new_run.id}" + (" with modifications." if feedback else "."))
return redirect(url_for('view_project', id=run.project_id))
@app.route('/run/<int:id>')
@@ -732,13 +826,12 @@ def view_run(id):
with open(run.log_file, 'r') as f: log_content = f.read()
# Fetch Artifacts for Display
run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}")
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
# Detect Books in Run (Series Support)
books_data = []
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 not subdirs: subdirs = ["."] # Handle legacy/flat runs
subdirs = utils.get_sorted_book_folders(run_dir)
for d in subdirs:
b_path = os.path.join(run_dir, d)
@@ -766,8 +859,8 @@ def view_run(id):
# Load Tracking Data for Run Details
tracking = {"events": [], "characters": {}, "content_warnings": []}
# We load tracking from the first book found to populate the general stats
book_dir = os.path.join(run_dir, books_data[0]['folder']) if books_data else run_dir
# We load tracking from the LAST book found to populate the general stats (most up-to-date)
book_dir = os.path.join(run_dir, books_data[-1]['folder']) if books_data else run_dir
if os.path.exists(book_dir):
t_ev = os.path.join(book_dir, "tracking_events.json")
t_ch = os.path.join(book_dir, "tracking_characters.json")
@@ -789,11 +882,13 @@ def run_status(id):
# Check status from DB or fallback to log file
log_content = ""
last_log = None
# 1. Try Database Logs (Fastest & Best)
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
if logs:
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
last_log = logs[-1]
# 2. Fallback to File (For old runs or if DB logging fails)
if not log_content:
@@ -804,7 +899,15 @@ def run_status(id):
if os.path.exists(temp_log):
with open(temp_log, 'r') as f: log_content = f.read()
return {"status": run.status, "log": log_content, "cost": run.cost}
response = {"status": run.status, "log": log_content, "cost": run.cost, "percent": run.progress}
if last_log:
response["progress"] = {
"phase": last_log.phase,
"message": last_log.message,
"timestamp": last_log.timestamp.timestamp()
}
return response
@app.route('/project/<int:run_id>/download')
@login_required
@@ -813,25 +916,223 @@ def download_artifact(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}")
if not filename: return "Missing filename", 400
# Security Check: Prevent path traversal
# Combined check using normpath to ensure it stays within root and catches basic traversal chars
if os.path.isabs(filename) or ".." in os.path.normpath(filename) or ":" in filename:
return "Invalid filename", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
# If file not found in root, check subfolders (Series Support)
if not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir):
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
if subdirs:
# Try the first book folder
possible_path = os.path.join(subdirs[0], filename)
subdirs = utils.get_sorted_book_folders(run_dir)
# Scan all book folders
for d in subdirs:
possible_path = os.path.join(d, filename)
if os.path.exists(os.path.join(run_dir, possible_path)):
filename = possible_path
break
return send_from_directory(run_dir, filename, as_attachment=True)
@app.route('/project/<int:run_id>/read/<string:book_folder>')
@login_required
def read_book(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
# Security Check: Prevent path traversal in book_folder
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json")
if not os.path.exists(ms_path):
flash("Manuscript not found.")
return redirect(url_for('view_run', id=run_id))
manuscript = utils.load_json(ms_path)
# Sort by chapter number (Handle Prologue/Epilogue)
manuscript.sort(key=utils.chapter_sort_key)
# Render Markdown for display
for ch in manuscript:
ch['html_content'] = markdown.markdown(ch.get('content', ''))
return render_template('read_book.html', run=run, book_folder=book_folder, manuscript=manuscript)
@app.route('/project/<int:run_id>/save_chapter', methods=['POST'])
@login_required
def save_chapter(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
return "Cannot edit chapter while run is active.", 409
book_folder = request.form.get('book_folder')
chap_num_raw = request.form.get('chapter_num')
try: chap_num = int(chap_num_raw)
except: chap_num = chap_num_raw
new_content = request.form.get('content')
# Security Check
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
ms_path = os.path.join(run_dir, book_folder, "manuscript.json")
if os.path.exists(ms_path):
ms = utils.load_json(ms_path)
for ch in ms:
if ch.get('num') == chap_num:
ch['content'] = new_content
break
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
# Regenerate Artifacts (EPUB/DOCX) to reflect manual edits
book_path = os.path.join(run_dir, book_folder)
bp_path = os.path.join(book_path, "final_blueprint.json")
if os.path.exists(bp_path):
bp = utils.load_json(bp_path)
export.compile_files(bp, ms, book_path)
return "Saved", 200
return "Error", 500
@app.route('/project/<int:run_id>/check_consistency/<string:book_folder>')
@login_required
def check_consistency(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
# Security Check
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json"))
ms = utils.load_json(os.path.join(book_path, "manuscript.json"))
if not bp or not ms:
return "Data files missing or corrupt.", 404
try: ai.init_models()
except: pass
report = story.analyze_consistency(bp, ms, book_path)
return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder)
@app.route('/project/<int:run_id>/sync_book/<string:book_folder>', methods=['POST'])
@login_required
def sync_book_metadata(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
flash("Cannot sync metadata while run is active.")
return redirect(url_for('read_book', run_id=run_id, book_folder=book_folder))
# Security Check
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json")
bp_path = os.path.join(book_path, "final_blueprint.json")
if os.path.exists(ms_path) and os.path.exists(bp_path):
ms = utils.load_json(ms_path)
bp = utils.load_json(bp_path)
if not ms or not bp:
flash("Data files corrupt.")
return redirect(url_for('read_book', run_id=run_id, book_folder=book_folder))
try: ai.init_models()
except: pass
# 1. Harvest new characters/info from the EDITED text (Updates BP)
bp = story.harvest_metadata(bp, book_path, ms)
# 2. Sync Tracking (Ensure new characters exist in tracking file)
tracking_path = os.path.join(book_path, "tracking_characters.json")
if os.path.exists(tracking_path):
tracking_chars = utils.load_json(tracking_path) or {}
updated_tracking = False
for c in bp.get('characters', []):
if c.get('name') and c['name'] not in tracking_chars:
tracking_chars[c['name']] = {"descriptors": [c.get('description', '')], "likes_dislikes": [], "last_worn": "Unknown"}
updated_tracking = True
if updated_tracking:
with open(tracking_path, 'w') as f: json.dump(tracking_chars, f, indent=2)
# 3. Update Persona (Style might have changed during manual edits)
story.update_persona_sample(bp, book_path)
with open(bp_path, 'w') as f: json.dump(bp, f, indent=2)
flash("Metadata synced. Future generations will respect your edits.")
else:
flash("Files not found.")
return redirect(url_for('read_book', run_id=run_id, book_folder=book_folder))
@app.route('/project/<int:run_id>/rewrite_chapter', methods=['POST'])
@login_required
def rewrite_chapter(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id:
return {"error": "Unauthorized"}, 403
if run.status == 'running':
return {"error": "Cannot rewrite while run is active."}, 409
data = request.json
book_folder = data.get('book_folder')
chap_num = data.get('chapter_num')
instruction = data.get('instruction')
if not book_folder or chap_num is None or not instruction:
return {"error": "Missing parameters"}, 400
# Security Check
if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400
# Try to convert to int, but allow strings (e.g. "Epilogue")
try: chap_num = int(chap_num)
except: pass
# Start background task
task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction)
# Store task ID in session to poll for status
session['rewrite_task_id'] = task.id
return {"status": "queued", "task_id": task.id}, 202
@app.route('/task_status/<string:task_id>')
@login_required
def get_task_status(task_id):
task_result = huey.result(task_id, peek=True)
if task_result is None:
return {"status": "running"}
else:
return {"status": "completed", "success": task_result}
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('login'))
@app.route('/debug/routes')
@login_required
@admin_required
def debug_routes():
output = []
for rule in app.url_map.iter_rules():
@@ -844,6 +1145,7 @@ def debug_routes():
@app.route('/system/optimize_models', methods=['POST'])
@login_required
@admin_required
def optimize_models():
# Force refresh via AI module (safely handles failures)
try:
@@ -859,12 +1161,6 @@ def optimize_models():
return redirect(request.referrer or url_for('index'))
# --- COMPATIBILITY ROUTES (Fix 404s) ---
@app.route('/project/<int:project_id>/run/<int:run_id>')
@login_required
def legacy_run_redirect(project_id, run_id):
return redirect(url_for('view_run', id=run_id))
@app.route('/system/status')
@login_required
def system_status():
@@ -880,11 +1176,7 @@ def system_status():
models_info = cache_data.get('models', {})
except: pass
# Create a placeholder run object so the template doesn't crash
dummy_project = SimpleNamespace(user_id=current_user.id, name="System", folder_path="")
dummy_run = SimpleNamespace(id=0, status="System Status", cost=0.0, log_file=None, start_time=datetime.utcnow(), project=dummy_project, duration=lambda: "N/A")
return render_template('system_status.html', run=dummy_run, models=models_info, cache=cache_data, datetime=datetime)
return render_template('system_status.html', models=models_info, cache=cache_data, datetime=datetime)
@app.route('/personas')
@login_required
@@ -1105,7 +1397,7 @@ def admin_spend_report():
).join(Project, Project.user_id == User.id)\
.join(Run, Run.project_id == Project.id)\
.filter(Run.start_time >= start_date)\
.group_by(User.id).all()
.group_by(User.id, User.username).all()
report = []
total_period_spend = 0.0
@@ -1182,7 +1474,7 @@ if __name__ == '__main__':
c.run()
# Configuration
debug_mode = True
debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
# Run worker if: 1. In reloader child process OR 2. Reloader is disabled (debug=False)
if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not debug_mode: