Adds a full per-chapter evaluation logging system that captures every
score, critique, and quality decision made during writing, then renders
a self-contained HTML report shareable with critics or prompt engineers.
New file — story/eval_logger.py:
- append_eval_entry(folder, entry): writes per-chapter eval data to
eval_log.json in the book folder (called from write_chapter() at
every return point).
- generate_html_report(folder, bp): reads eval_log.json and produces a
self-contained HTML file (no external deps) with:
• Summary cards (avg score, auto-accepted, rewrites, below-threshold)
• Score timeline bar chart (one bar per chapter, colour-coded)
• Score distribution histogram
• Chapter breakdown table with expand-on-click critique details
(attempt number, score, decision badge, full critique text)
• Critique pattern frequency table (keyword mining across all critiques)
• Auto-generated prompt tuning observations (systemic issues, POV
character weak spots, pacing type analysis, climax vs. early
chapter comparison)
story/writer.py:
- Imports time and eval_logger.
- Initialises _eval_entry dict (chapter metadata + polish flags + thresholds)
after all threshold variables are set.
- Records each evaluation attempt's score, critique (truncated to 700 chars),
and decision (auto_accepted / full_rewrite / refinement / accepted /
below_threshold / eval_error / refinement_failed) before every return.
web/routes/run.py:
- Imports story_eval_logger.
- New route GET /project/<run_id>/eval_report/<book_folder>: loads
eval_log.json, calls generate_html_report(), returns the HTML as a
downloadable attachment named eval_report_<title>.html.
Returns a user-friendly "not yet available" page if no log exists.
templates/run_details.html:
- Adds "Eval Report" (btn-outline-info) button next to "Check Consistency"
in each book's artifact section.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
515 lines
20 KiB
Python
515 lines
20 KiB
Python
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, eval_logger as story_eval_logger
|
|
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):
|
|
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/<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('/run/<int:id>/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/<int:id>/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('/project/<int:run_id>/eval_report/<string:book_folder>')
|
|
@login_required
|
|
def eval_report(run_id, book_folder):
|
|
"""Generate and download the self-contained HTML evaluation report."""
|
|
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)
|
|
|
|
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json")) or \
|
|
utils.load_json(os.path.join(book_path, "blueprint_initial.json"))
|
|
|
|
html = story_eval_logger.generate_html_report(book_path, bp)
|
|
if not html:
|
|
return (
|
|
"<html><body style='font-family:sans-serif;padding:40px'>"
|
|
"<h2>No evaluation data yet.</h2>"
|
|
"<p>The evaluation report is generated during the writing phase. "
|
|
"Start a generation run and the report will be available once chapters have been evaluated.</p>"
|
|
"</body></html>"
|
|
), 200
|
|
|
|
from flask import Response
|
|
safe_title = utils.sanitize_filename(
|
|
(bp or {}).get('book_metadata', {}).get('title', book_folder) or book_folder
|
|
)[:40]
|
|
filename = f"eval_report_{safe_title}.html"
|
|
return Response(
|
|
html,
|
|
mimetype='text/html',
|
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
|
|
)
|
|
|
|
|
|
@run_bp.route('/run/<int:id>/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/<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))
|