Blueprint v2.4-2.6: Style Rules UI, Lore RAG, Thread Tracking, Redo Book

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>
This commit is contained in:
2026-02-21 01:35:43 -05:00
parent 2db7a35a66
commit 83a6a4315b
9 changed files with 291 additions and 27 deletions

View File

@@ -93,6 +93,42 @@ def update_tracking(folder, chapter_num, chapter_text, current_tracking):
return current_tracking
def update_lore_index(folder, chapter_text, current_lore):
"""Extract canonical descriptions of locations and key items from a chapter
and merge them into the lore index dict. Returns the updated lore dict."""
utils.log("TRACKER", "Updating lore index from chapter...")
prompt = f"""
ROLE: Lore Keeper
TASK: Extract canonical descriptions of locations and key items from this chapter.
EXISTING_LORE:
{json.dumps(current_lore)}
CHAPTER_TEXT:
{chapter_text[:15000]}
INSTRUCTIONS:
1. For each LOCATION mentioned: provide a 1-2 sentence canonical description (appearance, atmosphere, notable features).
2. For each KEY ITEM or ARTIFACT mentioned: provide a 1-2 sentence canonical description (appearance, properties, significance).
3. Do NOT add characters — only physical places and objects.
4. If an entry already exists in EXISTING_LORE, update or preserve it — do not duplicate.
5. Use the exact name as the key (e.g., "The Thornwood Inn", "The Sunstone Amulet").
6. Only include entries that have meaningful descriptive detail in the chapter text.
OUTPUT_FORMAT (JSON): {{"LocationOrItemName": "Description.", ...}}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_entries = json.loads(utils.clean_json(response.text))
if isinstance(new_entries, dict):
current_lore.update(new_entries)
return current_lore
except Exception as e:
utils.log("TRACKER", f"Lore index update failed: {e}")
return current_lore
def harvest_metadata(bp, folder, full_manuscript):
utils.log("HARVESTER", "Scanning for new characters...")
full_text = "\n".join([c.get('content', '') for c in full_manuscript])[:500000]

94
story/state.py Normal file
View File

@@ -0,0 +1,94 @@
import json
import os
from core import utils
from ai import models as ai_models
def _empty_state():
return {"active_threads": [], "immediate_handoff": "", "resolved_threads": [], "chapter": 0}
def load_story_state(folder):
"""Load structured story state from story_state.json, or return empty state."""
path = os.path.join(folder, "story_state.json")
if os.path.exists(path):
return utils.load_json(path) or _empty_state()
return _empty_state()
def update_story_state(chapter_text, chapter_num, current_state, folder):
"""Use model_logic to extract structured story threads from the new chapter
and save the updated state to story_state.json. Returns the new state."""
utils.log("STATE", f"Updating story state after Ch {chapter_num}...")
prompt = f"""
ROLE: Story State Tracker
TASK: Update the structured story state based on the new chapter.
CURRENT_STATE:
{json.dumps(current_state)}
NEW_CHAPTER (Chapter {chapter_num}):
{utils.truncate_to_tokens(chapter_text, 4000)}
INSTRUCTIONS:
1. ACTIVE_THREADS: 2-5 concise strings, each describing what a key character is currently trying to achieve.
- Carry forward unresolved threads from CURRENT_STATE.
- Add new threads introduced in this chapter.
- Remove threads that are now resolved.
2. IMMEDIATE_HANDOFF: Write exactly 3 sentences describing how this chapter ended:
- Sentence 1: Where are the key characters physically right now?
- Sentence 2: What emotional state are they in at the very end of this chapter?
- Sentence 3: What immediate unresolved threat, question, or decision is hanging in the air?
3. RESOLVED_THREADS: Carry forward from CURRENT_STATE + add threads explicitly resolved in this chapter.
OUTPUT_FORMAT (JSON):
{{
"active_threads": ["Thread 1", "Thread 2"],
"immediate_handoff": "Sentence 1. Sentence 2. Sentence 3.",
"resolved_threads": ["Resolved thread 1"],
"chapter": {chapter_num}
}}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_state = json.loads(utils.clean_json(response.text))
new_state['chapter'] = chapter_num
path = os.path.join(folder, "story_state.json")
with open(path, 'w') as f:
json.dump(new_state, f, indent=2)
utils.log("STATE", f" -> Story state saved. Active threads: {len(new_state.get('active_threads', []))}")
return new_state
except Exception as e:
utils.log("STATE", f" -> Story state update failed: {e}. Keeping previous state.")
return current_state
def format_for_prompt(state, chapter_beats=None):
"""Format the story state into a prompt-ready string.
Active threads and immediate handoff are always included.
Resolved threads are only included if referenced in the chapter's beats."""
if not state or (not state.get('immediate_handoff') and not state.get('active_threads')):
return None
beats_text = " ".join(str(b) for b in (chapter_beats or [])).lower()
lines = []
if state.get('immediate_handoff'):
lines.append(f"IMMEDIATE STORY HANDOFF (exactly how the previous chapter ended):\n{state['immediate_handoff']}")
if state.get('active_threads'):
lines.append("ACTIVE PLOT THREADS:")
for t in state['active_threads']:
lines.append(f" - {t}")
relevant_resolved = [
t for t in state.get('resolved_threads', [])
if any(w in beats_text for w in t.lower().split() if len(w) > 4)
]
if relevant_resolved:
lines.append("RESOLVED THREADS (context only — do not re-introduce):")
for t in relevant_resolved:
lines.append(f" - {t}")
return "\n".join(lines)

View File

@@ -189,6 +189,22 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
if items:
char_visuals += f" * Held Items: {', '.join(items)}\n"
# Build lore block: pull only locations/items relevant to this chapter
lore_block = ""
if tracking and tracking.get('lore'):
chapter_locations = chap.get('locations', [])
chapter_items = chap.get('key_items', [])
lore = tracking['lore']
relevant_lore = {
name: desc for name, desc in lore.items()
if any(name.lower() in ref.lower() or ref.lower() in name.lower()
for ref in chapter_locations + chapter_items)
}
if relevant_lore:
lore_block = "\nLORE_CONTEXT (Canonical descriptions for this chapter — use these exactly):\n"
for name, desc in relevant_lore.items():
lore_block += f"- {name}: {desc}\n"
style_block = "\n".join([f"- {k.replace('_', ' ').title()}: {v}" for k, v in style.items() if isinstance(v, (str, int, float))])
if 'tropes' in style and isinstance(style['tropes'], list):
style_block += f"\n- Tropes: {', '.join(style['tropes'])}"
@@ -282,6 +298,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
{prev_context_block}
- CHARACTERS: {json.dumps(chars_for_writer)}
{char_visuals}
{lore_block}
- SCENE_BEATS: {json.dumps(chap['beats'])}
{treatment_block}