New comparison feature.

This commit is contained in:
2026-02-04 21:21:57 -05:00
parent 48dca539cd
commit ca221f0fb3
5 changed files with 356 additions and 21 deletions

View File

@@ -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']

View File

@@ -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))

View File

@@ -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

View 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 %}

View File

@@ -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 %}