New comparison feature.
This commit is contained in:
@@ -66,6 +66,57 @@ def refresh_style_guidelines(model, folder=None):
|
|||||||
utils.log("SYSTEM", f"Failed to refresh guidelines: {e}")
|
utils.log("SYSTEM", f"Failed to refresh guidelines: {e}")
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
def merge_selected_changes(original, draft, selected_keys):
|
||||||
|
"""Helper to merge specific fields from draft to original bible."""
|
||||||
|
# Sort keys to ensure deterministic order
|
||||||
|
def sort_key(k):
|
||||||
|
return [int(p) if p.isdigit() else p for p in k.split('.')]
|
||||||
|
selected_keys.sort(key=sort_key)
|
||||||
|
|
||||||
|
for key in selected_keys:
|
||||||
|
parts = key.split('.')
|
||||||
|
|
||||||
|
# Metadata: meta.title
|
||||||
|
if parts[0] == 'meta' and len(parts) == 2:
|
||||||
|
field = parts[1]
|
||||||
|
if field == 'tone':
|
||||||
|
original['project_metadata']['style']['tone'] = draft['project_metadata']['style']['tone']
|
||||||
|
elif field in original['project_metadata']:
|
||||||
|
original['project_metadata'][field] = draft['project_metadata'][field]
|
||||||
|
|
||||||
|
# Characters: char.0
|
||||||
|
elif parts[0] == 'char' and len(parts) >= 2:
|
||||||
|
idx = int(parts[1])
|
||||||
|
if idx < len(draft['characters']):
|
||||||
|
if idx < len(original['characters']):
|
||||||
|
original['characters'][idx] = draft['characters'][idx]
|
||||||
|
else:
|
||||||
|
original['characters'].append(draft['characters'][idx])
|
||||||
|
|
||||||
|
# Books: book.1.title
|
||||||
|
elif parts[0] == 'book' and len(parts) >= 2:
|
||||||
|
book_num = int(parts[1])
|
||||||
|
orig_book = next((b for b in original['books'] if b['book_number'] == book_num), None)
|
||||||
|
draft_book = next((b for b in draft['books'] if b['book_number'] == book_num), None)
|
||||||
|
|
||||||
|
if draft_book:
|
||||||
|
if not orig_book:
|
||||||
|
original['books'].append(draft_book)
|
||||||
|
original['books'].sort(key=lambda x: x.get('book_number', 999))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(parts) == 2:
|
||||||
|
orig_book['title'] = draft_book['title']
|
||||||
|
orig_book['manual_instruction'] = draft_book['manual_instruction']
|
||||||
|
|
||||||
|
elif len(parts) == 4 and parts[2] == 'beat':
|
||||||
|
beat_idx = int(parts[3])
|
||||||
|
if beat_idx < len(draft_book['plot_beats']):
|
||||||
|
while len(orig_book['plot_beats']) <= beat_idx:
|
||||||
|
orig_book['plot_beats'].append("")
|
||||||
|
orig_book['plot_beats'][beat_idx] = draft_book['plot_beats'][beat_idx]
|
||||||
|
return original
|
||||||
|
|
||||||
def filter_characters(chars):
|
def filter_characters(chars):
|
||||||
"""Removes placeholder characters generated by AI."""
|
"""Removes placeholder characters generated by AI."""
|
||||||
blacklist = ['name', 'character name', 'role', 'protagonist', 'antagonist', 'love interest', 'unknown', 'tbd', 'todo', 'hero', 'villain', 'main character', 'side character']
|
blacklist = ['name', 'character name', 'role', 'protagonist', 'antagonist', 'love interest', 'unknown', 'tbd', 'todo', 'hero', 'villain', 'main character', 'side character']
|
||||||
|
|||||||
@@ -570,29 +570,64 @@ def refine_bible_route(id):
|
|||||||
flash("Project is locked. Clone it to make changes.")
|
flash("Project is locked. Clone it to make changes.")
|
||||||
return redirect(url_for('view_project', id=id))
|
return redirect(url_for('view_project', id=id))
|
||||||
|
|
||||||
instruction = request.form.get('instruction')
|
# Handle JSON request (AJAX) or Form request
|
||||||
|
data = request.json if request.is_json else request.form
|
||||||
|
instruction = data.get('instruction')
|
||||||
|
|
||||||
if not instruction:
|
if not instruction:
|
||||||
flash("Instruction required.")
|
return {"error": "Instruction required"}, 400
|
||||||
return redirect(url_for('view_project', id=id))
|
|
||||||
|
|
||||||
|
source_type = data.get('source', 'original')
|
||||||
|
selected_keys = data.get('selected_keys')
|
||||||
|
if isinstance(selected_keys, str):
|
||||||
|
try: selected_keys = json.loads(selected_keys)
|
||||||
|
except: selected_keys = []
|
||||||
|
|
||||||
|
# Start Background Task
|
||||||
|
task = refine_bible_task(proj.folder_path, instruction, source_type, selected_keys)
|
||||||
|
|
||||||
|
return {"status": "queued", "task_id": task.id}
|
||||||
|
|
||||||
|
@app.route('/project/<int:id>/refine_bible/confirm', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def confirm_bible_refinement(id):
|
||||||
|
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||||
|
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||||
|
|
||||||
|
action = request.form.get('action')
|
||||||
|
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
||||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||||
bible = utils.load_json(bible_path)
|
|
||||||
|
|
||||||
if bible:
|
if action == 'accept_all':
|
||||||
try: ai.init_models()
|
if os.path.exists(draft_path):
|
||||||
except: pass
|
shutil.move(draft_path, bible_path)
|
||||||
|
|
||||||
if not ai.model_logic:
|
|
||||||
flash("AI models not initialized.")
|
|
||||||
return redirect(url_for('view_project', id=id))
|
|
||||||
|
|
||||||
new_bible = story.refine_bible(bible, instruction, proj.folder_path)
|
|
||||||
|
|
||||||
if new_bible:
|
|
||||||
with open(bible_path, 'w') as f: json.dump(new_bible, f, indent=2)
|
|
||||||
flash("Bible updated successfully.")
|
flash("Bible updated successfully.")
|
||||||
else:
|
else:
|
||||||
flash("AI failed to update Bible. Check logs.")
|
flash("Draft expired or missing.")
|
||||||
|
|
||||||
|
elif action == 'accept_selected':
|
||||||
|
if os.path.exists(draft_path) and os.path.exists(bible_path):
|
||||||
|
selected_keys_json = request.form.get('selected_keys', '[]')
|
||||||
|
try:
|
||||||
|
selected_keys = json.loads(selected_keys_json)
|
||||||
|
draft = utils.load_json(draft_path)
|
||||||
|
original = utils.load_json(bible_path)
|
||||||
|
|
||||||
|
original = _merge_selected_changes(original, draft, selected_keys)
|
||||||
|
|
||||||
|
with open(bible_path, 'w') as f: json.dump(original, f, indent=2)
|
||||||
|
os.remove(draft_path) # Cleanup draft after merge
|
||||||
|
flash(f"Merged {len(selected_keys)} changes into Bible.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Merge failed: {e}")
|
||||||
|
else:
|
||||||
|
flash("Files missing.")
|
||||||
|
|
||||||
|
elif action == 'decline':
|
||||||
|
if os.path.exists(draft_path):
|
||||||
|
os.remove(draft_path)
|
||||||
|
flash("Changes discarded.")
|
||||||
|
|
||||||
return redirect(url_for('view_project', id=id))
|
return redirect(url_for('view_project', id=id))
|
||||||
|
|
||||||
|
|||||||
@@ -368,3 +368,44 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("ERROR", f"Rewrite task exception for run {run_id}/{book_folder}: {e}")
|
utils.log("ERROR", f"Rewrite task exception for run {run_id}/{book_folder}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@huey.task()
|
||||||
|
def refine_bible_task(project_path, instruction, source_type, selected_keys=None):
|
||||||
|
"""
|
||||||
|
Background task to refine the Bible.
|
||||||
|
Handles partial merging of selected keys into a temp base before refinement.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bible_path = os.path.join(project_path, "bible.json")
|
||||||
|
draft_path = os.path.join(project_path, "bible_draft.json")
|
||||||
|
|
||||||
|
base_bible = utils.load_json(bible_path)
|
||||||
|
if not base_bible: return False
|
||||||
|
|
||||||
|
# If refining from draft, load it
|
||||||
|
if source_type == 'draft' and os.path.exists(draft_path):
|
||||||
|
draft_bible = utils.load_json(draft_path)
|
||||||
|
|
||||||
|
# If user selected specific changes, merge them into the base
|
||||||
|
# This creates a "Proposed State" to refine further, WITHOUT modifying bible.json
|
||||||
|
if selected_keys and draft_bible:
|
||||||
|
base_bible = story.merge_selected_changes(base_bible, draft_bible, selected_keys)
|
||||||
|
elif draft_bible:
|
||||||
|
# If no specific keys but source is draft, assume we refine the whole draft
|
||||||
|
base_bible = draft_bible
|
||||||
|
|
||||||
|
ai.init_models()
|
||||||
|
|
||||||
|
# Run AI Refinement
|
||||||
|
new_bible = story.refine_bible(base_bible, instruction, project_path)
|
||||||
|
|
||||||
|
if new_bible:
|
||||||
|
# Save to draft file (Overwrite previous draft)
|
||||||
|
with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
utils.log("ERROR", f"Bible refinement task failed: {e}")
|
||||||
|
return False
|
||||||
173
templates/bible_comparison.html
Normal file
173
templates/bible_comparison.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.diff-changed { background-color: #fff3cd; transition: background 0.5s; }
|
||||||
|
.diff-added { background-color: #d1e7dd; transition: background 0.5s; }
|
||||||
|
.diff-removed { background-color: #f8d7da; text-decoration: line-through; opacity: 0.7; transition: background 0.5s; }
|
||||||
|
.select-checkbox { transform: scale(1.2); cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="fas fa-balance-scale me-2"></i>Review Changes</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>Review the AI's proposed changes below. You can accept them, discard them, or ask for further refinements on the <strong>New Draft</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Bar -->
|
||||||
|
<div class="card shadow-sm mb-4 sticky-top" style="top: 20px; z-index: 100;">
|
||||||
|
<div class="card-body bg-light">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form id="refineForm" onsubmit="submitRefine(event)" class="d-flex">
|
||||||
|
<input type="hidden" name="source" value="draft">
|
||||||
|
<input type="hidden" name="selected_keys" id="refineSelectedKeys">
|
||||||
|
<input type="text" name="instruction" class="form-control me-2" placeholder="Refine this draft further (e.g. 'Fix the name spelling')..." required>
|
||||||
|
<button type="submit" class="btn btn-warning text-nowrap"><i class="fas fa-magic me-1"></i> Refine Draft</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-end">
|
||||||
|
<form action="/project/{{ project.id }}/refine_bible/confirm" method="POST" class="d-inline">
|
||||||
|
<div class="form-check form-switch d-inline-block me-3 align-middle">
|
||||||
|
<input class="form-check-input" type="checkbox" id="syncScroll" checked>
|
||||||
|
<label class="form-check-label small" for="syncScroll">Sync Scroll</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="action" value="decline" class="btn btn-outline-danger me-2"><i class="fas fa-times me-1"></i> Discard</button>
|
||||||
|
<button type="submit" name="action" value="accept" class="btn btn-success"><i class="fas fa-check me-1"></i> Accept Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% macro render_bible(bible) %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold border-bottom pb-1">Metadata</h6>
|
||||||
|
<dl class="row small mb-0">
|
||||||
|
<dt class="col-sm-4">Title</dt><dd class="col-sm-8" data-diff-key="meta.title">{{ bible.project_metadata.title }}</dd>
|
||||||
|
<dt class="col-sm-4">Genre</dt><dd class="col-sm-8" data-diff-key="meta.genre">{{ bible.project_metadata.genre }}</dd>
|
||||||
|
<dt class="col-sm-4">Tone</dt><dd class="col-sm-8" data-diff-key="meta.tone">{{ bible.project_metadata.style.tone }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold border-bottom pb-1">Characters ({{ bible.characters|length }})</h6>
|
||||||
|
<ul class="list-unstyled small">
|
||||||
|
{% for c in bible.characters %}
|
||||||
|
<li class="mb-2" data-diff-key="char.{{ loop.index0 }}">
|
||||||
|
<strong data-diff-key="char.{{ loop.index0 }}.name">{{ c.name }}</strong> <span class="badge bg-light text-dark border" data-diff-key="char.{{ loop.index0 }}.role">{{ c.role }}</span><br>
|
||||||
|
<span class="text-muted" data-diff-key="char.{{ loop.index0 }}.desc">{{ c.description }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted text-uppercase small fw-bold border-bottom pb-1">Plot Structure</h6>
|
||||||
|
{% for book in bible.books %}
|
||||||
|
<div class="mb-2" data-diff-key="book.{{ book.book_number }}">
|
||||||
|
<strong data-diff-key="book.{{ book.book_number }}.title">Book {{ book.book_number }}: {{ book.title }}</strong>
|
||||||
|
<p class="fst-italic small text-muted mb-1" data-diff-key="book.{{ book.book_number }}.instr">{{ book.manual_instruction }}</p>
|
||||||
|
<ol class="small ps-3 mb-0">
|
||||||
|
{% for beat in book.plot_beats %}
|
||||||
|
<li data-diff-key="book.{{ book.book_number }}.beat.{{ loop.index0 }}">{{ beat }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- ORIGINAL -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-secondary mb-4">
|
||||||
|
<div class="card-header bg-secondary text-white">
|
||||||
|
<h5 class="mb-0">Original</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body bg-light" id="original-col" style="max-height: 800px; overflow-y: auto;">
|
||||||
|
{{ render_bible(original) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEW DRAFT -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-success mb-4">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h5 class="mb-0">New Draft</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body bg-white" id="new-col" style="max-height: 800px; overflow-y: auto;">
|
||||||
|
{{ render_bible(new) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let refinePollInterval = null;
|
||||||
|
|
||||||
|
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...';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const original = document.getElementById('original-col');
|
||||||
|
const newDraft = document.getElementById('new-col');
|
||||||
|
|
||||||
|
// 1. Highlight Differences
|
||||||
|
const newElements = newDraft.querySelectorAll('[data-diff-key]');
|
||||||
|
newElements.forEach(el => {
|
||||||
|
const key = el.getAttribute('data-diff-key');
|
||||||
|
const origEl = original.querySelector(`[data-diff-key="${key}"]`);
|
||||||
|
|
||||||
|
if (!origEl) {
|
||||||
|
el.classList.add('diff-added');
|
||||||
|
el.title = "New item added";
|
||||||
|
} else if (el.innerText.trim() !== origEl.innerText.trim()) {
|
||||||
|
el.classList.add('diff-changed');
|
||||||
|
origEl.classList.add('diff-changed');
|
||||||
|
el.title = "Changed from original";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for removed items
|
||||||
|
const origElements = original.querySelectorAll('[data-diff-key]');
|
||||||
|
origElements.forEach(el => {
|
||||||
|
const key = el.getAttribute('data-diff-key');
|
||||||
|
const newEl = newDraft.querySelector(`[data-diff-key="${key}"]`);
|
||||||
|
if (!newEl) {
|
||||||
|
el.classList.add('diff-removed');
|
||||||
|
el.title = "Removed in new draft";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Sync Scroll
|
||||||
|
let isSyncingLeft = false;
|
||||||
|
let isSyncingRight = false;
|
||||||
|
|
||||||
|
original.onscroll = function() {
|
||||||
|
if (!isSyncingLeft && document.getElementById('syncScroll').checked) {
|
||||||
|
isSyncingRight = true;
|
||||||
|
newDraft.scrollTop = this.scrollTop;
|
||||||
|
}
|
||||||
|
isSyncingLeft = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
newDraft.onscroll = function() {
|
||||||
|
if (!isSyncingRight && document.getElementById('syncScroll').checked) {
|
||||||
|
isSyncingLeft = true;
|
||||||
|
original.scrollTop = this.scrollTop;
|
||||||
|
}
|
||||||
|
isSyncingRight = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<p class="text-muted">The AI has generated your characters and plot structure. Review them below and refine if needed.</p>
|
<p class="text-muted">The AI has generated your characters and plot structure. Review them below and refine if needed.</p>
|
||||||
|
|
||||||
<!-- Refinement Bar -->
|
<!-- Refinement Bar -->
|
||||||
<form action="/project/{{ project.id }}/refine_bible" method="POST" class="mb-4" onsubmit="showLoading(this)">
|
<form id="refineForm" onsubmit="submitRefine(event)" class="mb-4">
|
||||||
<div class="input-group shadow-sm">
|
<div class="input-group shadow-sm">
|
||||||
<span class="input-group-text bg-warning text-dark"><i class="fas fa-magic"></i></span>
|
<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>
|
<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>
|
||||||
@@ -88,5 +88,40 @@ function showLoading(form) {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Refining...';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Refining...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitRefine(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.target;
|
||||||
|
showLoading(form);
|
||||||
|
|
||||||
|
const instruction = form.querySelector('input[name="instruction"]').value;
|
||||||
|
|
||||||
|
fetch(`/project/{{ project.id }}/refine_bible`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ instruction: instruction, source: 'original' })
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.task_id) {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="refineProgressModal" tabindex="-1" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-body text-center p-4">
|
||||||
|
<div class="spinner-border text-warning mb-3" style="width: 3rem; height: 3rem;"></div>
|
||||||
|
<h4>Refining Bible...</h4><p class="text-muted">The AI is processing your changes.</p>
|
||||||
|
</div></div></div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('refineProgressModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
fetch(`/task_status/${data.task_id}`).then(r => r.json()).then(status => {
|
||||||
|
if (status.status === 'completed') window.location.href = `/project/{{ project.id }}/bible_comparison`;
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user