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:
{{"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":"",
"boundary_constraints":[],"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
scene, repeat the approved chapter beats without expanding their events or state changes. 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, do not restate or replace the approved chapter beats. Propose subordinate execution beats instead.
Every proposed beat must name one approved supports_beat_id, use kind "dramatization" or "transition", and
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 = {
"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 = [
{
"beat_id": str(item.get("beat_id") or "").strip(),
"text": str(item.get("text") or "").strip(),
"required": bool(item.get("required")),
"kind": "contract",
}
for item in chapter.get("beats") or []
if isinstance(item, dict) and str(item.get("text") or "").strip()
]
approved_text = {item["text"].casefold() for item in approved_beats}
discarded = [
item
for item in plan["beats"]
if str(item.get("text") or "").strip().casefold() not in approved_text
]
plan["beats"] = approved_beats
approved_by_text = {item["text"].casefold(): item for item in approved_beats}
approved_ids = {item["beat_id"] for item in approved_beats}
expansions_by_parent = {beat_id: [] for beat_id in approved_ids}
discarded = []
for item in plan["beats"]:
text = str(item.get("text") or "").strip()
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["ending_state"] = str(chapter.get("ending_state") or "").strip()
return plan, discarded
@ -1145,7 +1169,14 @@ class StandaloneSceneService:
raw = {"text": raw, "required": False}
text = str(raw.get("text") or "").strip()
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:
raise ValueError("scene plan must contain at least one beat")
plan = {

View file

@ -53,6 +53,12 @@ class OutOfScopePlanningRouter:
"present": ["Protagonist"],
"beats": [
{"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.",
"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"}
]
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["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"][
"discarded_out_of_scope_exact_values"
] == [{"label": "background floor", "value": "Floor ninety-six"}]