v1.3.1: Remove rigidity from chapter counts, beats, word lengths, and bridge chapters

story.py — create_chapter_plan():
- TARGET_CHAPTERS is now a guideline (±15%) not a hard constraint; the AI
  can produce a count that fits the story rather than forcing a specific number
- Word scaling is now pacing-aware instead of uniform: Very Fast ≈ 60% of avg,
  Fast ≈ 80%, Standard ≈ 100%, Slow ≈ 125%, Very Slow ≈ 150%
- Two-pass normalisation: pacing weights applied first, then the total is
  nudged to the word target — natural variation preserved throughout
- Variance range tightened to ±8% (was ±10%) for more predictable totals
- Prompt now tells the AI that estimated_words should reflect pacing rhythm

story.py — expand():
- Added event ceiling (target_chapters × 1.5): if the outline already has
  enough beats, the pass switches from "add events" to "enrich descriptions"
  — prevents over-dense outlines for short stories and flash fiction
- Task instruction is dynamically chosen: add-events vs deepen-descriptions
- Clarified that original user beats must be preserved but new events must
  each be distinct and spread evenly (not front-loaded)

story.py — refinement loop:
- Word count constraint softened from hard "do not condense" to
  "~N words ±20% acceptable if the scene demands it" so action chapters
  can run short and introspective chapters can run long naturally

main.py — bridge chapter insertion:
- Removed hardcoded 1500-word estimate for dynamically inserted bridge
  chapters; now computes the average estimated_words from the current
  chapter plan so bridge chapters match the book's natural chapter length

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 10:42:51 -05:00
parent 1964c9c2a5
commit 958a6d0ea0
3 changed files with 74 additions and 37 deletions

View File

@@ -65,4 +65,4 @@ LENGTH_DEFINITIONS = {
} }
# --- SYSTEM --- # --- SYSTEM ---
VERSION = "1.3.0" VERSION = "1.3.1"

View File

@@ -213,12 +213,17 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
pacing = story.check_pacing(bp, summary, txt, ch, remaining, folder) pacing = story.check_pacing(bp, summary, txt, ch, remaining, folder)
if pacing and pacing.get('status') == 'add_bridge': if pacing and pacing.get('status') == 'add_bridge':
new_data = pacing.get('new_chapter', {}) new_data = pacing.get('new_chapter', {})
# Estimate bridge chapter length from current plan average (not hardcoded)
if chapters:
avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters))
else:
avg_words = 1500
new_ch = { new_ch = {
"chapter_number": ch['chapter_number'] + 1, "chapter_number": ch['chapter_number'] + 1,
"title": new_data.get('title', 'Bridge Chapter'), "title": new_data.get('title', 'Bridge Chapter'),
"pov_character": new_data.get('pov_character', ch.get('pov_character')), "pov_character": new_data.get('pov_character', ch.get('pov_character')),
"pacing": "Slow", "pacing": "Slow",
"estimated_words": 1500, "estimated_words": avg_words,
"beats": new_data.get('beats', []) "beats": new_data.get('beats', [])
} }
chapters.insert(i+1, new_ch) chapters.insert(i+1, new_ch)

View File

@@ -261,29 +261,40 @@ def plan_structure(bp, folder):
def expand(events, pass_num, target_chapters, bp, folder): def expand(events, pass_num, target_chapters, bp, folder):
utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}") utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}")
beats_context = [] # If events already well exceed the target, only deepen descriptions — don't add more
event_ceiling = int(target_chapters * 1.5)
if len(events) >= event_ceiling:
task = (
f"The outline already has {len(events)} beats for a {target_chapters}-chapter book — do NOT add more events. "
f"Instead, enrich each existing beat's description with more specific detail: setting, characters involved, emotional stakes, and how it connects to what follows."
)
else:
task = (
f"Expand the outline toward {target_chapters} chapters. "
f"Current count: {len(events)} beats. "
f"Add intermediate events to fill pacing gaps, deepen subplots, and ensure character arcs are visible. "
f"Do not overshoot — aim for {target_chapters} to {event_ceiling} total events."
)
if not beats_context: original_beats = bp.get('plot_beats', [])
beats_context = bp.get('plot_beats', [])
prompt = f""" prompt = f"""
ROLE: Story Architect ROLE: Story Architect
TASK: Expand the outline to fit a {target_chapters}-chapter book. TASK: {task}
CURRENT_COUNT: {len(events)} beats.
INPUT_OUTLINE: ORIGINAL_USER_BEATS (must all remain present):
{json.dumps(beats_context)} {json.dumps(original_beats)}
CURRENT_EVENTS: CURRENT_EVENTS:
{json.dumps(events)} {json.dumps(events)}
RULES: RULES:
1. Detect pacing gaps. 1. PRESERVE all original user beats — do not remove or alter them.
2. Insert intermediate events. 2. New events must serve a clear narrative purpose (tension, character, world, reversal).
3. Deepen subplots. 3. Avoid repetitive events — each beat must be distinct.
4. PRESERVE original beats. 4. Distribute additions evenly — do not front-load the outline.
OUTPUT_FORMAT (JSON): {{ "events": [{{ "description": "String", "purpose": "String" }}] }} OUTPUT_FORMAT (JSON): {{ "events": [{{"description": "String", "purpose": "String"}}] }}
""" """
try: try:
response = ai.model_logic.generate_content(prompt) response = ai.model_logic.generate_content(prompt)
@@ -322,24 +333,30 @@ def create_chapter_plan(events, bp, folder):
prompt = f""" prompt = f"""
ROLE: Pacing Specialist ROLE: Pacing Specialist
TASK: Group events into Chapters. TASK: Group the provided events into chapters for a {meta.get('genre', 'Fiction')} {bp['length_settings'].get('label', 'novel')}.
CONSTRAINTS: GUIDELINES:
- TARGET_CHAPTERS: {target} - AIM for approximately {target} chapters, but the final count may vary ±15% if the story structure demands it.
- TARGET_WORDS: {words} (e.g. a tightly plotted thriller may need fewer; an epic with many subplots may need more.)
- INSTRUCTIONS: - TARGET_WORDS for the whole book: {words}
- Assign pacing to each chapter: Very Fast / Fast / Standard / Slow / Very Slow
Reflect dramatic rhythm — action scenes run fast, emotional beats run slow.
- estimated_words per chapter should reflect its pacing:
Very Fast ≈ 60% of average, Fast ≈ 80%, Standard ≈ 100%, Slow ≈ 125%, Very Slow ≈ 150%
- Do NOT force equal word counts. Natural variation makes the book feel alive.
{structure_instructions} {structure_instructions}
{pov_instruction} {pov_instruction}
INPUT_EVENTS: {json.dumps(events)} INPUT_EVENTS: {json.dumps(events)}
OUTPUT_FORMAT (JSON): [{{ "chapter_number": 1, "title": "String", "pov_character": "String", "pacing": "String", "estimated_words": 2000, "beats": ["String"] }}] OUTPUT_FORMAT (JSON): [{{"chapter_number": 1, "title": "String", "pov_character": "String", "pacing": "String", "estimated_words": 2000, "beats": ["String"]}}]
""" """
try: try:
response = ai.model_logic.generate_content(prompt) response = ai.model_logic.generate_content(prompt)
utils.log_usage(folder, ai.model_logic.name, response.usage_metadata) utils.log_usage(folder, ai.model_logic.name, response.usage_metadata)
plan = json.loads(utils.clean_json(response.text)) plan = json.loads(utils.clean_json(response.text))
# Parse target word count
target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '') target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '')
target_val = 0 target_val = 0
if '-' in target_str: if '-' in target_str:
@@ -352,16 +369,31 @@ def create_chapter_plan(events, bp, folder):
except: pass except: pass
if target_val > 0: if target_val > 0:
variance = random.uniform(0.90, 1.10) variance = random.uniform(0.92, 1.08)
target_val = int(target_val * variance) target_val = int(target_val * variance)
utils.log("ARCHITECT", f"Target adjusted with variance ({variance:.2f}x): {target_val} words.") utils.log("ARCHITECT", f"Word target after variance ({variance:.2f}x): {target_val} words.")
current_sum = sum(int(c.get('estimated_words', 0)) for c in plan) current_sum = sum(int(c.get('estimated_words', 0)) for c in plan)
if current_sum > 0: if current_sum > 0:
factor = target_val / current_sum base_factor = target_val / current_sum
utils.log("ARCHITECT", f"Adjusting chapter lengths by {factor:.2f}x to match target.") # Pacing multipliers — fast chapters are naturally shorter, slow chapters longer
pacing_weight = {
'very fast': 0.60, 'fast': 0.80, 'standard': 1.00,
'slow': 1.25, 'very slow': 1.50
}
# Two-pass: apply pacing weights then normalise to hit total target
for c in plan: for c in plan:
c['estimated_words'] = int(c.get('estimated_words', 0) * factor) pw = pacing_weight.get(c.get('pacing', 'standard').lower(), 1.0)
c['estimated_words'] = max(300, int(c.get('estimated_words', 0) * base_factor * pw))
# Normalise to keep total close to target
adjusted_sum = sum(c['estimated_words'] for c in plan)
if adjusted_sum > 0:
norm = target_val / adjusted_sum
for c in plan:
c['estimated_words'] = max(300, int(c['estimated_words'] * norm))
utils.log("ARCHITECT", f"Chapter lengths scaled by pacing. Total ≈ {sum(c['estimated_words'] for c in plan)} words across {len(plan)} chapters.")
return plan return plan
except Exception as e: except Exception as e:
@@ -785,7 +817,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None):
{history_str} {history_str}
HARD_CONSTRAINTS: HARD_CONSTRAINTS:
- TARGET_WORDS: ~{est_words} (maintain this length — do not condense or summarise) - 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', []))} - BEATS MUST BE COVERED: {json.dumps(chap.get('beats', []))}
- SUMMARY CONTEXT: {prev_sum[:1500]} - SUMMARY CONTEXT: {prev_sum[:1500]}