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:
@@ -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
94
story/state.py
Normal 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)
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user