624 lines
22 KiB
Python
624 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from django.test import Client
|
|
from django.urls import reverse
|
|
|
|
from control_plane.authoring.book_state import BookStateService, validate_book_state_content
|
|
from control_plane.authoring.models import (
|
|
BookRunStatus,
|
|
BookStateStatus,
|
|
SceneDraftStatus,
|
|
Series,
|
|
StandaloneScene,
|
|
StoryProject,
|
|
Work,
|
|
)
|
|
from control_plane.authoring.standalone_scenes import StandaloneSceneService
|
|
from control_plane.projects.models import Project
|
|
from model_router.router import ModelCapability, ModelResponseContract
|
|
|
|
pytestmark = pytest.mark.django_db(transaction=True)
|
|
|
|
|
|
class FakeReviewRouter:
|
|
def __init__(self) -> None:
|
|
self.requests = []
|
|
|
|
def complete(self, request):
|
|
self.requests.append(request)
|
|
return ModelResponseContract(
|
|
model="test-reviewer",
|
|
content=json.dumps({"findings": []}),
|
|
metadata={},
|
|
)
|
|
|
|
|
|
class OutOfScopePlanningRouter:
|
|
def complete(self, request):
|
|
assert str(request.purpose) == str(ModelCapability.STORY_PLANNING)
|
|
return ModelResponseContract(
|
|
model="test-planner",
|
|
content=json.dumps(
|
|
{
|
|
"purpose": "Perform the approved turn.",
|
|
"pov_character": "Protagonist",
|
|
"tense": "past",
|
|
"location": "Records room",
|
|
"time_context": "Chapter 1",
|
|
"present": ["Protagonist"],
|
|
"beats": [
|
|
{"text": "The chapter performs turn 1.", "required": True},
|
|
{
|
|
"text": "The planner adds a Floor ninety-six inspection.",
|
|
"required": True,
|
|
},
|
|
],
|
|
"exact_values": [
|
|
{"label": "approved turn", "value": "turn 1"},
|
|
{"label": "background floor", "value": "Floor ninety-six"},
|
|
],
|
|
"constraints": [],
|
|
"forbidden_events": [],
|
|
"ending_state": "Turn 1 is complete.",
|
|
"final_image": "The protagonist closes the record.",
|
|
"boundary_constraints": [],
|
|
"continuity_questions": [],
|
|
}
|
|
),
|
|
metadata={},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def work(tmp_path: Path) -> Work:
|
|
series = Series.objects.create(title="Test Series", slug="test-series")
|
|
work = Work.objects.create(series=series, title="Test Book", slug="test-book")
|
|
project = Project.objects.create(name="Test Book", project_type="STORY", goal="Write")
|
|
StoryProject.objects.create(
|
|
project=project,
|
|
work=work,
|
|
title=work.title,
|
|
series=series.title,
|
|
slug=work.slug,
|
|
artifact_root=str(tmp_path),
|
|
)
|
|
return work
|
|
|
|
|
|
def book_content(chapter_count: int = 3) -> dict:
|
|
chapters = []
|
|
for number in range(1, chapter_count + 1):
|
|
key = f"chapter-{number}"
|
|
chapters.append(
|
|
{
|
|
"chapter_key": key,
|
|
"number": number,
|
|
"order": number,
|
|
"title": f"Chapter {number}",
|
|
"purpose": f"Advance turn {number}.",
|
|
"target_words": 1800,
|
|
"depends_on": [] if number == 1 else [f"chapter-{number - 1}"],
|
|
"act_id": "act-1",
|
|
"arc_ids": ["main-arc"],
|
|
"thread_ids": ["main-thread"],
|
|
"beats": [
|
|
{
|
|
"beat_id": f"beat-{number}",
|
|
"text": f"The chapter performs turn {number}.",
|
|
"required": True,
|
|
}
|
|
],
|
|
"ending_state": f"Turn {number} is complete.",
|
|
"scene_refs": [],
|
|
}
|
|
)
|
|
return {
|
|
"schema_version": 1,
|
|
"title": "Test Book",
|
|
"premise": "A protagonist makes progressively harder choices.",
|
|
"target_words": chapter_count * 1800,
|
|
"constraints": ["Preserve agency."],
|
|
"forbidden_events": [],
|
|
"acts": [
|
|
{
|
|
"act_id": "act-1",
|
|
"number": 1,
|
|
"start_chapter": 1,
|
|
"end_chapter": chapter_count,
|
|
}
|
|
],
|
|
"arcs": [
|
|
{
|
|
"arc_id": "main-arc",
|
|
"start_chapter": "chapter-1",
|
|
"end_chapter": f"chapter-{chapter_count}",
|
|
}
|
|
],
|
|
"threads": [
|
|
{
|
|
"thread_id": "main-thread",
|
|
"start_chapter": "chapter-1",
|
|
"end_chapter": f"chapter-{chapter_count}",
|
|
}
|
|
],
|
|
"continuity": [
|
|
{
|
|
"fact_id": "fact-choice",
|
|
"entity_key": "character.protagonist",
|
|
"category": "character",
|
|
"description": "The protagonist has made the first choice.",
|
|
"established_in": "chapter-1",
|
|
"resolved_in": f"chapter-{chapter_count}",
|
|
}
|
|
],
|
|
"chapters": chapters,
|
|
"ending": "The central choice has lasting consequences.",
|
|
"open_questions": [],
|
|
}
|
|
|
|
|
|
def passing_reviews(state) -> dict:
|
|
state_hash = state.sha256
|
|
return {
|
|
"act:act-1": {"passed": True, "state_sha256": state_hash},
|
|
"manuscript": {"passed": True, "state_sha256": state_hash},
|
|
}
|
|
|
|
|
|
def make_review_ready(state) -> None:
|
|
state.status = BookStateStatus.REVIEW
|
|
state.reviews = passing_reviews(state)
|
|
state.save(update_fields=["status", "reviews", "updated_at"])
|
|
|
|
|
|
def test_validation_rejects_forward_dependency_and_sorts_messages(work: Work) -> None:
|
|
content = book_content()
|
|
content["chapters"][0]["depends_on"] = ["chapter-2"]
|
|
|
|
result = validate_book_state_content(content, work=work)
|
|
|
|
assert result["valid"] is False
|
|
assert result["errors"] == sorted(result["errors"])
|
|
assert any("must name an earlier chapter" in error for error in result["errors"])
|
|
|
|
|
|
def test_validation_checks_acts_continuity_and_approval_ending(work: Work) -> None:
|
|
content = book_content()
|
|
content["acts"][0]["end_chapter"] = 2
|
|
content["continuity"][0]["resolved_in"] = "chapter-1"
|
|
content["continuity"][0]["established_in"] = "chapter-2"
|
|
content["chapters"][1]["ending_state"] = ""
|
|
|
|
draft = validate_book_state_content(content, work=work)
|
|
approval = validate_book_state_content(content, work=work, for_approval=True)
|
|
|
|
assert any("ranges must cover every chapter" in error for error in draft["errors"])
|
|
assert any("cannot precede establishment" in error for error in draft["errors"])
|
|
assert any("ending_state" in warning for warning in draft["warnings"])
|
|
assert any("ending_state" in error for error in approval["errors"])
|
|
|
|
|
|
def test_validation_reports_malformed_word_targets_without_raising(work: Work) -> None:
|
|
content = book_content(1)
|
|
content["chapters"][0]["target_words"] = "many"
|
|
|
|
result = validate_book_state_content(content, work=work)
|
|
|
|
assert result["valid"] is False
|
|
assert any("target_words" in error for error in result["errors"])
|
|
|
|
|
|
def test_service_allocates_versions_and_preserves_parent(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
first = service.create(work=work, content=book_content())
|
|
revised_content = deepcopy(first.content)
|
|
revised_content["open_questions"] = ["Who notices the cost?"]
|
|
second = service.revise(first, content=revised_content)
|
|
|
|
assert (first.version, second.version) == (1, 2)
|
|
assert second.parent == first
|
|
assert second.change_summary["changed_sections"] == ["open_questions"]
|
|
assert second.change_summary["changed_from_chapter"] is None
|
|
first.content["premise"] = "Mutation is forbidden."
|
|
with pytest.raises(ValueError, match="immutable"):
|
|
first.save()
|
|
|
|
|
|
def test_impact_propagates_contract_change_to_dependents_and_later_continuity(
|
|
work: Work,
|
|
) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
first = service.create(work=work, content=book_content())
|
|
changed = deepcopy(first.content)
|
|
changed["chapters"][1]["purpose"] = "Force a materially different second turn."
|
|
second = service.revise(first, content=changed)
|
|
|
|
impact = service.impact(second)
|
|
|
|
assert impact["from_chapter"] == 2
|
|
assert impact["replan_required"] == ["chapter-2", "chapter-3"]
|
|
assert impact["continuity_review_required"] == ["chapter-3"]
|
|
|
|
|
|
def test_approval_requires_all_reviews_and_rejects_stale_parent(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
first = service.create(work=work, content=book_content())
|
|
first.status = BookStateStatus.REVIEW
|
|
first.reviews = {"manuscript": {"passed": True, "state_sha256": first.sha256}}
|
|
first.save(update_fields=["status", "reviews", "updated_at"])
|
|
with pytest.raises(ValueError, match="act:act-1"):
|
|
service.approve(first)
|
|
|
|
first.reviews = passing_reviews(first)
|
|
first.save(update_fields=["reviews", "updated_at"])
|
|
service.approve(first, actor="editor")
|
|
child = service.revise(first, content=deepcopy(first.content))
|
|
make_review_ready(child)
|
|
work.current_book_state = None
|
|
work.save(update_fields=["current_book_state", "updated_at"])
|
|
|
|
with pytest.raises(ValueError, match="stale"):
|
|
service.approve(child)
|
|
|
|
|
|
def test_approval_allows_review_revisions_before_first_approved_state(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
first = service.create(work=work, content=book_content(1))
|
|
second_content = deepcopy(first.content)
|
|
second_content["open_questions"] = ["What must the pilot verify?"]
|
|
second = service.revise(first, content=second_content)
|
|
third_content = deepcopy(second.content)
|
|
third_content["open_questions"] = ["What evidence resolves the review finding?"]
|
|
third = service.revise(second, content=third_content)
|
|
make_review_ready(third)
|
|
|
|
approved = service.approve(third, actor="editor")
|
|
|
|
work.refresh_from_db()
|
|
assert approved.status == BookStateStatus.APPROVED
|
|
assert work.current_book_state == approved
|
|
|
|
|
|
def test_review_routes_structure_and_continuity_to_distinct_capabilities(work: Work) -> None:
|
|
router = FakeReviewRouter()
|
|
service = BookStateService(router)
|
|
state = service.create(work=work, content=book_content())
|
|
|
|
act_review = service.review(state, "act:act-1")
|
|
manuscript_review = service.review(state, "manuscript")
|
|
continuity_review = service.review(state, "continuity")
|
|
|
|
assert act_review["passed"] and manuscript_review["passed"] and continuity_review["passed"]
|
|
assert [str(request.purpose) for request in router.requests] == [
|
|
"STORY_REVIEW",
|
|
"STORY_REVIEW",
|
|
"STORY_CONTINUITY",
|
|
]
|
|
|
|
|
|
def test_run_sync_uses_only_scenes_bound_to_exact_state_and_chapter(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
state = service.create(work=work, content=book_content(2))
|
|
make_review_ready(state)
|
|
state = service.approve(state)
|
|
run = service.start_run(state)
|
|
|
|
run = service.sync_run(run)
|
|
assert run.status == BookRunStatus.PAUSED
|
|
assert run.current_chapter_key == "chapter-1"
|
|
assert run.progress == {"chapter-1": "needs_scene", "chapter-2": "needs_scene"}
|
|
|
|
StandaloneScene.objects.create(
|
|
work=work,
|
|
book_state=state,
|
|
book_chapter_key="chapter-1",
|
|
scene_key="chapter-1-scene",
|
|
revision=1,
|
|
title="Chapter 1 Scene",
|
|
status=SceneDraftStatus.APPROVED,
|
|
brief="Perform the first turn.",
|
|
prose="The protagonist made the first choice.",
|
|
)
|
|
run = service.sync_run(run)
|
|
|
|
assert run.progress["chapter-1"] == "complete"
|
|
assert run.current_chapter_key == "chapter-2"
|
|
|
|
StandaloneScene.objects.create(
|
|
work=work,
|
|
book_state=state,
|
|
book_chapter_key="chapter-2",
|
|
scene_key="chapter-2-scene",
|
|
revision=1,
|
|
title="Chapter 2 Scene",
|
|
status=SceneDraftStatus.APPROVED,
|
|
brief="Perform the second turn.",
|
|
prose="The protagonist made the second choice.",
|
|
)
|
|
run = service.sync_run(run)
|
|
assert run.status == BookRunStatus.REVIEW
|
|
|
|
review = service.review_run(run)
|
|
run.refresh_from_db()
|
|
|
|
assert review["passed"] is True
|
|
assert run.status == BookRunStatus.COMPLETE
|
|
assert run.reviews["continuity"]["scene_set_sha256"]
|
|
assert [item["chapter_key"] for item in run.reviews["continuity"]["scene_manifest"]] == [
|
|
"chapter-1",
|
|
"chapter-2",
|
|
]
|
|
|
|
scene_service = StandaloneSceneService(FakeReviewRouter())
|
|
with pytest.raises(ValueError, match="start a new book run"):
|
|
scene_service.create(
|
|
work=work,
|
|
title="Chapter 2 Scene",
|
|
brief="Revise after completion.",
|
|
book_state=state,
|
|
book_chapter_key="chapter-2",
|
|
)
|
|
service.start_run(state)
|
|
revision = scene_service.create(
|
|
work=work,
|
|
title="Chapter 2 Scene",
|
|
brief="Revise in an explicit new run.",
|
|
book_state=state,
|
|
book_chapter_key="chapter-2",
|
|
)
|
|
assert revision.revision == 2
|
|
|
|
|
|
def test_book_scene_plan_discards_exact_values_outside_approved_scope(work: Work) -> None:
|
|
state_service = BookStateService(FakeReviewRouter())
|
|
state = state_service.create(work=work, content=book_content(1))
|
|
make_review_ready(state)
|
|
state = state_service.approve(state)
|
|
state_service.start_run(state)
|
|
scene_service = StandaloneSceneService(OutOfScopePlanningRouter())
|
|
scene = scene_service.create(
|
|
work=work,
|
|
title="Chapter 1",
|
|
brief="Perform turn 1 and stop.",
|
|
book_state=state,
|
|
book_chapter_key="chapter-1",
|
|
)
|
|
|
|
scene = scene_service.plan(scene)
|
|
|
|
assert scene.plan["exact_values"] == [
|
|
{"label": "approved turn", "value": "turn 1"}
|
|
]
|
|
assert scene.plan["beats"] == [
|
|
{"text": "The chapter performs turn 1.", "required": True}
|
|
]
|
|
assert scene.plan["purpose"] == "Advance turn 1."
|
|
assert scene.plan["ending_state"] == "Turn 1 is complete."
|
|
assert scene.generation_metadata["planning"][
|
|
"discarded_out_of_scope_exact_values"
|
|
] == [{"label": "background floor", "value": "Floor ninety-six"}]
|
|
assert scene.generation_metadata["planning"]["discarded_model_proposed_beats"] == [
|
|
{
|
|
"text": "The planner adds a Floor ninety-six inspection.",
|
|
"required": True,
|
|
}
|
|
]
|
|
|
|
|
|
def test_lifecycle_persists_audit_metadata_and_run_policy(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
state = service.create(
|
|
work=work,
|
|
content=book_content(1),
|
|
actor="planner",
|
|
context_pack={"sources": ["outline-v2"]},
|
|
generation_metadata={"model": "test-planner"},
|
|
)
|
|
state.status = BookStateStatus.REVIEW
|
|
state.save(update_fields=["status", "updated_at"])
|
|
|
|
with pytest.raises(ValueError, match="requires notes"):
|
|
service.approve(state, actor="editor", force=True)
|
|
state = service.approve(state, actor="editor", force=True, notes="Manual exception.")
|
|
run = service.start_run(state, policy={"max_parallel_chapters": 1})
|
|
|
|
assert state.created_by == "planner"
|
|
assert state.context_pack == {"sources": ["outline-v2"]}
|
|
assert state.generation_metadata == {"model": "test-planner"}
|
|
assert state.approval_forced is True
|
|
assert state.approval_notes == "Manual exception."
|
|
assert run.policy == {
|
|
"max_parallel_chapters": 1,
|
|
"required_reviews": ["act:act-1", "manuscript"],
|
|
"run_required_reviews": ["continuity"],
|
|
}
|
|
|
|
|
|
def test_rejection_persists_actor_notes_and_timestamp(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
state = service.create(work=work, content=book_content(1))
|
|
|
|
state = service.reject(state, actor="editor", notes="Dependency needs revision.")
|
|
|
|
assert state.status == BookStateStatus.REJECTED
|
|
assert state.rejected_at is not None
|
|
assert state.rejected_by == "editor"
|
|
assert state.rejection_notes == "Dependency needs revision."
|
|
|
|
|
|
def test_book_state_artifact_paths_remain_pinned_through_approval(
|
|
work: Work, tmp_path: Path
|
|
) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
state = service.create(work=work, content=book_content(1))
|
|
original_json_uri = state.json_artifact_uri
|
|
original_markdown_uri = state.markdown_artifact_uri
|
|
story = work.story_project
|
|
story.artifact_root = str(tmp_path / "moved-artifacts")
|
|
story.save(update_fields=["artifact_root", "updated_at"])
|
|
make_review_ready(state)
|
|
|
|
state = service.approve(state)
|
|
|
|
assert state.json_artifact_uri == original_json_uri
|
|
assert state.markdown_artifact_uri == original_markdown_uri
|
|
|
|
|
|
def test_book_api_approval_and_run_actions_match_service_contract(
|
|
work: Work, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
monkeypatch.setattr("control_plane.authoring.views.book_service", lambda: service)
|
|
client = Client()
|
|
response = client.post(
|
|
reverse("book_states"),
|
|
data=json.dumps(
|
|
{
|
|
"series_slug": work.series.slug,
|
|
"work_slug": work.slug,
|
|
"content": book_content(1),
|
|
"actor": "api-planner",
|
|
"context_pack": {"sources": ["outline-v2"]},
|
|
}
|
|
),
|
|
content_type="application/json",
|
|
)
|
|
assert response.status_code == 201
|
|
state_id = response.json()["id"]
|
|
state = work.book_state_versions.get(id=state_id)
|
|
state.status = BookStateStatus.REVIEW
|
|
state.save(update_fields=["status", "updated_at"])
|
|
|
|
invalid_boolean = client.post(
|
|
reverse("book_state_action", args=[state.id]),
|
|
data=json.dumps(
|
|
{
|
|
"action": "approve",
|
|
"force": "false",
|
|
"notes": "This must not be treated as true.",
|
|
}
|
|
),
|
|
content_type="application/json",
|
|
)
|
|
|
|
approval = client.post(
|
|
reverse("book_state_action", args=[state.id]),
|
|
data=json.dumps(
|
|
{
|
|
"action": "approve",
|
|
"actor": "api-editor",
|
|
"force": True,
|
|
"notes": "Reviewed outside Artifex.",
|
|
}
|
|
),
|
|
content_type="application/json",
|
|
)
|
|
run = client.post(
|
|
reverse("book_state_action", args=[state.id]),
|
|
data=json.dumps({"action": "start_run", "policy": {"max_parallel_chapters": 1}}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert invalid_boolean.status_code == 400
|
|
assert invalid_boolean.json()["error"] == "force must be boolean"
|
|
assert approval.status_code == 200
|
|
assert approval.json()["approval_forced"] is True
|
|
assert approval.json()["approval_notes"] == "Reviewed outside Artifex."
|
|
assert run.status_code == 201
|
|
assert run.json()["policy"]["max_parallel_chapters"] == 1
|
|
|
|
|
|
def test_review_rejects_malformed_model_response(work: Work) -> None:
|
|
class MalformedReviewRouter(FakeReviewRouter):
|
|
def complete(self, request):
|
|
self.requests.append(request)
|
|
return ModelResponseContract(model="bad-reviewer", content="{}", metadata={})
|
|
|
|
service = BookStateService(MalformedReviewRouter())
|
|
state = service.create(work=work, content=book_content(1))
|
|
|
|
with pytest.raises(ValueError, match="findings list"):
|
|
service.review(state, "manuscript")
|
|
|
|
|
|
def test_sync_rejects_cancelled_run(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
state = service.create(work=work, content=book_content(1))
|
|
make_review_ready(state)
|
|
state = service.approve(state)
|
|
run = service.start_run(state)
|
|
run.status = BookRunStatus.CANCELLED
|
|
run.save(update_fields=["status", "updated_at"])
|
|
|
|
with pytest.raises(ValueError, match="cancelled"):
|
|
service.sync_run(run)
|
|
|
|
|
|
def test_bound_scene_lineage_is_scoped_and_unambiguous(work: Work) -> None:
|
|
book_service = BookStateService(FakeReviewRouter())
|
|
state = book_service.create(work=work, content=book_content(1))
|
|
make_review_ready(state)
|
|
state = book_service.approve(state)
|
|
scene_service = StandaloneSceneService(FakeReviewRouter())
|
|
|
|
first = scene_service.create(
|
|
work=work,
|
|
title="First Draft",
|
|
brief="Perform the chapter turn.",
|
|
book_state=state,
|
|
book_chapter_key="chapter-1",
|
|
)
|
|
revision = scene_service.create(
|
|
work=work,
|
|
title="First Draft",
|
|
brief="Revise the chapter turn.",
|
|
book_state=state,
|
|
book_chapter_key="chapter-1",
|
|
)
|
|
|
|
assert revision.parent == first
|
|
assert revision.revision == 2
|
|
unbound = scene_service.create(
|
|
work=work,
|
|
title="First Draft",
|
|
brief="Use the same title outside book-state orchestration.",
|
|
)
|
|
assert scene_service._artifact_paths(first) != scene_service._artifact_paths(unbound)
|
|
with pytest.raises(ValueError, match="different scene lineage"):
|
|
scene_service.create(
|
|
work=work,
|
|
title="Competing Draft",
|
|
brief="Create an ambiguous chapter lineage.",
|
|
book_state=state,
|
|
book_chapter_key="chapter-1",
|
|
)
|
|
|
|
|
|
def test_scene_refs_reject_scenes_bound_to_another_book_state(work: Work) -> None:
|
|
service = BookStateService(FakeReviewRouter())
|
|
state = service.create(work=work, content=book_content(1))
|
|
make_review_ready(state)
|
|
state = service.approve(state)
|
|
scene = StandaloneScene.objects.create(
|
|
work=work,
|
|
book_state=state,
|
|
book_chapter_key="chapter-1",
|
|
scene_key="bound-source",
|
|
revision=1,
|
|
title="Bound Source",
|
|
status=SceneDraftStatus.APPROVED,
|
|
brief="A bound scene cannot be reused as an assembly reference.",
|
|
prose="Approved prose.",
|
|
)
|
|
revised = deepcopy(state.content)
|
|
revised["chapters"][0]["scene_refs"] = [
|
|
{"scene_id": str(scene.id), "revision": scene.revision, "sha256": scene.sha256}
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="unbound assembly scenes"):
|
|
service.revise(state, content=revised)
|