diff --git a/config.py b/config.py index a23c7b4..c135475 100644 --- a/config.py +++ b/config.py @@ -10,6 +10,7 @@ """ from novelforge.config import ( # noqa: F401 – re-exports for backward compatibility + CHAPTER_MIN_LENGTH_PCT, ConfigurationError, IMAGE_API_KEY, IMAGE_API_URL, @@ -26,6 +27,7 @@ LLM_TIMEOUT, LOGS_DIR, MAX_CHAPTERS, + MAX_EXPANSION_ATTEMPTS, MAX_WORD_COUNT, NOVELS_DIR, EXPORT_DIR, diff --git a/novelforge/agents/chapter/__init__.py b/novelforge/agents/chapter/__init__.py index 5decaa0..70456fc 100644 --- a/novelforge/agents/chapter/__init__.py +++ b/novelforge/agents/chapter/__init__.py @@ -30,6 +30,8 @@ _format_anti_repetition_rules, _log_pass_failure, _sanitize_for_content_policy, + check_chapter_length, + expand_chapter, format_vocabulary_rules, get_forbidden_words, get_soft_limited_words, @@ -50,6 +52,7 @@ from novelforge.agents.chapter.prompts import ( # noqa: F401 build_anti_llm_agent_prompt, build_chapter_draft_prompt, + build_chapter_expansion_prompt, build_chapter_revision_prompt, build_chapter_rhythm_classifier_prompt, build_rhythm_compliance_verifier_prompt, diff --git a/novelforge/agents/chapter/_helpers.py b/novelforge/agents/chapter/_helpers.py index e61f17d..5594018 100644 --- a/novelforge/agents/chapter/_helpers.py +++ b/novelforge/agents/chapter/_helpers.py @@ -1,4 +1,4 @@ -"""Shared constants, pass-failure helpers, content-policy retry, and vocabulary scanning.""" +"""Shared constants, pass-failure helpers, content-policy retry, vocabulary scanning, and length enforcement.""" import logging import re @@ -395,3 +395,83 @@ def scan_vocabulary_overuse(chapter_text: str, genre: str = "") -> list[str]: ) return warnings + + +# --------------------------------------------------------------------------- +# Chapter length enforcement +# --------------------------------------------------------------------------- + +def check_chapter_length( + text: str, + target_words: int, + min_pct: int = 85, +) -> tuple[int, int, bool]: + """Check whether a chapter meets the minimum length threshold. + + Returns ``(actual_word_count, min_threshold, is_acceptable)`` where + *min_threshold* is the minimum word count derived from *target_words* + and *min_pct*. + """ + actual = len(text.split()) + min_threshold = (target_words * min_pct) // 100 + return actual, min_threshold, actual >= min_threshold + + +def expand_chapter( + text: str, + *, + target_words: int, + min_words: int, + chapter_num: int, + title: str, + max_attempts: int = 2, +) -> tuple[str, int]: + """Expand an under-length chapter by calling the expansion agent. + + Tries up to *max_attempts* expansion calls. Returns + ``(expanded_text, final_word_count)``. If the expansion agent fails or + the chapter still doesn't meet the threshold, returns the best result + achieved so far rather than raising. + """ + from novelforge.agents.chapter.prompts import build_chapter_expansion_prompt + + current = text + current_wc = len(current.split()) + for attempt in range(1, max_attempts + 1): + if current_wc >= min_words: + break + logger.info( + "Chapter %d: expansion attempt %d/%d (%d words, need %d)", + chapter_num, attempt, max_attempts, current_wc, min_words, + ) + try: + expanded = call_llm( + build_chapter_expansion_prompt( + chapter_text=current, + current_words=current_wc, + target_words=target_words, + min_words=min_words, + ), + action=f"Chapter {chapter_num}: expansion (attempt {attempt})", + ) + new_wc = len(expanded.split()) + if new_wc > current_wc: + current = expanded + current_wc = new_wc + logger.info( + "Chapter %d: expansion attempt %d produced %d words", + chapter_num, attempt, new_wc, + ) + else: + logger.warning( + "Chapter %d: expansion attempt %d did not increase length (%d → %d)", + chapter_num, attempt, current_wc, new_wc, + ) + break + except Exception as exc: + logger.warning( + "Chapter %d: expansion attempt %d failed: %s: %s", + chapter_num, attempt, type(exc).__name__, exc, + ) + break + return current, current_wc diff --git a/novelforge/agents/chapter/pipeline.py b/novelforge/agents/chapter/pipeline.py index 8441da6..8b6dc6f 100644 --- a/novelforge/agents/chapter/pipeline.py +++ b/novelforge/agents/chapter/pipeline.py @@ -248,6 +248,7 @@ def _run_all_chapter_agents( degraded_passes: list[dict] | None = None, chapter_rhythm_shape: str = "", chapter_rhythm_reason: str = "", + target_words: int = 0, ) -> tuple[str, str]: """ Run all chapter refinement agents (post-draft) and return: @@ -468,6 +469,94 @@ def _build_with_vocab_rules(t: str) -> list[dict]: text, action=f"Chapter {chapter_num}: vocabulary fix-up", ) + # Length enforcement — expand under-length chapters after all refinement + # passes have finished so that the expansion doesn't get trimmed. + # Expansion uses _safe() so vocabulary rules and content-retry apply, + # and a vocabulary scan runs afterwards to catch any reintroduced words. + if target_words > 0: + from novelforge.agents.chapter._helpers import check_chapter_length + from novelforge.agents.chapter.prompts import build_chapter_expansion_prompt + import novelforge.config as _cfg + + min_pct = _cfg.CHAPTER_MIN_LENGTH_PCT + actual_wc, min_threshold, acceptable = check_chapter_length(text, target_words, min_pct) + if not acceptable: + for _exp_attempt in range(1, _cfg.MAX_EXPANSION_ATTEMPTS + 1): + if actual_wc >= min_threshold: + break + _check_deadline() + if step_callback: + step_callback( + f"Chapter {chapter_num}: expanding ({actual_wc} words, " + f"need {min_threshold}, attempt {_exp_attempt})" + ) + logger.info( + "Chapter %d: expansion attempt %d/%d (%d words, need %d)", + chapter_num, _exp_attempt, _cfg.MAX_EXPANSION_ATTEMPTS, + actual_wc, min_threshold, + ) + try: + _cur_wc = actual_wc + _cur_target = target_words + _cur_min = min_threshold + expanded = _safe( + lambda t: build_chapter_expansion_prompt( + chapter_text=t, + current_words=_cur_wc, + target_words=_cur_target, + min_words=_cur_min, + ), + text, + action=f"Chapter {chapter_num}: expansion (attempt {_exp_attempt})", + ) + new_wc = len(expanded.split()) + if new_wc > actual_wc: + text = expanded + actual_wc = new_wc + logger.info( + "Chapter %d: expansion attempt %d produced %d words", + chapter_num, _exp_attempt, new_wc, + ) + else: + logger.warning( + "Chapter %d: expansion attempt %d did not increase length (%d → %d)", + chapter_num, _exp_attempt, actual_wc, new_wc, + ) + break + except Exception as exc: + logger.warning( + "Chapter %d: expansion attempt %d failed: %s: %s", + chapter_num, _exp_attempt, type(exc).__name__, exc, + ) + break + + logger.info( + "Chapter %d: post-expansion word count = %d (target=%d, min=%d)", + chapter_num, actual_wc, target_words, min_threshold, + ) + + # Re-run vocabulary scan after expansion since the LLM may have + # reintroduced forbidden or overused words. + _check_deadline() + post_violations = scan_vocabulary_overuse(text, genre=genre) + if post_violations: + if step_callback: + step_callback( + f"Chapter {chapter_num}: fixing {len(post_violations)} " + f"vocabulary issues (post-expansion)" + ) + logger.info( + "Chapter %d: post-expansion vocabulary scan found %d violations", + chapter_num, len(post_violations), + ) + text = _safe( + lambda t: build_vocabulary_fix_prompt( + t, chapter_num, title, post_violations, + ), + text, + action=f"Chapter {chapter_num}: vocabulary fix-up (post-expansion)", + ) + _check_deadline() if step_callback: step_callback(f"Chapter {chapter_num}: summarising") diff --git a/novelforge/agents/chapter/prompts.py b/novelforge/agents/chapter/prompts.py index 6256bd3..3b4fed3 100644 --- a/novelforge/agents/chapter/prompts.py +++ b/novelforge/agents/chapter/prompts.py @@ -106,6 +106,22 @@ def build_chapter_draft_prompt( ) +def build_chapter_expansion_prompt( + chapter_text: str, + current_words: int, + target_words: int, + min_words: int, +) -> list[dict[str, str]]: + """Build the expansion prompt for under-length chapters.""" + return render_prompt( + "chapter_expansion", + chapter_text=chapter_text, + current_words=f"{current_words:,}", + target_words=f"{target_words:,}", + min_words=f"{min_words:,}", + ) + + # --------------------------------------------------------------------------- # Chapter refinement agent prompt builders # --------------------------------------------------------------------------- diff --git a/novelforge/config.py b/novelforge/config.py index bd6e072..3417604 100644 --- a/novelforge/config.py +++ b/novelforge/config.py @@ -52,6 +52,9 @@ # Input validation limits "MAX_CHAPTERS", "MAX_WORD_COUNT", + # Chapter length enforcement + "CHAPTER_MIN_LENGTH_PCT", + "MAX_EXPANSION_ATTEMPTS", # Flask "SECRET_KEY", "SESSION_FILE_DIR", @@ -226,6 +229,33 @@ def _parse_llm_providers() -> list[ProviderConfig]: MAX_CHAPTERS = get_env_int("MAX_CHAPTERS", 100, min_value=1) MAX_WORD_COUNT = get_env_int("MAX_WORD_COUNT", 500000, min_value=1) +# --------------------------------------------------------------------------- +# Chapter length enforcement (override via environment variables) +# --------------------------------------------------------------------------- + + +def _get_percentage_env( + name: str, default: int, *, min_value: int = 0, max_value: int = 100 +) -> int: + """Return an integer percentage env var constrained to the given range.""" + value = get_env_int(name, default, min_value=min_value) + if value > max_value: + _CONFIG_PARSE_ERRORS.append( + f"{name} must be <= {max_value} (got {value})." + ) + return default + return value + + +# Minimum acceptable chapter word count as a percentage of the per-chapter target. +# Chapters below this threshold trigger an automatic expansion pass. +CHAPTER_MIN_LENGTH_PCT = _get_percentage_env( + "CHAPTER_MIN_LENGTH_PCT", 85, min_value=50, max_value=100 +) + +# Maximum number of expansion attempts per chapter before accepting as-is. +MAX_EXPANSION_ATTEMPTS = get_env_int("MAX_EXPANSION_ATTEMPTS", 2, min_value=0) + # --------------------------------------------------------------------------- # Flask # --------------------------------------------------------------------------- diff --git a/novelforge/progress.py b/novelforge/progress.py index a3f5e81..87dd5c4 100644 --- a/novelforge/progress.py +++ b/novelforge/progress.py @@ -58,6 +58,10 @@ class ProgressState(TypedDict, total=False): # Token linking a novel entry to its illustration job illustration_token: str + # Length enforcement tracking: list of per-chapter dicts such as + # {chapter_num, target, min_threshold, actual, meets_min_threshold, total_words_so_far}. + length_enforcement: list[dict[str, Any]] + _REQUIRED_CREATION_KEYS: frozenset[str] = frozenset( {"status", "current", "total", "step", "chapters_done", "error"} diff --git a/novelforge/routes/generation/chapters.py b/novelforge/routes/generation/chapters.py index 487e85e..644ed30 100644 --- a/novelforge/routes/generation/chapters.py +++ b/novelforge/routes/generation/chapters.py @@ -203,6 +203,8 @@ def _run_chapter_generation_internal( consequence_log: list[dict] = [] # tracks irreversible consequences per chapter exposition_log: list[dict] = [] # tracks dramatization ratio per chapter thematic_phrase_log: list[str] = [] # tracks thematic phrases to avoid repeating + length_enforcement_log: list[dict] = [] # tracks per-chapter length enforcement results + total_words_generated: int = 0 # running total for global length tracking # Format voice seed for prompt injection from novelforge.voice import format_voice_prompt @@ -423,6 +425,7 @@ def _set_step(step_label: str) -> None: degraded_passes=degraded_passes, chapter_rhythm_shape=chapter_rhythm_shape, chapter_rhythm_reason=chapter_rhythm_reason, + target_words=target_per_chapter, ) summaries.append(summary) @@ -494,6 +497,22 @@ def _set_step(step_label: str) -> None: chapter_usage = get_llm_usage() chapter_word_count = len(text.split()) + # Track length enforcement results + from novelforge.agents.chapter._helpers import check_chapter_length + min_pct = config.CHAPTER_MIN_LENGTH_PCT + _, min_threshold, meets_min = check_chapter_length( + text, target_per_chapter, min_pct, + ) + total_words_generated += chapter_word_count + length_enforcement_log.append({ + "chapter_num": chapter_num, + "target": target_per_chapter, + "min_threshold": min_threshold, + "actual": chapter_word_count, + "meets_min_threshold": meets_min, + "total_words_so_far": total_words_generated, + }) + chapters_done.append({ "number": chapter_num, "title": chapter_title, @@ -516,6 +535,7 @@ def _set_step(step_label: str) -> None: "chapters_done": list(chapters_done), "character_state_log": list(character_state_log), "degraded_passes": list(degraded_passes), + "length_enforcement": list(length_enforcement_log), }) _persist_progress(force=True) # always persist on chapter completion diff --git a/prompts.yml b/prompts.yml index 212eea6..6d9da90 100644 --- a/prompts.yml +++ b/prompts.yml @@ -1037,7 +1037,13 @@ prompts: {%- endif %} ***CRITICAL: You are writing CHAPTER {{ chapter_num }}. Keep this chapter number in mind throughout.*** - 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. + *** LENGTH REQUIREMENT (MANDATORY) *** + Target: {{ target_words }} words. Aim for the full target, but your chapter MUST contain at least 85% of this target (approximately {{ target_words }} × 0.85 words). + Write at least 3,500 words. Aim for 4,000–4,500 words. Do NOT exceed 5,000 words. + Chapters that fall significantly short will be sent back for automatic expansion — write to the target the first time. + 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. + 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. + *** END LENGTH REQUIREMENT *** {%- if total_chapters > 0 and chapter_num == total_chapters %} *** THIS IS THE FINAL CHAPTER OF THE NOVEL. *** @@ -1111,6 +1117,41 @@ prompts: ***Return ONLY the chapter text with NO introduction, NO title header, NO explanation.*** + - name: "chapter_expansion" + description: "Expands an under-length chapter to meet the target word count without altering plot, characters, or continuity." + stage: "Chapter Generation" + system: | + You are a skilled novelist performing a targeted expansion pass on a chapter + that is currently too short. Your ONLY job is to add depth — do NOT remove, + summarize, or restructure any existing content. + + EXPANSION TECHNIQUES (use ALL of these): + - Deepen existing scenes with sensory detail (sight, sound, smell, texture, taste). + - Expand dialogue exchanges: add subtext, interruptions, physical reactions, pauses. + - Add internal monologue that reveals character motivation and conflict. + - Enrich environmental description to ground the reader in setting. + - Show character body language, micro-expressions, and physical habits. + - Extend transitions between scenes with brief atmospheric bridging. + + HARD RULES: + - Do NOT alter the plot, character actions, or story outcomes. + - Do NOT introduce new characters or plot events. + - Do NOT add exposition, summaries, or philosophical narration. + - Do NOT pad with repetitive or redundant content. + - Preserve the existing voice, tone, and pacing shape. + - Return the COMPLETE expanded chapter text. + user: | + This chapter currently has {{ current_words }} words. Expand it to meet the + minimum of {{ min_words }} words (target: {{ target_words }} words). + + Deepen scenes, dialogue, sensory detail, and character interiority. + Do NOT remove or summarize any existing content. + Return the COMPLETE expanded chapter. + + --- + + {{ chapter_text }} + - name: "prose_refinement_agent" description: "Refines dialogue for naturalism and voice distinction, and ensures every scene advances story momentum with varied structure." stage: "Chapter Generation" diff --git a/tests/test_length_enforcement.py b/tests/test_length_enforcement.py new file mode 100644 index 0000000..3dedc74 --- /dev/null +++ b/tests/test_length_enforcement.py @@ -0,0 +1,327 @@ +"""Tests for the chapter length enforcement feature. + +Covers: +- check_chapter_length() pure validation logic +- expand_chapter() retry and failure handling +- build_chapter_expansion_prompt() prompt builder +- Config constants (CHAPTER_MIN_LENGTH_PCT, MAX_EXPANSION_ATTEMPTS) +- Integration with _run_all_chapter_agents() via target_words parameter +""" + +import importlib + +import pytest + +from novelforge.agents.chapter._helpers import check_chapter_length, expand_chapter +from novelforge.agents.chapter.prompts import build_chapter_expansion_prompt + + +# --------------------------------------------------------------------------- +# check_chapter_length — pure validation +# --------------------------------------------------------------------------- + +class TestCheckChapterLength: + """Validate the pure-Python length-checking function.""" + + def test_meets_target_exactly(self): + text = " ".join(["word"] * 4000) + actual, min_threshold, ok = check_chapter_length(text, target_words=4000, min_pct=85) + assert actual == 4000 + assert min_threshold == 3400 + assert ok is True + + def test_above_target(self): + text = " ".join(["word"] * 5000) + actual, _, ok = check_chapter_length(text, target_words=4000, min_pct=85) + assert actual == 5000 + assert ok is True + + def test_below_threshold(self): + text = " ".join(["word"] * 2000) + actual, min_threshold, ok = check_chapter_length(text, target_words=4000, min_pct=85) + assert actual == 2000 + assert min_threshold == 3400 + assert ok is False + + def test_exactly_at_threshold(self): + text = " ".join(["word"] * 3400) + actual, min_threshold, ok = check_chapter_length(text, target_words=4000, min_pct=85) + assert actual == 3400 + assert min_threshold == 3400 + assert ok is True + + def test_one_below_threshold(self): + text = " ".join(["word"] * 3399) + actual, _, ok = check_chapter_length(text, target_words=4000, min_pct=85) + assert actual == 3399 + assert ok is False + + def test_custom_min_pct(self): + text = " ".join(["word"] * 3500) + _, min_threshold, ok = check_chapter_length(text, target_words=4000, min_pct=90) + assert min_threshold == 3600 + assert ok is False + + def test_empty_text(self): + actual, _, ok = check_chapter_length("", target_words=4000, min_pct=85) + # "".split() returns [], len == 0 + assert actual == 0 + assert ok is False + + +# --------------------------------------------------------------------------- +# build_chapter_expansion_prompt — prompt builder +# --------------------------------------------------------------------------- + +class TestBuildChapterExpansionPrompt: + """Validate the expansion prompt builder returns well-formed messages.""" + + def test_returns_message_list(self): + msgs = build_chapter_expansion_prompt( + chapter_text="Short chapter text.", + current_words=3, + target_words=4000, + min_words=3400, + ) + assert isinstance(msgs, list) + assert len(msgs) == 2 + assert msgs[0]["role"] == "system" + assert msgs[1]["role"] == "user" + + def test_includes_word_counts_in_user_prompt(self): + msgs = build_chapter_expansion_prompt( + chapter_text="Hello world", + current_words=2, + target_words=4000, + min_words=3400, + ) + user_content = msgs[1]["content"] + assert "3,400" in user_content + assert "4,000" in user_content + + def test_includes_chapter_text(self): + text = "Once upon a time there was a brave knight." + msgs = build_chapter_expansion_prompt( + chapter_text=text, + current_words=9, + target_words=4000, + min_words=3400, + ) + assert text in msgs[1]["content"] + + +# --------------------------------------------------------------------------- +# expand_chapter — expansion agent retry logic +# --------------------------------------------------------------------------- + +class TestExpandChapter: + """Test the expansion retry logic with mocked LLM calls.""" + + def test_successful_expansion(self, monkeypatch): + """When LLM returns longer text, expansion succeeds.""" + expanded_text = " ".join(["word"] * 4000) + + monkeypatch.setattr( + "novelforge.agents.chapter._helpers.call_llm", + lambda msgs, action="": expanded_text, + ) + result_text, result_wc = expand_chapter( + "short text", + target_words=4000, + min_words=3400, + chapter_num=1, + title="Test", + max_attempts=2, + ) + assert result_wc == 4000 + assert result_text == expanded_text + + def test_expansion_stops_when_threshold_met(self, monkeypatch): + """If first attempt meets threshold, second attempt is not called.""" + call_count = [0] + + def mock_llm(msgs, action=""): + call_count[0] += 1 + return " ".join(["word"] * 4000) + + monkeypatch.setattr( + "novelforge.agents.chapter._helpers.call_llm", + mock_llm, + ) + expand_chapter( + "short text", + target_words=4000, + min_words=3400, + chapter_num=1, + title="Test", + max_attempts=3, + ) + assert call_count[0] == 1 + + def test_expansion_retries_on_no_increase(self, monkeypatch): + """If expansion doesn't increase length, it stops early and keeps original.""" + call_count = [0] + + def mock_llm(msgs, action=""): + call_count[0] += 1 + return "still short" # same 2 words as input + + monkeypatch.setattr( + "novelforge.agents.chapter._helpers.call_llm", + mock_llm, + ) + original = "short text" + result_text, result_wc = expand_chapter( + original, + target_words=4000, + min_words=3400, + chapter_num=1, + title="Test", + max_attempts=3, + ) + # Should try once then stop since word count didn't increase + assert call_count[0] == 1 + # Original text is preserved since expansion didn't improve things + assert result_text == original + + def test_expansion_handles_llm_failure(self, monkeypatch): + """If LLM call fails, returns original text.""" + def mock_llm(msgs, action=""): + raise RuntimeError("API down") + + monkeypatch.setattr( + "novelforge.agents.chapter._helpers.call_llm", + mock_llm, + ) + original = "short text with few words" + result_text, result_wc = expand_chapter( + original, + target_words=4000, + min_words=3400, + chapter_num=1, + title="Test", + max_attempts=2, + ) + assert result_text == original + assert result_wc == len(original.split()) + + def test_zero_max_attempts_returns_original(self, monkeypatch): + """If max_attempts=0, no expansion is attempted.""" + call_count = [0] + + def mock_llm(msgs, action=""): + call_count[0] += 1 + return " ".join(["word"] * 4000) + + monkeypatch.setattr( + "novelforge.agents.chapter._helpers.call_llm", + mock_llm, + ) + result_text, result_wc = expand_chapter( + "short", + target_words=4000, + min_words=3400, + chapter_num=1, + title="Test", + max_attempts=0, + ) + assert call_count[0] == 0 + assert result_text == "short" + + def test_gradual_expansion_across_attempts(self, monkeypatch): + """Multiple expansion attempts can progressively increase length.""" + attempt = [0] + + def mock_llm(msgs, action=""): + attempt[0] += 1 + if attempt[0] == 1: + return " ".join(["word"] * 2500) # still under threshold + return " ".join(["word"] * 4000) # now over threshold + + monkeypatch.setattr( + "novelforge.agents.chapter._helpers.call_llm", + mock_llm, + ) + result_text, result_wc = expand_chapter( + "short", + target_words=4000, + min_words=3400, + chapter_num=1, + title="Test", + max_attempts=3, + ) + assert attempt[0] == 2 + assert result_wc == 4000 + + +# --------------------------------------------------------------------------- +# Config constants +# --------------------------------------------------------------------------- + +class TestLengthEnforcementConfig: + """Verify config constants exist and have sensible defaults.""" + + def test_chapter_min_length_pct_default(self, monkeypatch): + import novelforge.config as cfg + monkeypatch.delenv("CHAPTER_MIN_LENGTH_PCT", raising=False) + importlib.reload(cfg) + try: + assert cfg.CHAPTER_MIN_LENGTH_PCT == 85 + finally: + importlib.reload(cfg) + + def test_max_expansion_attempts_default(self, monkeypatch): + import novelforge.config as cfg + monkeypatch.delenv("MAX_EXPANSION_ATTEMPTS", raising=False) + importlib.reload(cfg) + try: + assert cfg.MAX_EXPANSION_ATTEMPTS == 2 + finally: + importlib.reload(cfg) + + def test_chapter_min_length_pct_in_all(self): + import novelforge.config as cfg + assert "CHAPTER_MIN_LENGTH_PCT" in cfg.__all__ + + def test_max_expansion_attempts_in_all(self): + import novelforge.config as cfg + assert "MAX_EXPANSION_ATTEMPTS" in cfg.__all__ + + def test_config_shim_exports_new_constants(self): + import config as shim + assert hasattr(shim, "CHAPTER_MIN_LENGTH_PCT") + assert hasattr(shim, "MAX_EXPANSION_ATTEMPTS") + + +# --------------------------------------------------------------------------- +# ProgressState schema +# --------------------------------------------------------------------------- + +class TestProgressStateLengthField: + """Verify the length_enforcement field is present in ProgressState.""" + + def test_length_enforcement_in_typed_dict(self): + from novelforge.progress import ProgressState + annotations = ProgressState.__annotations__ + assert "length_enforcement" in annotations + + +# --------------------------------------------------------------------------- +# Expansion prompt in prompts.yml +# --------------------------------------------------------------------------- + +class TestExpansionPromptExists: + """Verify the expansion prompt template loads correctly.""" + + def test_chapter_expansion_prompt_loads(self): + from novelforge.llm.prompts import _load_prompts + prompts = _load_prompts() + assert "chapter_expansion" in prompts + + def test_chapter_expansion_prompt_has_required_fields(self): + from novelforge.llm.prompts import _load_prompts + prompts = _load_prompts() + entry = prompts["chapter_expansion"] + assert "system" in entry + assert "user" in entry + assert "name" in entry