Skip to content

Commit c906378

Browse files
CopilotCyberSecDef
andauthored
Implement chapter length enforcement with expansion pass
- Add CHAPTER_MIN_LENGTH_PCT and MAX_EXPANSION_ATTEMPTS config constants - Add chapter_expansion prompt template in prompts.yml - Add build_chapter_expansion_prompt() prompt builder - Add check_chapter_length() and expand_chapter() to _helpers.py - Wire expansion into pipeline via target_words parameter - Add per-chapter length tracking with length_enforcement_log - Strengthen chapter_draft prompt with mandatory length requirements - Add length_enforcement field to ProgressState - Add 24 new tests covering all length enforcement components Agent-Logs-Url: https://github.com/CyberSecDef/NovelForge/sessions/edfa2372-2014-43cf-8520-a4f613600a1b Co-authored-by: CyberSecDef <17597068+CyberSecDef@users.noreply.github.com>
1 parent 85b4f64 commit c906378

10 files changed

Lines changed: 521 additions & 2 deletions

File tree

config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
from novelforge.config import ( # noqa: F401 – re-exports for backward compatibility
13+
CHAPTER_MIN_LENGTH_PCT,
1314
ConfigurationError,
1415
IMAGE_API_KEY,
1516
IMAGE_API_URL,
@@ -26,6 +27,7 @@
2627
LLM_TIMEOUT,
2728
LOGS_DIR,
2829
MAX_CHAPTERS,
30+
MAX_EXPANSION_ATTEMPTS,
2931
MAX_WORD_COUNT,
3032
NOVELS_DIR,
3133
EXPORT_DIR,

novelforge/agents/chapter/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
_format_anti_repetition_rules,
3131
_log_pass_failure,
3232
_sanitize_for_content_policy,
33+
check_chapter_length,
34+
expand_chapter,
3335
format_vocabulary_rules,
3436
get_forbidden_words,
3537
get_soft_limited_words,
@@ -50,6 +52,7 @@
5052
from novelforge.agents.chapter.prompts import ( # noqa: F401
5153
build_anti_llm_agent_prompt,
5254
build_chapter_draft_prompt,
55+
build_chapter_expansion_prompt,
5356
build_chapter_revision_prompt,
5457
build_chapter_rhythm_classifier_prompt,
5558
build_rhythm_compliance_verifier_prompt,

novelforge/agents/chapter/_helpers.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Shared constants, pass-failure helpers, content-policy retry, and vocabulary scanning."""
1+
"""Shared constants, pass-failure helpers, content-policy retry, vocabulary scanning, and length enforcement."""
22

33
import logging
44
import re
@@ -395,3 +395,83 @@ def scan_vocabulary_overuse(chapter_text: str, genre: str = "") -> list[str]:
395395
)
396396

397397
return warnings
398+
399+
400+
# ---------------------------------------------------------------------------
401+
# Chapter length enforcement
402+
# ---------------------------------------------------------------------------
403+
404+
def check_chapter_length(
405+
text: str,
406+
target_words: int,
407+
min_pct: int = 85,
408+
) -> tuple[int, int, bool]:
409+
"""Check whether a chapter meets the minimum length threshold.
410+
411+
Returns ``(actual_word_count, min_threshold, is_acceptable)`` where
412+
*min_threshold* is the minimum word count derived from *target_words*
413+
and *min_pct*.
414+
"""
415+
actual = len(text.split())
416+
min_threshold = (target_words * min_pct) // 100
417+
return actual, min_threshold, actual >= min_threshold
418+
419+
420+
def expand_chapter(
421+
text: str,
422+
*,
423+
target_words: int,
424+
min_words: int,
425+
chapter_num: int,
426+
title: str,
427+
max_attempts: int = 2,
428+
) -> tuple[str, int]:
429+
"""Expand an under-length chapter by calling the expansion agent.
430+
431+
Tries up to *max_attempts* expansion calls. Returns
432+
``(expanded_text, final_word_count)``. If the expansion agent fails or
433+
the chapter still doesn't meet the threshold, returns the best result
434+
achieved so far rather than raising.
435+
"""
436+
from novelforge.agents.chapter.prompts import build_chapter_expansion_prompt
437+
438+
current = text
439+
current_wc = len(current.split())
440+
for attempt in range(1, max_attempts + 1):
441+
if current_wc >= min_words:
442+
break
443+
logger.info(
444+
"Chapter %d: expansion attempt %d/%d (%d words, need %d)",
445+
chapter_num, attempt, max_attempts, current_wc, min_words,
446+
)
447+
try:
448+
expanded = call_llm(
449+
build_chapter_expansion_prompt(
450+
chapter_text=current,
451+
current_words=current_wc,
452+
target_words=target_words,
453+
min_words=min_words,
454+
),
455+
action=f"Chapter {chapter_num}: expansion (attempt {attempt})",
456+
)
457+
new_wc = len(expanded.split())
458+
if new_wc > current_wc:
459+
current = expanded
460+
current_wc = new_wc
461+
logger.info(
462+
"Chapter %d: expansion attempt %d produced %d words",
463+
chapter_num, attempt, new_wc,
464+
)
465+
else:
466+
logger.warning(
467+
"Chapter %d: expansion attempt %d did not increase length (%d → %d)",
468+
chapter_num, attempt, current_wc, new_wc,
469+
)
470+
break
471+
except Exception as exc:
472+
logger.warning(
473+
"Chapter %d: expansion attempt %d failed: %s: %s",
474+
chapter_num, attempt, type(exc).__name__, exc,
475+
)
476+
break
477+
return current, current_wc

novelforge/agents/chapter/pipeline.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ def _run_all_chapter_agents(
248248
degraded_passes: list[dict] | None = None,
249249
chapter_rhythm_shape: str = "",
250250
chapter_rhythm_reason: str = "",
251+
target_words: int = 0,
251252
) -> tuple[str, str]:
252253
"""
253254
Run all chapter refinement agents (post-draft) and return:
@@ -468,6 +469,33 @@ def _build_with_vocab_rules(t: str) -> list[dict]:
468469
text, action=f"Chapter {chapter_num}: vocabulary fix-up",
469470
)
470471

472+
# Length enforcement — expand under-length chapters after all refinement
473+
# passes have finished so that the expansion doesn't get trimmed.
474+
if target_words > 0:
475+
from novelforge.agents.chapter._helpers import check_chapter_length, expand_chapter
476+
import novelforge.config as _cfg
477+
478+
min_pct = _cfg.CHAPTER_MIN_LENGTH_PCT
479+
actual_wc, min_threshold, acceptable = check_chapter_length(text, target_words, min_pct)
480+
if not acceptable:
481+
_check_deadline()
482+
if step_callback:
483+
step_callback(
484+
f"Chapter {chapter_num}: expanding ({actual_wc} words, need {min_threshold})"
485+
)
486+
text, actual_wc = expand_chapter(
487+
text,
488+
target_words=target_words,
489+
min_words=min_threshold,
490+
chapter_num=chapter_num,
491+
title=title,
492+
max_attempts=_cfg.MAX_EXPANSION_ATTEMPTS,
493+
)
494+
logger.info(
495+
"Chapter %d: post-expansion word count = %d (target=%d, min=%d)",
496+
chapter_num, actual_wc, target_words, min_threshold,
497+
)
498+
471499
_check_deadline()
472500
if step_callback:
473501
step_callback(f"Chapter {chapter_num}: summarising")

novelforge/agents/chapter/prompts.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ def build_chapter_draft_prompt(
106106
)
107107

108108

109+
def build_chapter_expansion_prompt(
110+
chapter_text: str,
111+
current_words: int,
112+
target_words: int,
113+
min_words: int,
114+
) -> list[dict[str, str]]:
115+
"""Build the expansion prompt for under-length chapters."""
116+
return render_prompt(
117+
"chapter_expansion",
118+
chapter_text=chapter_text,
119+
current_words=f"{current_words:,}",
120+
target_words=f"{target_words:,}",
121+
min_words=f"{min_words:,}",
122+
)
123+
124+
109125
# ---------------------------------------------------------------------------
110126
# Chapter refinement agent prompt builders
111127
# ---------------------------------------------------------------------------

novelforge/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
# Input validation limits
5353
"MAX_CHAPTERS",
5454
"MAX_WORD_COUNT",
55+
# Chapter length enforcement
56+
"CHAPTER_MIN_LENGTH_PCT",
57+
"MAX_EXPANSION_ATTEMPTS",
5558
# Flask
5659
"SECRET_KEY",
5760
"SESSION_FILE_DIR",
@@ -226,6 +229,17 @@ def _parse_llm_providers() -> list[ProviderConfig]:
226229
MAX_CHAPTERS = get_env_int("MAX_CHAPTERS", 100, min_value=1)
227230
MAX_WORD_COUNT = get_env_int("MAX_WORD_COUNT", 500000, min_value=1)
228231

232+
# ---------------------------------------------------------------------------
233+
# Chapter length enforcement (override via environment variables)
234+
# ---------------------------------------------------------------------------
235+
236+
# Minimum acceptable chapter word count as a percentage of the per-chapter target.
237+
# Chapters below this threshold trigger an automatic expansion pass.
238+
CHAPTER_MIN_LENGTH_PCT = get_env_int("CHAPTER_MIN_LENGTH_PCT", 85, min_value=50)
239+
240+
# Maximum number of expansion attempts per chapter before accepting as-is.
241+
MAX_EXPANSION_ATTEMPTS = get_env_int("MAX_EXPANSION_ATTEMPTS", 2, min_value=0)
242+
229243
# ---------------------------------------------------------------------------
230244
# Flask
231245
# ---------------------------------------------------------------------------

novelforge/progress.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class ProgressState(TypedDict, total=False):
5858
# Token linking a novel entry to its illustration job
5959
illustration_token: str
6060

61+
# Length enforcement tracking: list of {chapter_num, target, actual, expanded} dicts
62+
length_enforcement: list[dict[str, Any]]
63+
6164

6265
_REQUIRED_CREATION_KEYS: frozenset[str] = frozenset(
6366
{"status", "current", "total", "step", "chapters_done", "error"}

novelforge/routes/generation/chapters.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ def _run_chapter_generation_internal(
203203
consequence_log: list[dict] = [] # tracks irreversible consequences per chapter
204204
exposition_log: list[dict] = [] # tracks dramatization ratio per chapter
205205
thematic_phrase_log: list[str] = [] # tracks thematic phrases to avoid repeating
206+
length_enforcement_log: list[dict] = [] # tracks per-chapter length enforcement results
207+
total_words_generated: int = 0 # running total for global length tracking
206208

207209
# Format voice seed for prompt injection
208210
from novelforge.voice import format_voice_prompt
@@ -423,6 +425,7 @@ def _set_step(step_label: str) -> None:
423425
degraded_passes=degraded_passes,
424426
chapter_rhythm_shape=chapter_rhythm_shape,
425427
chapter_rhythm_reason=chapter_rhythm_reason,
428+
target_words=target_per_chapter,
426429
)
427430
summaries.append(summary)
428431

@@ -494,6 +497,22 @@ def _set_step(step_label: str) -> None:
494497
chapter_usage = get_llm_usage()
495498
chapter_word_count = len(text.split())
496499

500+
# Track length enforcement results
501+
from novelforge.agents.chapter._helpers import check_chapter_length
502+
min_pct = config.CHAPTER_MIN_LENGTH_PCT
503+
_, min_threshold, meets_target = check_chapter_length(
504+
text, target_per_chapter, min_pct,
505+
)
506+
total_words_generated += chapter_word_count
507+
length_enforcement_log.append({
508+
"chapter_num": chapter_num,
509+
"target": target_per_chapter,
510+
"min_threshold": min_threshold,
511+
"actual": chapter_word_count,
512+
"meets_target": meets_target,
513+
"total_words_so_far": total_words_generated,
514+
})
515+
497516
chapters_done.append({
498517
"number": chapter_num,
499518
"title": chapter_title,
@@ -516,6 +535,7 @@ def _set_step(step_label: str) -> None:
516535
"chapters_done": list(chapters_done),
517536
"character_state_log": list(character_state_log),
518537
"degraded_passes": list(degraded_passes),
538+
"length_enforcement": list(length_enforcement_log),
519539
})
520540
_persist_progress(force=True) # always persist on chapter completion
521541

prompts.yml

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1037,7 +1037,13 @@ prompts:
10371037
{%- endif %}
10381038
10391039
***CRITICAL: You are writing CHAPTER {{ chapter_num }}. Keep this chapter number in mind throughout.***
1040-
Target: approximately {{ target_words }} words. You MUST write at least 3,500 words and SHOULD aim for 4,000–4,500 words. Do NOT exceed 5,000 words. Subsequent editing passes will trim and tighten the prose, so write generously and let the polish passes do the trimming. Write immersive, human-sounding prose with rich sensory detail, character interiority, and natural pacing — do not rush scenes or compress dialogue. Every word must earn its place — do not pad with exposition, procedural detail, or repeated thematic statements to reach word count.
1040+
*** LENGTH REQUIREMENT (MANDATORY) ***
1041+
Target: {{ target_words }} words. This is NOT optional — your chapter MUST contain at least {{ target_words }} words.
1042+
Write at least 3,500 words. Aim for 4,000–4,500 words. Do NOT exceed 5,000 words.
1043+
Chapters that fall short of {{ target_words }} words will be sent back for expansion — write to the target the first time.
1044+
Structure this chapter with at least 3 distinct scenes, each containing: a character goal, an obstacle, an outcome, and a transition. Include sensory detail, internal thoughts, and dialogue in every scene. Do not skip time unless explicitly required by the outline.
1045+
Subsequent editing passes will trim and tighten the prose, so write generously and let the polish passes do the trimming. Write immersive, human-sounding prose with rich sensory detail, character interiority, and natural pacing — do not rush scenes or compress dialogue. Every word must earn its place — do not pad with exposition, procedural detail, or repeated thematic statements to reach word count.
1046+
*** END LENGTH REQUIREMENT ***
10411047
{%- if total_chapters > 0 and chapter_num == total_chapters %}
10421048
10431049
*** THIS IS THE FINAL CHAPTER OF THE NOVEL. ***
@@ -1111,6 +1117,41 @@ prompts:
11111117
11121118
***Return ONLY the chapter text with NO introduction, NO title header, NO explanation.***
11131119
1120+
- name: "chapter_expansion"
1121+
description: "Expands an under-length chapter to meet the target word count without altering plot, characters, or continuity."
1122+
stage: "Chapter Generation"
1123+
system: |
1124+
You are a skilled novelist performing a targeted expansion pass on a chapter
1125+
that is currently too short. Your ONLY job is to add depth — do NOT remove,
1126+
summarize, or restructure any existing content.
1127+
1128+
EXPANSION TECHNIQUES (use ALL of these):
1129+
- Deepen existing scenes with sensory detail (sight, sound, smell, texture, taste).
1130+
- Expand dialogue exchanges: add subtext, interruptions, physical reactions, pauses.
1131+
- Add internal monologue that reveals character motivation and conflict.
1132+
- Enrich environmental description to ground the reader in setting.
1133+
- Show character body language, micro-expressions, and physical habits.
1134+
- Extend transitions between scenes with brief atmospheric bridging.
1135+
1136+
HARD RULES:
1137+
- Do NOT alter the plot, character actions, or story outcomes.
1138+
- Do NOT introduce new characters or plot events.
1139+
- Do NOT add exposition, summaries, or philosophical narration.
1140+
- Do NOT pad with repetitive or redundant content.
1141+
- Preserve the existing voice, tone, and pacing shape.
1142+
- Return the COMPLETE expanded chapter text.
1143+
user: |
1144+
The following chapter is {{ current_words }} words but must be at least
1145+
{{ min_words }} words (target: {{ target_words }} words).
1146+
1147+
Expand it to at least {{ min_words }} words by deepening scenes, dialogue,
1148+
sensory detail, and character interiority. Do NOT remove or summarize any
1149+
existing content. Return the COMPLETE expanded chapter.
1150+
1151+
---
1152+
1153+
{{ chapter_text }}
1154+
11141155
- name: "prose_refinement_agent"
11151156
description: "Refines dialogue for naturalism and voice distinction, and ensures every scene advances story momentum with varied structure."
11161157
stage: "Chapter Generation"

0 commit comments

Comments
 (0)