feat: Implement ai_blueprint.md Steps 1 & 2 — bible-tracking merge and character voice profiles
Step 1 (Bible-Tracking Merge):
- Added merge_tracking_to_bible() to story/bible_tracker.py — merges character
tracking state and lore back into bible dict after each chapter, making
blueprint_initial.json the single persistent source of truth.
- Integrated in cli/engine.py after each chapter's update_tracking + update_lore_index
calls so the persisted bible is always up-to-date.
Step 2 (Character-Specific Voice Profiles):
- story/writer.py: write_chapter now checks bp['characters'] for a voice_profile on
the POV character before falling back to the prebuilt_persona cache.
- story/style_persona.py: refine_persona() accepts pov_character=None; when a POV
character with a voice_profile is supplied it refines that profile's bio instead of
the global author_details bio.
- cli/engine.py: refine_persona call now passes ch.get('pov_character') so per-chapter
persona refinement targets the correct voice.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -219,7 +219,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
|
|
||||||
# Refine Persona to match the actual output (every 5 chapters)
|
# Refine Persona to match the actual output (every 5 chapters)
|
||||||
if (i == 0 or i % 5 == 0) and txt:
|
if (i == 0 or i % 5 == 0) and txt:
|
||||||
bp['book_metadata']['author_details'] = style_persona.refine_persona(bp, txt, folder)
|
pov_char = ch.get('pov_character')
|
||||||
|
bp['book_metadata']['author_details'] = style_persona.refine_persona(bp, txt, folder, pov_character=pov_char)
|
||||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||||
cached_persona = build_persona_info(bp) # Rebuild cache with updated bio
|
cached_persona = build_persona_info(bp) # Rebuild cache with updated bio
|
||||||
|
|
||||||
@@ -275,6 +276,10 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
tracking['lore'] = bible_tracker.update_lore_index(folder, txt, tracking.get('lore', {}))
|
tracking['lore'] = bible_tracker.update_lore_index(folder, txt, tracking.get('lore', {}))
|
||||||
with open(lore_track_path, "w") as f: json.dump(tracking['lore'], f, indent=2)
|
with open(lore_track_path, "w") as f: json.dump(tracking['lore'], f, indent=2)
|
||||||
|
|
||||||
|
# Persist dynamic tracking changes back to the bible (Step 1: Bible-Tracking Merge)
|
||||||
|
bp = bible_tracker.merge_tracking_to_bible(bp, tracking)
|
||||||
|
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||||
|
|
||||||
# Update Structured Story State (Item 9: Thread Tracking)
|
# Update Structured Story State (Item 9: Thread Tracking)
|
||||||
current_story_state = story_state.update_story_state(txt, ch['chapter_number'], current_story_state, folder)
|
current_story_state = story_state.update_story_state(txt, ch['chapter_number'], current_story_state, folder)
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,30 @@ def update_lore_index(folder, chapter_text, current_lore):
|
|||||||
return current_lore
|
return current_lore
|
||||||
|
|
||||||
|
|
||||||
|
def merge_tracking_to_bible(bible, tracking):
|
||||||
|
"""Merge dynamic tracking state back into the bible dict.
|
||||||
|
|
||||||
|
Makes bible.json the single persistent source of truth by updating
|
||||||
|
character data and lore from the in-memory tracking object.
|
||||||
|
Returns the modified bible dict.
|
||||||
|
"""
|
||||||
|
for name, data in tracking.get('characters', {}).items():
|
||||||
|
matched = False
|
||||||
|
for char in bible.get('characters', []):
|
||||||
|
if char.get('name') == name:
|
||||||
|
char.update(data)
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
utils.log("TRACKER", f" -> Character '{name}' in tracking not found in bible. Skipping.")
|
||||||
|
|
||||||
|
if 'lore' not in bible:
|
||||||
|
bible['lore'] = {}
|
||||||
|
bible['lore'].update(tracking.get('lore', {}))
|
||||||
|
|
||||||
|
return bible
|
||||||
|
|
||||||
|
|
||||||
def harvest_metadata(bp, folder, full_manuscript):
|
def harvest_metadata(bp, folder, full_manuscript):
|
||||||
utils.log("HARVESTER", "Scanning for new characters...")
|
utils.log("HARVESTER", "Scanning for new characters...")
|
||||||
full_text = "\n".join([c.get('content', '') for c in full_manuscript])[:500000]
|
full_text = "\n".join([c.get('content', '') for c in full_manuscript])[:500000]
|
||||||
|
|||||||
@@ -184,11 +184,42 @@ def validate_persona(bp, persona_details, folder):
|
|||||||
return True, 7
|
return True, 7
|
||||||
|
|
||||||
|
|
||||||
def refine_persona(bp, text, folder):
|
def refine_persona(bp, text, folder, pov_character=None):
|
||||||
utils.log("SYSTEM", "Refining Author Persona based on recent chapters...")
|
utils.log("SYSTEM", "Refining Author Persona based on recent chapters...")
|
||||||
ad = bp.get('book_metadata', {}).get('author_details', {})
|
ad = bp.get('book_metadata', {}).get('author_details', {})
|
||||||
current_bio = ad.get('bio', 'Standard style.')
|
|
||||||
|
|
||||||
|
# If a POV character is given and has a voice_profile, refine that instead
|
||||||
|
if pov_character:
|
||||||
|
for char in bp.get('characters', []):
|
||||||
|
if char.get('name') == pov_character and char.get('voice_profile'):
|
||||||
|
vp = char['voice_profile']
|
||||||
|
current_bio = vp.get('bio', 'Standard style.')
|
||||||
|
prompt = f"""
|
||||||
|
ROLE: Literary Stylist
|
||||||
|
TASK: Refine a POV character's voice profile based on the text sample.
|
||||||
|
|
||||||
|
INPUT_DATA:
|
||||||
|
- TEXT_SAMPLE: {text[:3000]}
|
||||||
|
- CHARACTER: {pov_character}
|
||||||
|
- CURRENT_VOICE_BIO: {current_bio}
|
||||||
|
|
||||||
|
GOAL: Ensure future chapters for this POV character sound exactly like the sample. Highlight quirks, patterns, vocabulary specific to this character's perspective.
|
||||||
|
|
||||||
|
OUTPUT_FORMAT (JSON): {{ "bio": "Updated voice bio..." }}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = ai_models.model_logic.generate_content(prompt)
|
||||||
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||||
|
new_bio = json.loads(utils.clean_json(response.text)).get('bio')
|
||||||
|
if new_bio:
|
||||||
|
char['voice_profile']['bio'] = new_bio
|
||||||
|
utils.log("SYSTEM", f" -> Voice profile bio updated for '{pov_character}'.")
|
||||||
|
except Exception as e:
|
||||||
|
utils.log("SYSTEM", f" -> Voice profile refinement failed for '{pov_character}': {e}")
|
||||||
|
return ad # Return author_details unchanged
|
||||||
|
|
||||||
|
# Default: refine the main author persona bio
|
||||||
|
current_bio = ad.get('bio', 'Standard style.')
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
ROLE: Literary Stylist
|
ROLE: Literary Stylist
|
||||||
TASK: Refine Author Bio based on text sample.
|
TASK: Refine Author Bio based on text sample.
|
||||||
|
|||||||
@@ -168,8 +168,19 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
|||||||
|
|
||||||
pov_char = chap.get('pov_character', '')
|
pov_char = chap.get('pov_character', '')
|
||||||
|
|
||||||
# Use pre-loaded persona if provided (avoids re-reading sample files every chapter)
|
# Check for character-specific voice profile (Step 2: Character Voice Profiles)
|
||||||
if prebuilt_persona is not None:
|
character_voice = None
|
||||||
|
if pov_char:
|
||||||
|
for char in bp.get('characters', []):
|
||||||
|
if char.get('name') == pov_char and char.get('voice_profile'):
|
||||||
|
vp = char['voice_profile']
|
||||||
|
character_voice = f"Style/Bio: {vp.get('bio', '')}\nKeywords: {', '.join(vp.get('keywords', []))}"
|
||||||
|
utils.log("WRITER", f" -> Using voice profile for POV character: {pov_char}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if character_voice:
|
||||||
|
persona_info = character_voice
|
||||||
|
elif prebuilt_persona is not None:
|
||||||
persona_info = prebuilt_persona
|
persona_info = prebuilt_persona
|
||||||
else:
|
else:
|
||||||
persona_info = build_persona_info(bp) or "Standard, balanced writing style."
|
persona_info = build_persona_info(bp) or "Standard, balanced writing style."
|
||||||
|
|||||||
Reference in New Issue
Block a user