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:
@@ -65,4 +65,4 @@ LENGTH_DEFINITIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- SYSTEM ---
|
# --- SYSTEM ---
|
||||||
VERSION = "1.3.0"
|
VERSION = "1.3.1"
|
||||||
7
main.py
7
main.py
@@ -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)
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user