@@ -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" )
0 commit comments