import os import json import shutil import markdown from datetime import datetime from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory from flask_login import login_required, current_user from web.db import db, Run, LogEntry from core import utils from ai import models as ai_models from ai import setup as ai_setup from story import editor as story_editor from story import bible_tracker, style_persona from export import exporter from web.tasks import huey, regenerate_artifacts_task, rewrite_chapter_task run_bp = Blueprint('run', __name__) @run_bp.route('/run/') @login_required def view_run(id): run = db.session.get(Run, id) if not run: return "Run not found", 404 if run.project.user_id != current_user.id: return "Unauthorized", 403 log_content = "" logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all() if logs: log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs]) elif run.log_file and os.path.exists(run.log_file): with open(run.log_file, 'r') as f: log_content = f.read() run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}") books_data = [] if os.path.exists(run_dir): subdirs = utils.get_sorted_book_folders(run_dir) for d in subdirs: b_path = os.path.join(run_dir, d) b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''} for f in os.listdir(b_path): if f.lower().endswith(('.epub', '.docx')): b_info['artifacts'].append({'name': f, 'path': os.path.join(d, f).replace("\\", "/")}) if os.path.exists(os.path.join(b_path, "cover.png")): b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/") blurb_p = os.path.join(b_path, "blurb.txt") if os.path.exists(blurb_p): with open(blurb_p, 'r', encoding='utf-8', errors='ignore') as f: b_info['blurb'] = f.read() books_data.append(b_info) bible_path = os.path.join(run.project.folder_path, "bible.json") bible_data = utils.load_json(bible_path) tracking = {"events": [], "characters": {}, "content_warnings": []} 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") t_wn = os.path.join(book_dir, "tracking_warnings.json") if os.path.exists(t_ev): tracking['events'] = utils.load_json(t_ev) or [] if os.path.exists(t_ch): tracking['characters'] = utils.load_json(t_ch) or {} if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or [] return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking) @run_bp.route('/run//status') @login_required def run_status(id): import sqlite3 as _sql3 import sys as _sys from core import config as _cfg # Expire session so we always read fresh values from disk (not cached state) db.session.expire_all() run = db.session.get(Run, id) if not run: return {"status": "not_found", "log": "", "cost": 0, "percent": 0, "start_time": None}, 404 log_content = "" last_log = None # 1. ORM query for log entries 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. Raw sqlite3 fallback — bypasses any SQLAlchemy session caching if not log_content: try: _db_path = os.path.join(_cfg.DATA_DIR, "bookapp.db") with _sql3.connect(_db_path, timeout=5) as _conn: _rows = _conn.execute( "SELECT timestamp, phase, message FROM log_entry WHERE run_id = ? ORDER BY timestamp", (id,) ).fetchall() if _rows: log_content = "\n".join([ f"[{str(r[0])[:8]}] {str(r[1]):<15} | {r[2]}" for r in _rows ]) except Exception as _e: print(f"[run_status] sqlite3 fallback error for run {id}: {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout) # 3. File fallback — reads the log file written by the task worker if not log_content: try: if run.log_file and os.path.exists(run.log_file): with open(run.log_file, 'r', encoding='utf-8', errors='replace') as f: log_content = f.read() elif run.status in ['queued', 'running']: project_folder = run.project.folder_path # Temp log written at task start (before run dir exists) temp_log = os.path.join(project_folder, f"system_log_{run.id}.txt") if os.path.exists(temp_log): with open(temp_log, 'r', encoding='utf-8', errors='replace') as f: log_content = f.read() else: # Also check inside the run directory (after engine creates it) run_dir = os.path.join(project_folder, "runs", f"run_{run.id}") console_log = os.path.join(run_dir, "web_console.log") if os.path.exists(console_log): with open(console_log, 'r', encoding='utf-8', errors='replace') as f: log_content = f.read() except Exception as _e: print(f"[run_status] file fallback error for run {id}: {type(_e).__name__}: {_e}", flush=True, file=_sys.stdout) response = { "status": run.status, "log": log_content, "cost": run.cost, "percent": run.progress, "start_time": run.start_time.timestamp() if run.start_time else None, "server_timestamp": datetime.utcnow().isoformat() + "Z", "db_log_count": len(logs), "latest_log_timestamp": last_log.timestamp.isoformat() if last_log else None, } if last_log: response["progress"] = { "phase": last_log.phase, "message": last_log.message, "timestamp": last_log.timestamp.timestamp() } return response @run_bp.route('/project//download') @login_required def download_artifact(run_id): filename = request.args.get('file') run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id) if run.project.user_id != current_user.id: return "Unauthorized", 403 if not filename: return "Missing filename", 400 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 not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir): subdirs = utils.get_sorted_book_folders(run_dir) 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) @run_bp.route('/project//read/') @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 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('run.view_run', id=run_id)) manuscript = utils.load_json(ms_path) manuscript.sort(key=utils.chapter_sort_key) 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) @run_bp.route('/project//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') 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 str(ch.get('num')) == str(chap_num): ch['content'] = new_content break with open(ms_path, 'w') as f: json.dump(ms, f, indent=2) 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) exporter.compile_files(bp, ms, book_path) return "Saved", 200 return "Error", 500 @run_bp.route('/project//check_consistency/') @login_required def check_consistency(run_id, book_folder): run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id) 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_setup.init_models() except: pass report = story_editor.analyze_consistency(bp, ms, book_path) return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder) @run_bp.route('/project//sync_book/', 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('run.read_book', run_id=run_id, book_folder=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") 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('run.read_book', run_id=run_id, book_folder=book_folder)) try: ai_setup.init_models() except: pass bp = bible_tracker.harvest_metadata(bp, book_path, ms) 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) style_persona.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('run.read_book', run_id=run_id, book_folder=book_folder)) @run_bp.route('/project//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 if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400 try: chap_num = int(chap_num) except: pass task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction) session['rewrite_task_id'] = task.id return {"status": "queued", "task_id": task.id}, 202 @run_bp.route('/task_status/') @login_required def get_task_status(task_id): try: task_result = huey.result(task_id, preserve=True) except Exception as e: return {"status": "completed", "success": False, "error": str(e)} if task_result is None: return {"status": "running"} else: return {"status": "completed", "success": task_result} @run_bp.route('/project//revise_book/', methods=['POST']) @login_required def revise_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: flash("Unauthorized.") return redirect(url_for('run.view_run', id=run_id)) if run.status == 'running': flash("A run is already active. Please wait for it to finish.") return redirect(url_for('run.view_run', id=run_id)) instruction = request.form.get('instruction', '').strip() if not instruction: flash("Please provide an instruction describing what to fix.") return redirect(url_for('run.check_consistency', run_id=run_id, book_folder=book_folder)) bible_path = os.path.join(run.project.folder_path, "bible.json") if not os.path.exists(bible_path): flash("Bible file not found. Cannot start revision.") return redirect(url_for('run.view_run', id=run_id)) new_run = Run(project_id=run.project_id, status='queued', start_time=datetime.utcnow()) db.session.add(new_run) db.session.commit() from web.tasks import generate_book_task generate_book_task(new_run.id, run.project.folder_path, bible_path, feedback=instruction, source_run_id=run.id) flash(f"Book revision queued. Instruction: '{instruction[:80]}...' — a new run has been started.") return redirect(url_for('run.view_run', id=new_run.id)) @run_bp.route('/run//set_tags', methods=['POST']) @login_required def set_tags(id): run = db.session.get(Run, id) if not run: return "Run not found", 404 if run.project.user_id != current_user.id: return "Unauthorized", 403 raw = request.form.get('tags', '') tags = [t.strip() for t in raw.split(',') if t.strip()] run.tags = ','.join(dict.fromkeys(tags)) db.session.commit() flash("Tags updated.") return redirect(url_for('run.view_run', id=id)) @run_bp.route('/run//delete', methods=['POST']) @login_required def delete_run(id): run = db.session.get(Run, id) if not run: return "Run not found", 404 if run.project.user_id != current_user.id: return "Unauthorized", 403 if run.status in ['running', 'queued']: flash("Cannot delete an active run. Stop it first.") return redirect(url_for('run.view_run', id=id)) project_id = run.project_id run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}") if os.path.exists(run_dir): shutil.rmtree(run_dir) db.session.delete(run) db.session.commit() flash(f"Run #{id} deleted successfully.") return redirect(url_for('project.view_project', id=project_id)) @run_bp.route('/run//download_bible') @login_required def download_bible(id): run = db.session.get(Run, id) if not run: return "Run not found", 404 if run.project.user_id != current_user.id: return "Unauthorized", 403 bible_path = os.path.join(run.project.folder_path, "bible.json") if not os.path.exists(bible_path): return "Bible file not found", 404 safe_name = utils.sanitize_filename(run.project.name or "project") download_name = f"bible_{safe_name}.json" return send_from_directory( os.path.dirname(bible_path), os.path.basename(bible_path), as_attachment=True, download_name=download_name ) @run_bp.route('/project//regenerate_artifacts', methods=['POST']) @login_required def regenerate_artifacts(run_id): run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id) if run.project.user_id != current_user.id: return "Unauthorized", 403 if run.status == 'running': flash("Run is already active. Please wait for it to finish.") return redirect(url_for('run.view_run', id=run_id)) feedback = request.form.get('feedback') run.status = 'queued' db.session.commit() regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback) flash("Regenerating cover and files with updated metadata...") return redirect(url_for('run.view_run', id=run_id))