From dc39930da4ec9279e1c61d3983135ca66a91bd10 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Sun, 22 Feb 2026 22:45:54 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Implement=20ai=5Fblueprint.md=20Steps?= =?UTF-8?q?=201=20&=202=20=E2=80=94=20bible-tracking=20merge=20and=20chara?= =?UTF-8?q?cter=20voice=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli/engine.py | 7 ++++++- story/bible_tracker.py | 24 ++++++++++++++++++++++++ story/style_persona.py | 35 +++++++++++++++++++++++++++++++++-- story/writer.py | 15 +++++++++++++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/cli/engine.py b/cli/engine.py index 56e1a90..1b8da69 100644 --- a/cli/engine.py +++ b/cli/engine.py @@ -219,7 +219,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False): # Refine Persona to match the actual output (every 5 chapters) 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) 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', {})) 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) current_story_state = story_state.update_story_state(txt, ch['chapter_number'], current_story_state, folder) diff --git a/story/bible_tracker.py b/story/bible_tracker.py index 1ff47ce..6d9d16f 100644 --- a/story/bible_tracker.py +++ b/story/bible_tracker.py @@ -141,6 +141,30 @@ def update_lore_index(folder, chapter_text, 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): utils.log("HARVESTER", "Scanning for new characters...") full_text = "\n".join([c.get('content', '') for c in full_manuscript])[:500000] diff --git a/story/style_persona.py b/story/style_persona.py index 5b9a1a8..ce813bb 100644 --- a/story/style_persona.py +++ b/story/style_persona.py @@ -184,11 +184,42 @@ def validate_persona(bp, persona_details, folder): 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...") 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""" ROLE: Literary Stylist TASK: Refine Author Bio based on text sample. diff --git a/story/writer.py b/story/writer.py index 24c39be..215c40e 100644 --- a/story/writer.py +++ b/story/writer.py @@ -168,8 +168,19 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, pov_char = chap.get('pov_character', '') - # Use pre-loaded persona if provided (avoids re-reading sample files every chapter) - if prebuilt_persona is not None: + # Check for character-specific voice profile (Step 2: Character Voice Profiles) + 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 else: persona_info = build_persona_info(bp) or "Standard, balanced writing style."