v2.0.0: Modularize project into single-responsibility packages

Replaced monolithic modules/ package with a clean architecture:

- core/       config.py, utils.py
- ai/         models.py (ResilientModel), setup.py (init_models)
- story/      planner.py, writer.py, editor.py, style_persona.py, bible_tracker.py
- marketing/  cover.py, blurb.py, fonts.py, assets.py
- export/     exporter.py
- web/        app.py (Flask factory), db.py, helpers.py, tasks.py, routes/{auth,project,run,persona,admin}.py
- cli/        engine.py (run_generation), wizard.py (BookWizard)

Flask routes split into 5 Blueprints; all templates updated with blueprint-
prefixed url_for() calls. Dockerfile and docker-compose updated to use
web.app entry point and new package paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 22:20:53 -05:00
parent edabc4d4fa
commit f7099cc3e4
52 changed files with 3984 additions and 3798 deletions

278
story/writer.py Normal file
View File

@@ -0,0 +1,278 @@
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 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)
char_visuals = ""
if tracking and 'characters' in tracking:
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
for name, data in tracking['characters'].items():
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"
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 = prev_content[-3000:] if len(prev_content) > 3000 else prev_content
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (For Tone & Continuity):\n{trunc_content}\n"
chars_for_writer = [
{"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")}
for c in bp.get('characters', [])
]
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'])}
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: {prev_sum[:1500]}
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