1. editor.py — Fix rewrite_chapter_content to use model_writer (was model_logic). Chapter rewrites now use the creative writing model, not the cheaper analysis model. 2. editor.py — evaluate_chapter_quality now uses keep_head=True so the evaluator sees the chapter opening (engagement hook, sensory anchoring) as well as the ending; long chapters no longer scored on tail only. 3. editor.py — Consistency analysis sampling upgraded to head+middle+tail (was head+tail), giving the LLM a complete view of each chapter's events. 4. writer.py — max_attempts is now adaptive: climax/resolution chapters (position >= 0.75) receive 3 refinement attempts; others keep 2. 5. writer.py — Polish-skip threshold tightened from 0.012 to 0.008 (1 filter word per 125 words vs. 1 per 83 words), so more borderline drafts are cleaned. 6. style_persona.py — Persona validation sample increased from 200 to 400 words for more reliable voice quality assessment. Version bumped: 3.0 → 3.1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
561 lines
30 KiB
Python
561 lines
30 KiB
Python
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.0–1.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', '')
|
||
|
||
# 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."
|
||
|
||
# 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 rough draft with the logic (Pro) model before evaluation.
|
||
# Skip when local filter-word heuristic shows draft is already clean (saves ~8K tokens/chapter).
|
||
_guidelines_for_polish = get_style_guidelines()
|
||
_fw_set = set(_guidelines_for_polish['filter_words'])
|
||
_draft_word_list = current_text.lower().split() if current_text else []
|
||
_fw_hit_count = sum(1 for w in _draft_word_list if w in _fw_set)
|
||
_fw_density = _fw_hit_count / max(len(_draft_word_list), 1)
|
||
_skip_polish = _fw_density < 0.008 # < ~1 filter word per 125 words → draft already clean
|
||
|
||
if current_text and not _skip_polish:
|
||
utils.log("WRITER", f" -> Two-pass polish (Pro model, FW density {_fw_density:.3f})...")
|
||
fw_list = '", "'.join(_guidelines_for_polish['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', []))}
|
||
|
||
CONTINUITY (maintain seamless flow from previous chapter):
|
||
{prev_context_block if prev_context_block else "First chapter — no prior context."}
|
||
|
||
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.")
|
||
elif current_text:
|
||
utils.log("WRITER", f" -> Draft clean (FW density {_fw_density:.3f}). Skipping polish pass.")
|
||
|
||
# Adaptive attempts: climax/resolution chapters (position >= 0.75) get 3 passes;
|
||
# earlier chapters keep 2 (polish pass already refines prose before evaluation).
|
||
if chapter_position is not None and chapter_position >= 0.75:
|
||
max_attempts = 3
|
||
else:
|
||
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
|