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.012 # < ~1 filter word per 83 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.") # 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