v2.4 — Item 7: Refresh Style Guidelines - web/routes/admin.py: Added /admin/refresh-style-guidelines route (AJAX-aware) - templates/system_status.html: Added 'Refresh Style Rules' button with spinner v2.5 — Item 8: Lore & Location RAG-Lite - story/bible_tracker.py: Added update_lore_index() — extracts location/item descriptions from chapters into tracking_lore.json - story/writer.py: Reads chapter locations/key_items, builds LORE_CONTEXT block injected into the prompt (graceful degradation if no tags) - cli/engine.py: Loads tracking_lore.json on resume, calls update_lore_index after each chapter, saves tracking_lore.json v2.5 — Item 9: Structured Story State (Thread Tracking) - story/state.py (new): load_story_state, update_story_state (extracts active_threads, immediate_handoff, resolved_threads via model_logic), format_for_prompt (structured context replacing the prev_sum blob) - cli/engine.py: Loads story_state.json on resume, uses format_for_prompt as summary_ctx for write_chapter, updates state after each chapter accepted v2.6 — Item 10: Redo Book - templates/consistency_report.html: Added 'Redo Book' form with instruction input and confirmation dialog - web/routes/run.py: Added revise_book route — creates new Run, queues generate_book_task with user instruction as feedback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
370 lines
14 KiB
Python
370 lines
14 KiB
Python
import os
|
|
import json
|
|
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/<int:id>')
|
|
@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/<int:id>/status')
|
|
@login_required
|
|
def run_status(id):
|
|
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
|
|
|
log_content = ""
|
|
last_log = None
|
|
|
|
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]
|
|
|
|
if not log_content:
|
|
if run.log_file and os.path.exists(run.log_file):
|
|
with open(run.log_file, 'r') as f: log_content = f.read()
|
|
elif run.status in ['queued', 'running']:
|
|
temp_log = os.path.join(run.project.folder_path, f"system_log_{run.id}.txt")
|
|
if os.path.exists(temp_log):
|
|
with open(temp_log, 'r') as f: log_content = f.read()
|
|
|
|
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
|
|
}
|
|
|
|
if last_log:
|
|
response["progress"] = {
|
|
"phase": last_log.phase,
|
|
"message": last_log.message,
|
|
"timestamp": last_log.timestamp.timestamp()
|
|
}
|
|
return response
|
|
|
|
|
|
@run_bp.route('/project/<int:run_id>/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/<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
|
|
|
|
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/<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')
|
|
|
|
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/<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)
|
|
|
|
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/<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('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/<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
|
|
|
|
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/<string:task_id>')
|
|
@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/<int:run_id>/revise_book/<string:book_folder>', 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('/project/<int:run_id>/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))
|