Files
bookapp/story/writer.py
Mike Wichers 4f2449f79b feat: Implement ai_blueprint_v2.md — Exp 5, 6 & 7 (persona validation, mid-gen consistency, two-pass drafting)
Exp 6 — Iterative Persona Validation (story/style_persona.py + cli/engine.py):
- Added validate_persona(): generates ~200-word sample in persona voice, scores 1–10 via
  lightweight voice-quality prompt; accepts if ≥ 7/10
- cli/engine.py retries create_initial_persona() up to 3× until validation passes
- Expected: -20% Phase 3 voice-drift rewrites

Exp 5 — Mid-gen Consistency Snapshots (cli/engine.py):
- analyze_consistency() called every 10 chapters inside the writing loop
- Issues logged as ⚠️ warnings; non-blocking; score and summary emitted
- Expected: -30% post-generation continuity error rate

Exp 7 — Two-Pass Drafting (story/writer.py):
- After Flash rough draft, Pro model (model_logic) polishes prose against a strict
  checklist: filter words, deep POV, active voice, AI-isms, chapter hook
- max_attempts reduced 3 → 2 since polished prose needs fewer rewrite cycles
- Expected: +0.3 HQS with no increase in per-chapter cost

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 22:08:47 -05:00

535 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
from core import config, utils
from ai import models as ai_models
from story.style_persona import get_style_guidelines
from story.editor import evaluate_chapter_quality
def get_genre_instructions(genre):
"""Return genre-specific writing mandates to inject into the draft prompt."""
g = genre.lower()
if any(x in g for x in ['thriller', 'mystery', 'crime', 'suspense']):
return (
"GENRE_MANDATES (Thriller/Mystery):\n"
"- Every scene must end on a hook: a revelation, reversal, or imminent threat.\n"
"- Clues must be planted through detail, not narrated as clues.\n"
"- Danger must feel visceral — use short, punchy sentences during action beats.\n"
"- Internal monologue must reflect calculation and suspicion, not passive observation.\n"
"- NEVER explain the mystery through the narrator — show the protagonist piecing it together."
)
elif any(x in g for x in ['romance', 'romantic']):
return (
"GENRE_MANDATES (Romance):\n"
"- Show attraction through micro-actions: eye contact, proximity, hesitation, body heat.\n"
"- NEVER tell the reader they feel attraction — render it through physical involuntary response.\n"
"- Dialogue must carry subtext — what is NOT said is as important as what is said.\n"
"- Every scene must shift the relationship dynamic (closer together or further apart).\n"
"- The POV character's emotional wound must be present even in light-hearted scenes."
)
elif any(x in g for x in ['fantasy', 'epic', 'sword', 'magic']):
return (
"GENRE_MANDATES (Fantasy):\n"
"- Introduce world-building through the POV character's reactions — not exposition dumps.\n"
"- Magic and the fantastical must have visible cost or consequence — no deus ex machina.\n"
"- Use concrete, grounded sensory details even in otherworldly settings.\n"
"- Character motivation must be rooted in tangible personal stakes, not abstract prophecy or destiny.\n"
"- NEVER use 'As you know Bob' exposition — characters who live in this world do not explain it to each other."
)
elif any(x in g for x in ['science fiction', 'sci-fi', 'scifi', 'space', 'cyberpunk']):
return (
"GENRE_MANDATES (Science Fiction):\n"
"- Introduce technology through its sensory and social impact, not technical exposition.\n"
"- The speculative premise must colour every scene — do not write contemporary fiction with sci-fi decoration.\n"
"- Characters must treat their environment as natives, not tourists — no wonder at ordinary things.\n"
"- Avoid anachronistic emotional or social responses inconsistent with the world's norms.\n"
"- Themes (AI, surveillance, cloning) must emerge from plot choices and character conflict, not speeches."
)
elif any(x in g for x in ['horror', 'dark', 'gothic']):
return (
"GENRE_MANDATES (Horror):\n"
"- Dread is built through implication — show what is wrong, never describe the monster directly.\n"
"- Use the environment as an active hostile force — the setting must feel alive and threatening.\n"
"- The POV character's psychology IS the true horror: isolation, doubt, paranoia.\n"
"- Avoid jump-scare prose (sudden capitalised noises). Build sustained, crawling unease.\n"
"- Sensory details must feel 'off' — wrong smells, sounds that don't belong, textures that repel."
)
elif any(x in g for x in ['historical', 'period', 'regency', 'victorian']):
return (
"GENRE_MANDATES (Historical Fiction):\n"
"- Characters must think and speak with period-accurate worldviews — avoid modern anachronisms.\n"
"- Historical detail must be woven into action and dialogue, never listed in descriptive passages.\n"
"- Social hierarchy and constraint must feel like real, material limits on character choices.\n"
"- Avoid modern idioms, slang, or metaphors that did not exist in the era.\n"
"- The tension between historical inevitability and personal agency is the engine of the story."
)
else:
return (
"GENRE_MANDATES (General Fiction):\n"
"- Every scene must change the character's situation, knowledge, or emotional state.\n"
"- Conflict must be present in every scene — internal, interpersonal, or external.\n"
"- Subtext: characters rarely say exactly what they mean — write the gap between intent and words.\n"
"- The end of every chapter must be earned through causality, not arbitrary stopping.\n"
"- Avoid coincidence as a plot driver — every event must have a clear cause."
)
def build_persona_info(bp):
"""Build the author persona string from bp['book_metadata']['author_details'].
Extracted as a standalone function so engine.py can pre-load the persona once
for the entire writing phase instead of re-reading sample files for every chapter.
Returns the assembled persona string, or None if no author_details are present.
"""
meta = bp.get('book_metadata', {})
ad = meta.get('author_details', {})
if not ad and 'author_bio' in meta:
return meta['author_bio']
if not ad:
return None
info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
if ad.get('age'): info += f"Age: {ad['age']}\n"
if ad.get('gender'): info += f"Gender: {ad['gender']}\n"
if ad.get('race'): info += f"Race: {ad['race']}\n"
if ad.get('nationality'): info += f"Nationality: {ad['nationality']}\n"
if ad.get('language'): info += f"Language: {ad['language']}\n"
if ad.get('bio'): info += f"Style/Bio: {ad['bio']}\n"
samples = []
if ad.get('sample_text'):
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
if ad.get('sample_files'):
for fname in ad['sample_files']:
fpath = os.path.join(config.PERSONAS_DIR, fname)
if os.path.exists(fpath):
try:
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read(3000)
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
except:
pass
if samples:
info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
return info
def expand_beats_to_treatment(beats, pov_char, genre, folder):
"""Expand sparse scene beats into a Director's Treatment using a fast model.
This pre-flight step gives the writer detailed staging and emotional direction,
reducing rewrites by preventing skipped beats and flat pacing."""
if not beats:
return None
prompt = f"""
ROLE: Story Director
TASK: Expand the following sparse scene beats into a concise "Director's Treatment".
GENRE: {genre}
POV_CHARACTER: {pov_char or 'Protagonist'}
SCENE_BEATS: {json.dumps(beats)}
For EACH beat, provide 3-4 sentences covering:
1. STAGING: Where are characters physically? How do they enter/exit the scene?
2. SENSORY ANCHOR: One specific sensory detail (sound, smell, texture) to ground the beat.
3. EMOTIONAL SHIFT: What is the POV character's internal state at the START vs END of this beat?
4. SUBTEXT: What does the POV character want vs. what they actually do or say?
OUTPUT: Prose treatment only. Do NOT write the chapter prose itself.
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
utils.log("WRITER", " -> Beat expansion complete.")
return response.text
except Exception as e:
utils.log("WRITER", f" -> Beat expansion failed: {e}. Using raw beats.")
return None
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, next_chapter_hint="", prebuilt_persona=None, chapter_position=None):
"""Write a single chapter with iterative quality evaluation.
Args:
prebuilt_persona: Pre-loaded persona string from build_persona_info(bp).
When provided, skips per-chapter file reads (persona cache optimisation).
chapter_position: Float 0.01.0 indicating position in book. Used for
adaptive scoring thresholds (setup = lenient, climax = strict).
"""
pacing = chap.get('pacing', 'Standard')
est_words = chap.get('estimated_words', 'Flexible')
utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}")
ls = bp['length_settings']
meta = bp.get('book_metadata', {})
style = meta.get('style', {})
genre = meta.get('genre', 'Fiction')
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:
persona_info = prebuilt_persona
else:
persona_info = build_persona_info(bp) or "Standard, balanced writing style."
# Only inject characters named in the chapter beats + the POV character
beats_text = " ".join(str(b) for b in chap.get('beats', []))
pov_lower = pov_char.lower() if pov_char else ""
chars_for_writer = [
{"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")}
for c in bp.get('characters', [])
if c.get("name") and (
c["name"].lower() in beats_text.lower() or
(pov_lower and c["name"].lower() == pov_lower)
)
]
if not chars_for_writer:
chars_for_writer = [
{"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")}
for c in bp.get('characters', [])
]
relevant_names = {c["name"] for c in chars_for_writer}
char_visuals = ""
if tracking and 'characters' in tracking:
char_visuals = "\nCHARACTER TRACKING (Visuals, State & Scene Position):\n"
for name, data in tracking['characters'].items():
if name not in relevant_names:
continue
desc = ", ".join(data.get('descriptors', []))
likes = ", ".join(data.get('likes_dislikes', []))
speech = data.get('speech_style', 'Unknown')
worn = data.get('last_worn', 'Unknown')
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n * Likes/Dislikes: {likes}\n"
major = data.get('major_events', [])
if major: char_visuals += f" * Major Events: {'; '.join(major)}\n"
if worn and worn != 'Unknown':
char_visuals += f" * Last Worn: {worn} (NOTE: Only relevant if scene is continuous from previous chapter)\n"
location = data.get('current_location', '')
items = data.get('held_items', [])
if location:
char_visuals += f" * Current Location: {location}\n"
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'])}"
if 'formatting_rules' in style and isinstance(style['formatting_rules'], list):
style_block += "\n- Formatting Rules:\n * " + "\n * ".join(style['formatting_rules'])
prev_context_block = ""
if prev_content:
trunc_content = utils.truncate_to_tokens(prev_content, 1000)
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (Last ~1000 Tokens — For Immediate Continuity):\n{trunc_content}\n"
# Skip beat expansion if beats are already detailed (saves ~5K tokens per chapter)
beats_list = chap.get('beats', [])
total_beat_words = sum(len(str(b).split()) for b in beats_list)
if total_beat_words > 100:
utils.log("WRITER", f" -> Beats already detailed ({total_beat_words} words). Skipping expansion.")
treatment = None
else:
utils.log("WRITER", f" -> Expanding beats to Director's Treatment...")
treatment = expand_beats_to_treatment(beats_list, pov_char, genre, folder)
treatment_block = f"\n DIRECTORS_TREATMENT (Staged expansion of the beats — use this as your scene blueprint; DRAMATIZE every moment, do NOT summarize):\n{treatment}\n" if treatment else ""
genre_mandates = get_genre_instructions(genre)
series_meta = bp.get('series_metadata', {})
series_block = ""
if series_meta.get('is_series'):
series_title = series_meta.get('series_title', 'this series')
book_num = series_meta.get('book_number', '?')
total_books = series_meta.get('total_books', '?')
series_block = (
f"\n - SERIES_CONTEXT: This is Book {book_num} of {total_books} in the '{series_title}' series. "
f"Pace character arcs and emotional resolution to reflect this book's position in the series: "
f"{'establish foundations, plant seeds, avoid premature resolution of series-level stakes' if str(book_num) == '1' else 'escalate the overarching conflict, deepen character arcs, end on a compelling hook that carries into the next book' if str(book_num) != str(total_books) else 'resolve all major character arcs and series-level conflicts with earned, satisfying payoffs'}."
)
total_chapters = ls.get('chapters', '?')
prompt = f"""
ROLE: Fiction Writer
TASK: Write Chapter {chap['chapter_number']}: {chap['title']}
METADATA:
- GENRE: {genre}
- FORMAT: {ls.get('label', 'Story')}
- POSITION: Chapter {chap['chapter_number']} of {total_chapters} — calibrate narrative tension accordingly (early = setup/intrigue, middle = escalation, final third = payoff/climax)
- PACING: {pacing} — see PACING_GUIDE below
- TARGET_WORDS: ~{est_words} (write to this length; do not summarise to save space)
- POV: {pov_char if pov_char else 'Protagonist'}{series_block}
PACING_GUIDE:
- 'Very Fast': Pure action/dialogue. Minimal description. Short punchy paragraphs.
- 'Fast': Keep momentum. No lingering. Cut to the next beat quickly.
- 'Standard': Balanced dialogue and description. Standard paragraph lengths.
- 'Slow': Detailed, atmospheric. Linger on emotion and environment.
- 'Very Slow': Deep introspection. Heavy sensory immersion. Slow burn tension.
STYLE_GUIDE:
{style_block}
AUTHOR_VOICE:
{persona_info}
{genre_mandates}
DEEP_POV_MANDATE (NON-NEGOTIABLE):
- SUMMARY MODE IS BANNED. Every scene beat must be DRAMATIZED in real-time. Do NOT write "Over the next hour they discussed..." — write the actual exchange.
- FILTER WORDS ARE BANNED: Do NOT write "She felt nervous," "He saw the door," "She realized she was late," "He noticed the knife." Instead, render the sensation directly: the reader must experience it, not be told about it.
- BANNED FILTER WORDS: felt, saw, heard, realized, decided, noticed, knew, thought, wondered, seemed, appeared, watched, observed, sensed — remove all instances and rewrite to show the underlying experience.
- EMOTION RENDERING: Never label an emotion. "She was terrified" → show the dry mouth, the locked knees, the way her vision narrowed to a single point. "He was angry" → show the jaw tightening, the controlled breath, the clipped syllables.
- DEEP POV means: the reader is inside the POV character's skull at all times. The prose must feel like consciousness, not narration about a character.
INSTRUCTIONS:
- Start with the Chapter Header formatted as Markdown H1 (e.g. '# Chapter X: Title'). Follow the 'Formatting Rules' for the header style.
- SENSORY ANCHORING: Start scenes by establishing Who, Where, and When immediately.
- DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state. (See DEEP_POV_MANDATE above.)
- SHOW, DON'T TELL: Focus on immediate action and internal reaction. NEVER summarize feelings; show the physical manifestation of them.
- CAUSALITY: Ensure events follow a "Because of X, Y happened" logic, not just "And then X, and then Y".
- STAGING: When characters enter, describe their entrance. Don't let them just "appear" in dialogue.
- SENSORY DETAILS: Use specific sensory details sparingly to ground the scene. Avoid stacking adjectives (e.g. "crisp white blouses, sharp legal briefs").
- ACTIVE VOICE: Use active voice. Subject -> Verb -> Object. Avoid "was/were" constructions.
- STRONG VERBS: Delete adverbs. Use specific verbs (e.g. "trudged" instead of "walked slowly").
- NO INFO-DUMPS: Weave backstory into dialogue or action. Do not stop the story to explain history.
- AVOID AI-ISMS: Banned phrases — 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to', 'tapestry of', 'azure', 'cerulean', 'delved', 'mined', 'bustling', 'neon-lit', 'a sense of', 'symphony of', 'the weight of'. Any of these appearing is an automatic quality failure.
- MAINTAIN CONTINUITY: Pay close attention to the PREVIOUS CONTEXT. Characters must NOT know things that haven't happened yet or haven't been revealed to them.
- CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers.
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
- GENRE CONSISTENCY: Ensure all introductions of characters, places, items, or actions are strictly appropriate for the {genre} genre. Avoid anachronisms or tonal clashes.
- DIALOGUE VOICE: Every character speaks with their own distinct voice (see CHARACTER TRACKING for speech styles). No two characters may sound the same. Vary sentence length, vocabulary, and register per character.
- CHAPTER HOOK: End this chapter with unresolved tension — a decision pending, a threat imminent, or a question unanswered.{f" Seed subtle anticipation for the next scene: '{next_chapter_hint}'." if next_chapter_hint else " Do not neatly resolve all threads."}
QUALITY_CRITERIA:
1. ENGAGEMENT & TENSION: Grip the reader. Ensure conflict/tension in every scene.
2. SCENE EXECUTION: Flesh out the middle. Avoid summarizing key moments.
3. VOICE & TONE: Distinct narrative voice matching the genre.
4. SENSORY IMMERSION: Engage all five senses.
5. SHOW, DON'T TELL: Show emotions through physical reactions and subtext.
6. CHARACTER AGENCY: Characters must drive the plot through active choices.
7. PACING: Avoid rushing. Ensure the ending lands with impact.
8. GENRE APPROPRIATENESS: Introductions of characters, places, items, or actions must be consistent with {genre} conventions.
9. DIALOGUE AUTHENTICITY: Characters must sound distinct. Use subtext. Avoid "on-the-nose" dialogue.
10. PLOT RELEVANCE: Every scene must advance the plot or character arcs. No filler.
11. STAGING & FLOW: Characters must enter and exit physically. Paragraphs must transition logically.
12. PROSE DYNAMICS: Vary sentence length. Use strong verbs. Avoid passive voice.
13. CLARITY: Ensure sentences are clear and readable. Avoid convoluted phrasing.
CONTEXT:
- STORY_SO_FAR: {prev_sum}
{prev_context_block}
- CHARACTERS: {json.dumps(chars_for_writer)}
{char_visuals}
{lore_block}
- SCENE_BEATS: {json.dumps(chap['beats'])}
{treatment_block}
OUTPUT: Markdown text.
"""
current_text = ""
try:
resp_draft = ai_models.model_writer.generate_content(prompt)
utils.log_usage(folder, ai_models.model_writer.name, resp_draft.usage_metadata)
current_text = resp_draft.text
draft_words = len(current_text.split()) if current_text else 0
utils.log("WRITER", f" -> Draft: {draft_words:,} words (target: ~{est_words})")
except Exception as e:
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
# Exp 7: Two-Pass Drafting — Polish the rough draft with the logic (Pro) model
# before evaluation. Produces cleaner prose with fewer rewrite cycles.
if current_text:
utils.log("WRITER", f" -> Two-pass polish (Pro model)...")
guidelines = get_style_guidelines()
fw_list = '", "'.join(guidelines['filter_words'])
polish_prompt = f"""
ROLE: Senior Fiction Editor
TASK: Polish this rough draft into publication-ready prose.
AUTHOR_VOICE:
{persona_info}
GENRE: {genre}
TARGET_WORDS: ~{est_words}
BEATS (must all be covered): {json.dumps(chap.get('beats', []))}
POLISH_CHECKLIST:
1. FILTER_REMOVAL: Remove all filter words [{fw_list}] — rewrite each to show the sensation directly.
2. DEEP_POV: Ensure the reader is inside the POV character's experience at all times — no external narration.
3. ACTIVE_VOICE: Replace all 'was/were + -ing' constructions with active alternatives.
4. SENTENCE_VARIETY: No two consecutive sentences starting with the same word. Vary length for rhythm.
5. STRONG_VERBS: Delete adverbs; replace with precise verbs.
6. NO_AI_ISMS: Remove: 'testament to', 'tapestry', 'palpable tension', 'azure', 'cerulean', 'bustling', 'a sense of'.
7. CHAPTER_HOOK: Ensure the final paragraph ends on unresolved tension, a question, or a threat.
8. PRESERVE: Keep all narrative beats, approximate word count (±15%), and chapter header.
ROUGH_DRAFT:
{current_text}
OUTPUT: Complete polished chapter in Markdown.
"""
try:
resp_polish = ai_models.model_logic.generate_content(polish_prompt)
utils.log_usage(folder, ai_models.model_logic.name, resp_polish.usage_metadata)
polished = resp_polish.text
if polished:
polished_words = len(polished.split())
utils.log("WRITER", f" -> Polished: {polished_words:,} words.")
current_text = polished
except Exception as e:
utils.log("WRITER", f" -> Polish pass failed: {e}. Proceeding with raw draft.")
# Reduced from 3 → 2 attempts since polish pass already refines prose before evaluation
max_attempts = 2
SCORE_AUTO_ACCEPT = 8
# Adaptive passing threshold: lenient for early setup chapters, strict for climax/resolution.
# chapter_position=0.0 → setup (SCORE_PASSING=6.5), chapter_position=1.0 → climax (7.5)
if chapter_position is not None:
SCORE_PASSING = round(6.5 + chapter_position * 1.0, 1)
utils.log("WRITER", f" -> Adaptive threshold: SCORE_PASSING={SCORE_PASSING} (position={chapter_position:.2f})")
else:
SCORE_PASSING = 7
SCORE_REWRITE_THRESHOLD = 6
best_score = 0
best_text = current_text
past_critiques = []
for attempt in range(1, max_attempts + 1):
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...")
score, critique = evaluate_chapter_quality(current_text, chap['title'], meta.get('genre', 'Fiction'), ai_models.model_logic, folder, series_context=series_block.strip())
past_critiques.append(f"Attempt {attempt}: {critique}")
if "Evaluation error" in critique:
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
if best_score == 0: best_text = current_text
break
utils.log("WRITER", f" Score: {score}/10. Critique: {critique}")
if score >= SCORE_AUTO_ACCEPT:
utils.log("WRITER", " 🌟 Auto-Accept threshold met.")
return current_text
if score > best_score:
best_score = score
best_text = current_text
if attempt == max_attempts:
if best_score >= SCORE_PASSING:
utils.log("WRITER", f" ✅ Max attempts reached. Accepting best score ({best_score}).")
return best_text
else:
utils.log("WRITER", f" ⚠️ Quality low ({best_score}/{SCORE_PASSING}) but max attempts reached. Proceeding.")
return best_text
if score < SCORE_REWRITE_THRESHOLD:
utils.log("WRITER", f" -> Score {score} < {SCORE_REWRITE_THRESHOLD}. Triggering FULL REWRITE (Fresh Draft)...")
full_rewrite_prompt = prompt + f"""
[SYSTEM ALERT: QUALITY CHECK FAILED]
The previous draft was rejected.
CRITIQUE: {critique}
NEW TASK: Discard the previous attempt. Write a FRESH version of the chapter that addresses the critique above.
"""
try:
_pro = getattr(ai_models, 'pro_model_name', 'models/gemini-2.0-pro-exp')
ai_models.model_logic.update(_pro)
resp_rewrite = ai_models.model_logic.generate_content(full_rewrite_prompt)
utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata)
current_text = resp_rewrite.text
ai_models.model_logic.update(ai_models.logic_model_name)
continue
except Exception as e:
ai_models.model_logic.update(ai_models.logic_model_name)
utils.log("WRITER", f"Full rewrite failed: {e}. Falling back to refinement.")
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
guidelines = get_style_guidelines()
fw_list = '", "'.join(guidelines['filter_words'])
history_str = "\n".join(past_critiques[-3:-1]) if len(past_critiques) > 1 else "None"
refine_prompt = f"""
ROLE: Automated Editor
TASK: Rewrite the draft chapter to address the critique. Preserve the narrative content and approximate word count.
CURRENT_CRITIQUE:
{critique}
PREVIOUS_ATTEMPTS (context only):
{history_str}
HARD_CONSTRAINTS:
- TARGET_WORDS: ~{est_words} words (aim for this; ±20% is acceptable if the scene genuinely demands it — but do not condense beats to save space)
- BEATS MUST BE COVERED: {json.dumps(chap.get('beats', []))}
- SUMMARY CONTEXT: {utils.truncate_to_tokens(prev_sum, 600)}
AUTHOR_VOICE:
{persona_info}
STYLE:
{style_block}
{char_visuals}
PROSE_RULES (fix each one found in the draft):
1. FILTER_REMOVAL: Remove filter words [{fw_list}] — rewrite to show the sensation directly.
2. VARIETY: No two consecutive sentences starting with the same word or pronoun.
3. SUBTEXT: Dialogue must imply meaning — not state it outright.
4. TONE: Match {meta.get('genre', 'Fiction')} conventions throughout.
5. ENVIRONMENT: Characters interact with their physical space.
6. NO_SUMMARY_MODE: Dramatise key moments — do not skip or summarise them.
7. ACTIVE_VOICE: Replace 'was/were + verb-ing' constructions with active alternatives.
8. SHOWING: Render emotion through physical reactions, not labels.
9. STAGING: Characters must enter and exit physically — no teleporting.
10. CLARITY: Prefer simple sentence structures over convoluted ones.
DRAFT_TO_REWRITE:
{current_text}
PREVIOUS_CHAPTER_ENDING (maintain continuity):
{prev_context_block}
OUTPUT: Complete polished chapter in Markdown. Include the chapter header. Same approximate length as the draft.
"""
try:
resp_refine = ai_models.model_writer.generate_content(refine_prompt)
utils.log_usage(folder, ai_models.model_writer.name, resp_refine.usage_metadata)
current_text = resp_refine.text
except Exception as e:
utils.log("WRITER", f"Refinement failed: {e}")
return best_text
return best_text