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 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=""): 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', '') ad = meta.get('author_details', {}) if not ad and 'author_bio' in meta: persona_info = meta['author_bio'] else: persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n" if ad.get('age'): persona_info += f"Age: {ad['age']}\n" if ad.get('gender'): persona_info += f"Gender: {ad['gender']}\n" if ad.get('race'): persona_info += f"Race: {ad['race']}\n" if ad.get('nationality'): persona_info += f"Nationality: {ad['nationality']}\n" if ad.get('language'): persona_info += f"Language: {ad['language']}\n" if ad.get('bio'): persona_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: persona_info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples) # 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" 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" utils.log("WRITER", f" -> Expanding beats to Director's Treatment...") treatment = expand_beats_to_treatment(chap.get('beats', []), 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 "" 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'} 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} 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. - SHOW, DON'T TELL: Focus on immediate action and internal reaction. Don't 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 CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to', 'tapestry of', 'azure', 'cerulean'). - 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} - 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}" max_attempts = 5 SCORE_AUTO_ACCEPT = 8 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_writer, folder) 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: 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 continue except Exception as e: 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