Skip to content

Commit 932dcfe

Browse files
Robert WeberRobert Weber
authored andcommitted
Updated the prompts for quality control
1 parent 9e9e71b commit 932dcfe

12 files changed

Lines changed: 1483 additions & 94 deletions

File tree

novelforge/agents/chapter/pipeline.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,31 @@ def run_per_chapter_compression_check(chapter_num: int, chapter_summary: str, pr
111111
return ""
112112

113113

114-
def run_character_state_updater(chapter_text: str, chapter_summary: str, characters_text: str, chapter_num: int, title: str,
115-
degraded_passes: list[dict] | None = None) -> str:
116-
"""Run the character state updater, returning state changes or empty string on failure."""
114+
def run_character_state_updater(
115+
chapter_text: str, chapter_summary: str, characters_text: str,
116+
chapter_num: int, title: str,
117+
degraded_passes: list[dict] | None = None,
118+
) -> tuple[str, list[dict]]:
119+
"""Run the character state updater, returning ``(state_log, ledger_updates)``.
120+
121+
``state_log`` is the bullet-point character state text (empty string on
122+
failure). ``ledger_updates`` is a list of object-ledger delta records
123+
(empty list on failure or when no plot-critical object moved). The
124+
caller merges ledger_updates into a cumulative object ledger keyed by
125+
``object_name``.
126+
127+
If the LLM returns plain text instead of JSON (for example because the
128+
prompt change landed mid-run), the text is treated as the state log and
129+
the ledger updates are an empty list.
130+
"""
117131
try:
118-
return call_llm(
132+
raw = call_llm(
119133
build_character_state_updater_prompt(
120134
chapter_text=chapter_text, chapter_summary=chapter_summary,
121135
characters_text=characters_text, chapter_num=chapter_num, title=title,
122136
),
123-
action=f"Running Character State Updater for Chapter {chapter_num}"
137+
action=f"Running Character State Updater for Chapter {chapter_num}",
138+
json_mode=True,
124139
)
125140
except Exception as exc:
126141
failure_summary = _log_pass_failure(
@@ -133,7 +148,33 @@ def run_character_state_updater(chapter_text: str, chapter_summary: str, charact
133148
"chapter_num": chapter_num,
134149
"failure_summary": failure_summary,
135150
})
136-
return ""
151+
return "", []
152+
153+
# Tolerant parse: JSON preferred, plain text accepted as legacy fallback.
154+
try:
155+
data = parse_llm_json(raw)
156+
except Exception:
157+
data = None
158+
if isinstance(data, dict):
159+
state_log = str(data.get("character_state_log", "") or "").strip()
160+
ledger_updates_raw = data.get("object_ledger_updates", [])
161+
ledger_updates: list[dict] = []
162+
if isinstance(ledger_updates_raw, list):
163+
for item in ledger_updates_raw:
164+
if not isinstance(item, dict):
165+
continue
166+
object_name = str(item.get("object_name", "")).strip()
167+
if not object_name:
168+
continue
169+
ledger_updates.append({
170+
"object_name": object_name,
171+
"current_holder": str(item.get("current_holder", "")).strip(),
172+
"last_transfer_method": str(item.get("last_transfer_method", "")).strip(),
173+
"plot_critical": bool(item.get("plot_critical", True)),
174+
})
175+
return state_log, ledger_updates
176+
# Legacy / non-JSON response: keep the text and emit no ledger updates.
177+
return str(raw or "").strip(), []
137178

138179

139180
def _apply_name_substitution(text: str, old_name: str, replacement: str) -> str:
@@ -361,6 +402,9 @@ def run_continuity_gatekeeper(
361402
chapter_num: int, chapter_title: str, chapter_summary: str, previous_summaries: str,
362403
chapter_timeline_context: str = "", chapter_fate_context: str = "",
363404
chapter_arc_context: str = "", character_state_log: str = "",
405+
chapter_technology_context: str = "",
406+
chapter_rhythm_shape: str = "",
407+
object_ledger_context: str = "",
364408
degraded_passes: list[dict] | None = None,
365409
) -> str:
366410
"""Run the pre-chapter continuity gatekeeper, returning a brief or empty string on failure."""
@@ -373,6 +417,9 @@ def run_continuity_gatekeeper(
373417
chapter_fate_context=chapter_fate_context,
374418
chapter_arc_context=chapter_arc_context,
375419
character_state_log=character_state_log,
420+
chapter_technology_context=chapter_technology_context,
421+
chapter_rhythm_shape=chapter_rhythm_shape,
422+
object_ledger_context=object_ledger_context,
376423
),
377424
action=f"Running Continuity Gatekeeper for Chapter {chapter_num}",
378425
)

novelforge/agents/chapter/prompts.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,15 @@ def build_chapter_draft_prompt(
116116
voice_prompt: str = "", perspective_prompt: str = "",
117117
procedural_exemplars: str = "", chapter_openings_log: str = "",
118118
consequence_log: str = "", total_chapters: int = 0,
119+
object_ledger_context: str = "",
119120
) -> list[dict[str, str]]:
120-
"""Build the initial chapter draft prompt with all planning context."""
121+
"""Build the initial chapter draft prompt with all planning context.
122+
123+
``object_ledger_context`` carries the cumulative chain-of-custody for
124+
plot-critical items. When non-empty, the draft prompt surfaces each
125+
item's current holder so the LLM does not invent new custody (giving a
126+
character an object they don't canonically hold).
127+
"""
121128
return render_prompt(
122129
"chapter_draft",
123130
title=title, genre=genre, premise=premise,
@@ -146,6 +153,7 @@ def build_chapter_draft_prompt(
146153
soft_limited_words=", ".join(get_soft_limited_words(genre)),
147154
voice_prompt=voice_prompt or "",
148155
perspective_prompt=perspective_prompt or "",
156+
object_ledger_context=object_ledger_context or "",
149157
)
150158

151159

@@ -380,8 +388,28 @@ def build_continuity_gatekeeper_prompt(
380388
chapter_num: int, chapter_title: str, chapter_summary: str, previous_summaries: str,
381389
chapter_timeline_context: str = "", chapter_fate_context: str = "",
382390
chapter_arc_context: str = "", character_state_log: str = "",
391+
chapter_technology_context: str = "",
392+
chapter_rhythm_shape: str = "",
393+
object_ledger_context: str = "",
383394
) -> list[dict[str, str]]:
384-
"""Build the pre-chapter continuity validation prompt."""
395+
"""Build the pre-chapter continuity validation prompt.
396+
397+
``chapter_technology_context`` carries the world-rule specification
398+
(``rules`` array plus supplementary detail) for this chapter. When
399+
present, the gatekeeper is expected to flag scenes that violate any
400+
rule's forbidden clause or alter its cost.
401+
402+
``chapter_rhythm_shape`` carries the assigned rhythm. When it is
403+
``"climax-focal"`` or ``"aftermath"`` (the two reserved climax-gate
404+
rhythms) the gatekeeper enforces the extra rules in the template —
405+
blocking deferred climactic choices, externally-resolved climaxes, and
406+
post-climax re-staging.
407+
408+
``object_ledger_context`` carries the cumulative chain-of-custody for
409+
plot-critical items. When non-empty, the gatekeeper flags any
410+
chapter-plan scene that would have a character interact with an object
411+
they do not canonically hold, without an explicit transfer scene.
412+
"""
385413
return render_prompt(
386414
"continuity_gatekeeper",
387415
chapter_num=chapter_num, chapter_title=chapter_title,
@@ -391,6 +419,9 @@ def build_continuity_gatekeeper_prompt(
391419
chapter_fate_context=chapter_fate_context or "",
392420
chapter_arc_context=chapter_arc_context or "",
393421
character_state_log=character_state_log or "",
422+
chapter_technology_context=chapter_technology_context or "",
423+
chapter_rhythm_shape=chapter_rhythm_shape or "",
424+
object_ledger_context=object_ledger_context or "",
394425
)
395426

396427

novelforge/agents/planning/character_arc.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,35 @@ def _build_fallback_impl(character_list: list[dict], chapter_list: list[dict]) -
8080
{"chapter": vuln_ch_2, "scene": f"{name} shows vulnerability through action — admits fault, asks for help, or breaks composure."},
8181
]
8282

83+
# Plant 2-3 foreshadow beats per major pivot in earlier chapters
84+
# so each arc turn (midpoint, crisis, final) feels earned.
85+
# Foreshadow chapters are clamped to land BEFORE the pivot they
86+
# telegraph, and at least chapter 2 to leave room for setup.
87+
def _foreshadow_chapters(pivot_ch: int) -> list[int]:
88+
slots = [
89+
max(2, pivot_ch - max(1, pivot_ch // 3)),
90+
max(2, pivot_ch - max(1, pivot_ch // 2)),
91+
]
92+
return sorted({c for c in slots if c < pivot_ch})
93+
94+
foreshadow_beats: list[dict] = []
95+
for pivot_label, pivot_ch in (
96+
("midpoint", midpoint_chapter),
97+
("crisis", crisis_chapter),
98+
("final", final_chapter),
99+
):
100+
for fch in _foreshadow_chapters(pivot_ch):
101+
foreshadow_beats.append({
102+
"chapter": fch,
103+
"foreshadows": pivot_label,
104+
"beat": (
105+
f"Plant a small, dramatized moment for {name} that "
106+
f"telegraphs the upcoming {pivot_label} — a line of "
107+
f"dialogue, a decision, or a visible reaction that "
108+
f"the reader will recognize in retrospect."
109+
),
110+
})
111+
83112
arcs.append(
84113
{
85114
"character": name,
@@ -110,6 +139,7 @@ def _build_fallback_impl(character_list: list[dict], chapter_list: list[dict]) -
110139
},
111140
],
112141
"vulnerability_scenes": vulnerability_scenes,
142+
"foreshadow_beats": foreshadow_beats,
113143
"consistency_rules": [
114144
"Arc must move forward each appearance.",
115145
"No regression to start state after midpoint without explicit cause.",
@@ -187,6 +217,31 @@ def normalise(self, data: dict, **ctx) -> dict:
187217
"scene": str(vs.get("scene", "")).strip(),
188218
})
189219

220+
# Foreshadow beats — scheduled plants for upcoming pivots. Each
221+
# beat binds a chapter to a specific pivot it telegraphs; the
222+
# per-chapter Character agent verifies the beat is on-page when
223+
# the scheduled chapter is written. Records missing a non-empty
224+
# ``beat`` field are dropped (they cannot be verified).
225+
foreshadow_beats = item.get("foreshadow_beats", [])
226+
if not isinstance(foreshadow_beats, list):
227+
foreshadow_beats = []
228+
normalised_foreshadow: list[dict] = []
229+
allowed_pivots = {"midpoint", "crisis", "final"}
230+
for fb in foreshadow_beats:
231+
if not isinstance(fb, dict):
232+
continue
233+
beat_text = str(fb.get("beat", "")).strip()
234+
if not beat_text:
235+
continue
236+
foreshadows = str(fb.get("foreshadows", "")).strip().lower()
237+
if foreshadows not in allowed_pivots:
238+
foreshadows = "midpoint"
239+
normalised_foreshadow.append({
240+
"chapter": _coerce_positive_int(fb.get("chapter"), 1),
241+
"foreshadows": foreshadows,
242+
"beat": beat_text,
243+
})
244+
190245
normalised_arcs.append(
191246
{
192247
"character": name,
@@ -200,6 +255,7 @@ def normalise(self, data: dict, **ctx) -> dict:
200255
"arc_theme": str(item.get("arc_theme", "")).strip(),
201256
"chapter_beats": normalised_beats,
202257
"vulnerability_scenes": normalised_vuln,
258+
"foreshadow_beats": normalised_foreshadow,
203259
"consistency_rules": [str(x) for x in consistency_rules if str(x).strip()],
204260
}
205261
)
@@ -263,7 +319,15 @@ def get_chapter_context(self, plan: dict, chapter_num: int) -> str:
263319
if isinstance(vs, dict) and _coerce_positive_int(vs.get("chapter"), 0) == chapter_num
264320
]
265321

266-
if matching_beats or matching_vuln:
322+
foreshadow_beats = arc.get("foreshadow_beats", [])
323+
if not isinstance(foreshadow_beats, list):
324+
foreshadow_beats = []
325+
matching_foreshadow = [
326+
fb for fb in foreshadow_beats
327+
if isinstance(fb, dict) and _coerce_positive_int(fb.get("chapter"), 0) == chapter_num
328+
]
329+
330+
if matching_beats or matching_vuln or matching_foreshadow:
267331
lines.append(
268332
f"- {char_name}: start={arc.get('start_state', '')}; midpoint={arc.get('midpoint_transformation', '')}; "
269333
f"crisis={arc.get('crisis_point', '')}; final_choice={arc.get('final_moral_choice', '')}"
@@ -281,6 +345,13 @@ def get_chapter_context(self, plan: dict, chapter_num: int) -> str:
281345
f" - *** VULNERABILITY SCENE (this chapter): {vs.get('scene', '')} — "
282346
f"Show this through ACTION and DIALOGUE, not narration. ***"
283347
)
348+
for fb in matching_foreshadow:
349+
pivot = str(fb.get("foreshadows", "")).strip() or "upcoming pivot"
350+
lines.append(
351+
f" - *** FORESHADOW BEAT (this chapter, telegraphs {pivot}): "
352+
f"{fb.get('beat', '')} — Must appear on-page through a "
353+
f"dramatized moment (dialogue, decision, or visible reaction). ***"
354+
)
284355
rules = arc.get("consistency_rules", [])
285356
if isinstance(rules, list) and rules:
286357
lines.append(" - Arc rules: " + "; ".join(str(x) for x in rules[:4]))

novelforge/agents/planning/technology_rules.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ def _build_fallback_impl(chapter_list: list[dict]) -> dict:
3737
"""Create a deterministic fallback with a single surveillance system and chapter constraints."""
3838
safe_chapter_list = _safe_chapter_list(chapter_list)
3939
total_chapters = max(1, len(safe_chapter_list))
40+
# Minimal default rules — intentionally sparse. The fallback exists
41+
# only when the LLM call fails entirely; a real novel's LLM output
42+
# will supply 4-10 concrete, genre-appropriate rules.
43+
rules = [
44+
{
45+
"capability": "Primary detection apparatus",
46+
"cost": "Finite compute budget; cannot run every check simultaneously.",
47+
"forbidden": "Instant omniscient tracking of all targets; retroactive perfect reconstruction of past events.",
48+
"exception": "none",
49+
}
50+
]
4051
systems = [
4152
{
4253
"name": "Primary Surveillance Grid",
@@ -60,6 +71,7 @@ def _build_fallback_impl(chapter_list: list[dict]) -> dict:
6071
"must_not_allow": ["Do not grant instant detection or infinite processing without explicit setup."],
6172
})
6273
return {
74+
"rules": rules,
6375
"systems": systems,
6476
"global_constraints": [
6577
"Every tech action has delay, uncertainty, or resource cost.",
@@ -76,6 +88,29 @@ def normalise(self, data: dict, **ctx) -> dict:
7688
if not isinstance(data, dict):
7789
return fallback
7890

91+
# Normalise the authoritative `rules` array. Each rule must have a
92+
# non-empty `capability`; other fields default to empty string or
93+
# "none" so the gatekeeper can always render them.
94+
raw_rules = data.get("rules", [])
95+
if not isinstance(raw_rules, list):
96+
raw_rules = []
97+
normalised_rules: list[dict] = []
98+
seen_capabilities: set[str] = set()
99+
for item in raw_rules:
100+
if not isinstance(item, dict):
101+
continue
102+
capability = str(item.get("capability", "")).strip()
103+
if not capability or capability.lower() in seen_capabilities:
104+
continue
105+
seen_capabilities.add(capability.lower())
106+
exception = str(item.get("exception", "")).strip() or "none"
107+
normalised_rules.append({
108+
"capability": capability,
109+
"cost": str(item.get("cost", "")).strip(),
110+
"forbidden": str(item.get("forbidden", "")).strip(),
111+
"exception": exception,
112+
})
113+
79114
raw_systems = data.get("systems", [])
80115
if not isinstance(raw_systems, list):
81116
raw_systems = []
@@ -126,21 +161,47 @@ def normalise(self, data: dict, **ctx) -> dict:
126161
continuity_risks = []
127162

128163
return {
164+
"rules": normalised_rules or fallback["rules"],
129165
"systems": normalised_systems or fallback["systems"],
130166
"global_constraints": [str(x) for x in global_constraints if str(x).strip()] or fallback["global_constraints"],
131167
"chapter_constraints": normalised_constraints or fallback["chapter_constraints"],
132168
"continuity_risks": [str(x) for x in continuity_risks if str(x).strip()],
133169
}
134170

135171
def get_chapter_context(self, plan: dict, chapter_num: int) -> str:
136-
"""Format technology systems and constraints as a prompt snippet for a chapter."""
172+
"""Format world rules, systems, and constraints as a prompt snippet for a chapter.
173+
174+
Rules are surfaced at the top as the primary, enforceable constraints
175+
the Continuity Gatekeeper should match chapter plans against. Other
176+
fields (systems, global/chapter constraints, continuity risks) are
177+
supplementary detail.
178+
"""
137179
if not isinstance(plan, dict):
138180
return ""
139181

182+
lines = ["Technology Rules Designer output for this chapter:"]
183+
184+
# Primary: enforceable rules (capability / cost / forbidden / exception).
185+
rules = plan.get("rules", [])
186+
if isinstance(rules, list) and rules:
187+
lines.append("- ENFORCEABLE RULES:")
188+
for rule in rules[:10]:
189+
if not isinstance(rule, dict):
190+
continue
191+
capability = str(rule.get("capability", "?")).strip() or "?"
192+
cost = str(rule.get("cost", "")).strip()
193+
forbidden = str(rule.get("forbidden", "")).strip()
194+
exception = str(rule.get("exception", "")).strip() or "none"
195+
lines.append(f" - Rule '{capability}':")
196+
if cost:
197+
lines.append(f" · cost: {cost}")
198+
if forbidden:
199+
lines.append(f" · forbidden: {forbidden}")
200+
lines.append(f" · exception: {exception}")
201+
140202
systems = plan.get("systems", [])
141203
if not isinstance(systems, list):
142204
systems = []
143-
lines = ["Technology Rules Designer output for this chapter:"]
144205
for system in systems[:6]:
145206
if not isinstance(system, dict):
146207
continue

0 commit comments

Comments
 (0)