Allow traceable chapter beat expansion

This commit is contained in:
Daniel Maddern 2026-08-29 01:12:27 +07:00
parent 8a6eab543f
commit 864f3e80e2
3 changed files with 69 additions and 13 deletions

View file

@ -68,15 +68,19 @@ Target words: {target_words}
Return strict JSON in this shape: Return strict JSON in this shape:
{{"purpose":"","pov_character":"","tense":"past","location":"","time_context":"", {{"purpose":"","pov_character":"","tense":"past","location":"","time_context":"",
"present":[],"target_words":{target_words},"beats":[{{"text":"","required":true}}], "present":[],"target_words":{target_words},"beats":[{{"text":"","required":true,
"supports_beat_id":"","kind":""}}],
"exact_values":[],"constraints":[],"forbidden_events":[],"ending_state":"","final_image":"", "exact_values":[],"constraints":[],"forbidden_events":[],"ending_state":"","final_image":"",
"boundary_constraints":[],"continuity_questions":[]}} "boundary_constraints":[],"continuity_questions":[]}}
Use 3-8 concrete beats. Mark only indispensable events required:true. Preserve unresolved continuity questions Use 3-8 concrete beats. Mark only indispensable events required:true. Preserve unresolved continuity questions
instead of guessing. The ending state and final image must define where the scene stops. For a book-bound instead of guessing. The ending state and final image must define where the scene stops. For a book-bound
scene, repeat the approved chapter beats without expanding their events or state changes. Include an exact scene, do not restate or replace the approved chapter beats. Propose subordinate execution beats instead.
value only when it appears explicitly in the approved chapter packet or scene brief; omit numbers and Every proposed beat must name one approved supports_beat_id, use kind "dramatization" or "transition", and
classifications found only in background source context.""" remain non-required. It may stage action, resistance, dialogue pressure, sensory evidence, or movement that
realizes its parent beat, but it cannot add an outcome, agreement, state change, explanation, or arc movement.
Include an exact value only when it appears explicitly in the approved chapter packet or scene brief; omit
numbers and classifications found only in background source context."""
SCENE_IDEA_TYPES = { SCENE_IDEA_TYPES = {
"quiet_connection": "A short, low-stakes character moment whose meaning comes from attention or choice.", "quiet_connection": "A short, low-stakes character moment whose meaning comes from attention or choice.",

View file

@ -677,19 +677,43 @@ class StandaloneSceneService:
) )
approved_beats = [ approved_beats = [
{ {
"beat_id": str(item.get("beat_id") or "").strip(),
"text": str(item.get("text") or "").strip(), "text": str(item.get("text") or "").strip(),
"required": bool(item.get("required")), "required": bool(item.get("required")),
"kind": "contract",
} }
for item in chapter.get("beats") or [] for item in chapter.get("beats") or []
if isinstance(item, dict) and str(item.get("text") or "").strip() if isinstance(item, dict) and str(item.get("text") or "").strip()
] ]
approved_text = {item["text"].casefold() for item in approved_beats} approved_by_text = {item["text"].casefold(): item for item in approved_beats}
discarded = [ approved_ids = {item["beat_id"] for item in approved_beats}
item expansions_by_parent = {beat_id: [] for beat_id in approved_ids}
for item in plan["beats"] discarded = []
if str(item.get("text") or "").strip().casefold() not in approved_text for item in plan["beats"]:
] text = str(item.get("text") or "").strip()
plan["beats"] = approved_beats if text.casefold() in approved_by_text:
continue
supports_beat_id = str(item.get("supports_beat_id") or "").strip()
kind = str(item.get("kind") or "").strip().lower()
if supports_beat_id not in approved_ids or kind not in {
"dramatization",
"transition",
}:
discarded.append(item)
continue
expansions_by_parent[supports_beat_id].append(
{
"text": text,
"required": False,
"supports_beat_id": supports_beat_id,
"kind": kind,
}
)
scoped_beats = []
for approved in approved_beats:
scoped_beats.append(approved)
scoped_beats.extend(expansions_by_parent[approved["beat_id"]])
plan["beats"] = scoped_beats
plan["purpose"] = str(chapter.get("purpose") or "").strip() plan["purpose"] = str(chapter.get("purpose") or "").strip()
plan["ending_state"] = str(chapter.get("ending_state") or "").strip() plan["ending_state"] = str(chapter.get("ending_state") or "").strip()
return plan, discarded return plan, discarded
@ -1145,7 +1169,14 @@ class StandaloneSceneService:
raw = {"text": raw, "required": False} raw = {"text": raw, "required": False}
text = str(raw.get("text") or "").strip() text = str(raw.get("text") or "").strip()
if text: if text:
beats.append({"text": text, "required": bool(raw.get("required"))}) beat = {"text": text, "required": bool(raw.get("required"))}
supports_beat_id = str(raw.get("supports_beat_id") or "").strip()
kind = str(raw.get("kind") or "").strip().lower()
if supports_beat_id:
beat["supports_beat_id"] = supports_beat_id
if kind:
beat["kind"] = kind
beats.append(beat)
if not beats: if not beats:
raise ValueError("scene plan must contain at least one beat") raise ValueError("scene plan must contain at least one beat")
plan = { plan = {

View file

@ -53,6 +53,12 @@ class OutOfScopePlanningRouter:
"present": ["Protagonist"], "present": ["Protagonist"],
"beats": [ "beats": [
{"text": "The chapter performs turn 1.", "required": True}, {"text": "The chapter performs turn 1.", "required": True},
{
"text": "The protagonist opens the record and challenges its premise.",
"required": True,
"supports_beat_id": "beat-1",
"kind": "dramatization",
},
{ {
"text": "The planner adds a Floor ninety-six inspection.", "text": "The planner adds a Floor ninety-six inspection.",
"required": True, "required": True,
@ -395,10 +401,25 @@ def test_book_scene_plan_discards_exact_values_outside_approved_scope(work: Work
{"label": "approved turn", "value": "turn 1"} {"label": "approved turn", "value": "turn 1"}
] ]
assert scene.plan["beats"] == [ assert scene.plan["beats"] == [
{"text": "The chapter performs turn 1.", "required": True} {
"beat_id": "beat-1",
"text": "The chapter performs turn 1.",
"required": True,
"kind": "contract",
},
{
"text": "The protagonist opens the record and challenges its premise.",
"required": False,
"supports_beat_id": "beat-1",
"kind": "dramatization",
},
] ]
assert scene.plan["purpose"] == "Advance turn 1." assert scene.plan["purpose"] == "Advance turn 1."
assert scene.plan["ending_state"] == "Turn 1 is complete." assert scene.plan["ending_state"] == "Turn 1 is complete."
beat_requirements = [
item for item in scene.contract_requirements if item["type"] == "BEAT"
]
assert [item["blocking"] for item in beat_requirements] == [True, False]
assert scene.generation_metadata["planning"][ assert scene.generation_metadata["planning"][
"discarded_out_of_scope_exact_values" "discarded_out_of_scope_exact_values"
] == [{"label": "background floor", "value": "Floor ninety-six"}] ] == [{"label": "background floor", "value": "Floor ninety-six"}]