Skip to content

Commit 836808b

Browse files
authored
Merge pull request #164 from CyberSecDef/copilot/enforce-target-novel-length
Enforce target novel length via expansion agent and length validation
2 parents 835aab8 + 5f8daa6 commit 836808b

10 files changed

Lines changed: 614 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: 89 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,94 @@ 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+
# Expansion uses _safe() so vocabulary rules and content-retry apply,
475+
# and a vocabulary scan runs afterwards to catch any reintroduced words.
476+
if target_words > 0:
477+
from novelforge.agents.chapter._helpers import check_chapter_length
478+
from novelforge.agents.chapter.prompts import build_chapter_expansion_prompt
479+
import novelforge.config as _cfg
480+
481+
min_pct = _cfg.CHAPTER_MIN_LENGTH_PCT
482+
actual_wc, min_threshold, acceptable = check_chapter_length(text, target_words, min_pct)
483+
if not acceptable:
484+
for _exp_attempt in range(1, _cfg.MAX_EXPANSION_ATTEMPTS + 1):
485+
if actual_wc >= min_threshold:
486+
break
487+
_check_deadline()
488+
if step_callback:
489+
step_callback(
490+
f"Chapter {chapter_num}: expanding ({actual_wc} words, "
491+
f"need {min_threshold}, attempt {_exp_attempt})"
492+
)
493+
logger.info(
494+
"Chapter %d: expansion attempt %d/%d (%d words, need %d)",
495+
chapter_num, _exp_attempt, _cfg.MAX_EXPANSION_ATTEMPTS,
496+
actual_wc, min_threshold,
497+
)
498+
try:
499+
_cur_wc = actual_wc
500+
_cur_target = target_words
501+
_cur_min = min_threshold
502+
expanded = _safe(
503+
lambda t: build_chapter_expansion_prompt(
504+
chapter_text=t,
505+
current_words=_cur_wc,
506+
target_words=_cur_target,
507+
min_words=_cur_min,
508+
),
509+
text,
510+
action=f"Chapter {chapter_num}: expansion (attempt {_exp_attempt})",
511+
)
512+
new_wc = len(expanded.split())
513+
if new_wc > actual_wc:
514+
text = expanded
515+
actual_wc = new_wc
516+
logger.info(
517+
"Chapter %d: expansion attempt %d produced %d words",
518+
chapter_num, _exp_attempt, new_wc,
519+
)
520+
else:
521+
logger.warning(
522+
"Chapter %d: expansion attempt %d did not increase length (%d → %d)",
523+
chapter_num, _exp_attempt, actual_wc, new_wc,
524+
)
525+
break
526+
except Exception as exc:
527+
logger.warning(
528+
"Chapter %d: expansion attempt %d failed: %s: %s",
529+
chapter_num, _exp_attempt, type(exc).__name__, exc,
530+
)
531+
break
532+
533+
logger.info(
534+
"Chapter %d: post-expansion word count = %d (target=%d, min=%d)",
535+
chapter_num, actual_wc, target_words, min_threshold,
536+
)
537+
538+
# Re-run vocabulary scan after expansion since the LLM may have
539+
# reintroduced forbidden or overused words.
540+
_check_deadline()
541+
post_violations = scan_vocabulary_overuse(text, genre=genre)
542+
if post_violations:
543+
if step_callback:
544+
step_callback(
545+
f"Chapter {chapter_num}: fixing {len(post_violations)} "
546+
f"vocabulary issues (post-expansion)"
547+
)
548+
logger.info(
549+
"Chapter %d: post-expansion vocabulary scan found %d violations",
550+
chapter_num, len(post_violations),
551+
)
552+
text = _safe(
553+
lambda t: build_vocabulary_fix_prompt(
554+
t, chapter_num, title, post_violations,
555+
),
556+
text,
557+
action=f"Chapter {chapter_num}: vocabulary fix-up (post-expansion)",
558+
)
559+
471560
_check_deadline()
472561
if step_callback:
473562
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: 30 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,33 @@ 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+
237+
def _get_percentage_env(
238+
name: str, default: int, *, min_value: int = 0, max_value: int = 100
239+
) -> int:
240+
"""Return an integer percentage env var constrained to the given range."""
241+
value = get_env_int(name, default, min_value=min_value)
242+
if value > max_value:
243+
_CONFIG_PARSE_ERRORS.append(
244+
f"{name} must be <= {max_value} (got {value})."
245+
)
246+
return default
247+
return value
248+
249+
250+
# Minimum acceptable chapter word count as a percentage of the per-chapter target.
251+
# Chapters below this threshold trigger an automatic expansion pass.
252+
CHAPTER_MIN_LENGTH_PCT = _get_percentage_env(
253+
"CHAPTER_MIN_LENGTH_PCT", 85, min_value=50, max_value=100
254+
)
255+
256+
# Maximum number of expansion attempts per chapter before accepting as-is.
257+
MAX_EXPANSION_ATTEMPTS = get_env_int("MAX_EXPANSION_ATTEMPTS", 2, min_value=0)
258+
229259
# ---------------------------------------------------------------------------
230260
# Flask
231261
# ---------------------------------------------------------------------------

novelforge/progress.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ 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 per-chapter dicts such as
62+
# {chapter_num, target, min_threshold, actual, meets_min_threshold, total_words_so_far}.
63+
length_enforcement: list[dict[str, Any]]
64+
6165

6266
_REQUIRED_CREATION_KEYS: frozenset[str] = frozenset(
6367
{"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_min = 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_min_threshold": meets_min,
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

0 commit comments

Comments
 (0)