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}")
|
||||
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):
|
||||
"""Removes placeholder characters generated by AI."""
|
||||
blacklist = ['name', 'character name', 'role', 'protagonist', 'antagonist', 'love interest', 'unknown', 'tbd', 'todo', 'hero', 'villain', 'main character', 'side character']
|
||||
|
||||
@@ -569,30 +569,65 @@ def refine_bible_route(id):
|
||||
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.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible:
|
||||
try: ai.init_models()
|
||||
except: pass
|
||||
|
||||
if not ai.model_logic:
|
||||
flash("AI models not initialized.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
# Handle JSON request (AJAX) or Form request
|
||||
data = request.json if request.is_json else request.form
|
||||
instruction = data.get('instruction')
|
||||
|
||||
if not instruction:
|
||||
return {"error": "Instruction required"}, 400
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
if action == 'accept_all':
|
||||
if os.path.exists(draft_path):
|
||||
shutil.move(draft_path, bible_path)
|
||||
flash("Bible updated successfully.")
|
||||
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))
|
||||
|
||||
|
||||
@@ -367,4 +367,45 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
||||
return False
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Rewrite task exception for run {run_id}/{book_folder}: {e}")
|
||||
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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
@@ -88,5 +88,40 @@ function showLoading(form) {
|
||||
btn.disabled = true;
|
||||
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>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user