diff --git a/control_plane/authoring/prompts.py b/control_plane/authoring/prompts.py index 216b313..4d7be1e 100644 --- a/control_plane/authoring/prompts.py +++ b/control_plane/authoring/prompts.py @@ -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.", diff --git a/control_plane/authoring/standalone_scenes.py b/control_plane/authoring/standalone_scenes.py index 1fa082f..2ac1daf 100644 --- a/control_plane/authoring/standalone_scenes.py +++ b/control_plane/authoring/standalone_scenes.py @@ -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 = { diff --git a/tests/test_book_authoring_state.py b/tests/test_book_authoring_state.py index daac247..739c08e 100644 --- a/tests/test_book_authoring_state.py +++ b/tests/test_book_authoring_state.py @@ -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"}]