Files
bookapp/story/state.py
Mike Wichers 83a6a4315b 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>
2026-02-21 01:35:43 -05:00

95 lines
3.8 KiB
Python

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)