318 lines
11 KiB
Python
318 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from control_plane.authoring.models import (
|
|
DocumentAuthority,
|
|
DocumentType,
|
|
SceneDraftStatus,
|
|
Series,
|
|
StoryProject,
|
|
Work,
|
|
WorkType,
|
|
)
|
|
from control_plane.authoring.scene_context import build_scene_context_pack
|
|
from control_plane.authoring.sources import register_source
|
|
from control_plane.authoring.standalone_scenes import StandaloneSceneService
|
|
from control_plane.projects.models import Project
|
|
from model_router.router import (
|
|
ModelCapability,
|
|
ModelChunk,
|
|
ModelRequestContract,
|
|
ModelResponseContract,
|
|
ModelRouter,
|
|
)
|
|
|
|
|
|
class FakeSceneProvider:
|
|
provider_name = "test"
|
|
|
|
def complete(self, request: ModelRequestContract) -> ModelResponseContract:
|
|
if str(request.purpose) == str(ModelCapability.STORY_PLANNING):
|
|
content = json.dumps(
|
|
{
|
|
"purpose": "Sabine gives Corin a precise answer.",
|
|
"pov_character": "Sabine",
|
|
"tense": "past",
|
|
"location": "Corin's office",
|
|
"time_context": "ordinary daytime work",
|
|
"present": ["Sabine", "Corin"],
|
|
"beats": [
|
|
{"text": "Sabine crosses the room.", "required": True},
|
|
{"text": "Corin waits for her answer.", "required": True},
|
|
{"text": "Sabine leaves on her own timing.", "required": True},
|
|
{"text": "Corin does not turn it into a negotiation.", "required": True},
|
|
{"text": "Ordinary work remains unfinished.", "required": True},
|
|
],
|
|
"exact_values": [],
|
|
"constraints": [],
|
|
"forbidden_events": [],
|
|
"ending_state": "Sabine has left the office.",
|
|
"final_image": "Corin remains beside the unfinished paperwork.",
|
|
"boundary_constraints": [],
|
|
"continuity_questions": [],
|
|
}
|
|
)
|
|
else:
|
|
requirement_ids = re.findall(r'"id":\s*"([^"]+)"', request.prompt)
|
|
content = json.dumps(
|
|
{
|
|
"passed": True,
|
|
"requirement_results": [
|
|
{
|
|
"requirement_id": requirement_id,
|
|
"status": "HIT",
|
|
"evidence_quote": "Sabine crossed the room.",
|
|
"details": "Satisfied in the scene.",
|
|
}
|
|
for requirement_id in requirement_ids
|
|
],
|
|
"findings": [],
|
|
"observed_state": {"scene_end": {"location": "office"}},
|
|
"proposed_changes": [],
|
|
}
|
|
)
|
|
return ModelResponseContract(model="test-model", content=content, metadata={})
|
|
|
|
def stream(self, _request: ModelRequestContract):
|
|
prose = "Sabine crossed the room. " + "She waited. " * 245
|
|
yield ModelChunk(prose + "[[END_OF_SCENE]]")
|
|
|
|
def health(self) -> str:
|
|
return "AVAILABLE"
|
|
|
|
|
|
def scene_work(tmp_path: Path) -> Work:
|
|
series = Series.objects.create(title="Labyrinth Hero", slug="labyrinth-hero")
|
|
work = Work.objects.create(series=series, title="The Fortune Below", slug="the-fortune-below")
|
|
project = Project.objects.create(name="The Fortune Below", 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 / "artifacts"),
|
|
)
|
|
return work
|
|
|
|
|
|
def add_canon_source(work: Work, tmp_path: Path) -> None:
|
|
root = tmp_path / "sources"
|
|
root.mkdir()
|
|
source = root / "relationship-canon.md"
|
|
source.write_text(
|
|
"# Relationship Canon\n\n"
|
|
"Sabine and Corin preserve explicit choice and ordinary work boundaries.\n",
|
|
encoding="utf-8",
|
|
)
|
|
register_source(
|
|
work=work,
|
|
path=source,
|
|
root=root,
|
|
authority=DocumentAuthority.CANON,
|
|
document_type=DocumentType.CANON,
|
|
)
|
|
|
|
|
|
def test_context_pack_only_uses_latest_allowed_authority(tmp_path: Path) -> None:
|
|
work = scene_work(tmp_path)
|
|
root = tmp_path / "sources"
|
|
root.mkdir()
|
|
source = root / "facts.md"
|
|
source.write_text("# Facts\n\nSabine works at the estate.\n", encoding="utf-8")
|
|
register_source(
|
|
work=work,
|
|
path=source,
|
|
root=root,
|
|
authority=DocumentAuthority.CANON,
|
|
document_type=DocumentType.CANON,
|
|
)
|
|
register_source(
|
|
work=work,
|
|
path=source,
|
|
root=root,
|
|
authority=DocumentAuthority.SUPERSEDED,
|
|
document_type=DocumentType.CANON,
|
|
)
|
|
|
|
pack, ranked = build_scene_context_pack(work=work, query="Sabine estate")
|
|
|
|
assert ranked == []
|
|
assert pack["citations"] == []
|
|
|
|
|
|
def test_context_pack_includes_series_reference_but_not_sibling_book(tmp_path: Path) -> None:
|
|
work = scene_work(tmp_path)
|
|
reference = Work.objects.create(
|
|
series=work.series,
|
|
title="Series Reference",
|
|
slug="series-reference",
|
|
work_type=WorkType.SERIES_REFERENCE,
|
|
)
|
|
sibling = Work.objects.create(
|
|
series=work.series,
|
|
title="Sibling Book",
|
|
slug="sibling-book",
|
|
)
|
|
root = tmp_path / "series-sources"
|
|
root.mkdir()
|
|
reference_path = root / "shared.md"
|
|
sibling_path = root / "sibling.md"
|
|
reference_path.write_text("Sabine follows the shared household rule.", encoding="utf-8")
|
|
sibling_path.write_text("Sabine ignores a sibling-only invention.", encoding="utf-8")
|
|
for source_work, path in [(reference, reference_path), (sibling, sibling_path)]:
|
|
register_source(
|
|
work=source_work,
|
|
path=path,
|
|
root=root,
|
|
authority=DocumentAuthority.CANON,
|
|
document_type=DocumentType.CANON,
|
|
)
|
|
|
|
pack, _ranked = build_scene_context_pack(work=work, query="Sabine shared sibling invention")
|
|
|
|
keys = {citation["document_key"] for citation in pack["citations"]}
|
|
assert "shared.md" in keys
|
|
assert "sibling.md" not in keys
|
|
|
|
|
|
def test_pinned_planning_sources_do_not_crowd_out_canon(tmp_path: Path) -> None:
|
|
work = scene_work(tmp_path)
|
|
root = tmp_path / "mixed-sources"
|
|
root.mkdir()
|
|
canon_path = root / "canon.md"
|
|
planning_path = root / "planning.md"
|
|
canon_path.write_text(
|
|
"\n\n".join(f"Sabine canon boundary {index}." for index in range(4)),
|
|
encoding="utf-8",
|
|
)
|
|
planning_path.write_text(
|
|
"\n\n".join(f"Sabine planning detail {index}." for index in range(10)),
|
|
encoding="utf-8",
|
|
)
|
|
register_source(
|
|
work=work,
|
|
path=canon_path,
|
|
root=root,
|
|
authority=DocumentAuthority.CANON,
|
|
document_type=DocumentType.CANON,
|
|
)
|
|
register_source(
|
|
work=work,
|
|
path=planning_path,
|
|
root=root,
|
|
authority=DocumentAuthority.PLANNING,
|
|
document_type=DocumentType.PLANNING,
|
|
)
|
|
|
|
pack, _ranked = build_scene_context_pack(
|
|
work=work,
|
|
query="Sabine boundary planning detail",
|
|
authorities=[DocumentAuthority.CANON, DocumentAuthority.PLANNING],
|
|
pinned_document_keys=["planning.md"],
|
|
limit=6,
|
|
)
|
|
|
|
authorities = {citation["authority"] for citation in pack["citations"]}
|
|
assert authorities == {DocumentAuthority.CANON, DocumentAuthority.PLANNING}
|
|
|
|
|
|
def test_governing_document_is_supplied_in_full_before_rag(tmp_path: Path) -> None:
|
|
work = scene_work(tmp_path)
|
|
root = tmp_path / "governing-sources"
|
|
root.mkdir()
|
|
governing_path = root / "rules.md"
|
|
canon_path = root / "canon.md"
|
|
governing_text = "# Rules\n\nFirst governing rule.\n\nFinal governing rule.\n"
|
|
governing_path.write_text(governing_text, encoding="utf-8")
|
|
canon_path.write_text("Sabine has an additional canon fact.", encoding="utf-8")
|
|
register_source(
|
|
work=work,
|
|
path=governing_path,
|
|
root=root,
|
|
authority=DocumentAuthority.PLANNING,
|
|
document_type=DocumentType.PLANNING,
|
|
)
|
|
register_source(
|
|
work=work,
|
|
path=canon_path,
|
|
root=root,
|
|
authority=DocumentAuthority.CANON,
|
|
document_type=DocumentType.CANON,
|
|
)
|
|
|
|
pack, _ranked = build_scene_context_pack(
|
|
work=work,
|
|
query="Sabine canon",
|
|
authorities=[DocumentAuthority.CANON, DocumentAuthority.PLANNING],
|
|
governing_document_keys=["rules.md"],
|
|
limit=2,
|
|
)
|
|
|
|
assert pack["governing_document_keys"] == ["rules.md"]
|
|
assert pack["citations"][0]["kind"] == "governing_document"
|
|
assert pack["citations"][0]["sha256"]
|
|
assert "# Rules" in pack["rendered_context"]
|
|
assert "First governing rule." in pack["rendered_context"]
|
|
assert "Final governing rule." in pack["rendered_context"]
|
|
assert "additional canon fact" in pack["rendered_context"]
|
|
|
|
|
|
def test_standalone_scene_runs_from_cited_plan_to_provisional_approval(tmp_path: Path) -> None:
|
|
work = scene_work(tmp_path)
|
|
add_canon_source(work, tmp_path)
|
|
provider = FakeSceneProvider()
|
|
service = StandaloneSceneService(ModelRouter({"terra": provider, "luna": provider}))
|
|
scene = service.create(
|
|
work=work,
|
|
title="Fourteen Seconds",
|
|
brief="Sabine interrupts Corin's ordinary work, gives him a deliberate answer, and leaves.",
|
|
target_words=300,
|
|
constraints=["Sabine owns the timing."],
|
|
forbidden_events=["Do not turn this into a scored turn."],
|
|
boundary_constraints=["Stop when Sabine leaves the office."],
|
|
)
|
|
|
|
service.prepare_context(scene)
|
|
assert scene.status == SceneDraftStatus.PLANNING
|
|
assert scene.context_citations.count() == 1
|
|
|
|
service.plan(scene)
|
|
assert scene.status == SceneDraftStatus.PLAN_REVIEW
|
|
assert scene.context_citations.count() == 1
|
|
assert scene.context_pack["citations"][0]["authority"] == DocumentAuthority.CANON
|
|
assert scene.contract_requirements
|
|
blocking_beats = [
|
|
item
|
|
for item in scene.contract_requirements
|
|
if item["type"] == "BEAT" and item["blocking"]
|
|
]
|
|
assert len(blocking_beats) == 5
|
|
|
|
service.approve_plan(scene)
|
|
service.write(scene)
|
|
scene.prose = scene.prose.removeprefix("# Fourteen Seconds\n\n")
|
|
scene.save()
|
|
service.review(scene)
|
|
service.approve(scene, actor="test")
|
|
scene.refresh_from_db()
|
|
|
|
assert scene.status == SceneDraftStatus.APPROVED
|
|
assert scene.prose.startswith("# Fourteen Seconds\n\n")
|
|
assert scene.word_count >= 250
|
|
assert scene.review["passed"] is True
|
|
assert scene.source_version.authority == DocumentAuthority.PROVISIONAL
|
|
assert len(scene.generation_metadata["planning"]["prompt_sha256"]) == 64
|
|
assert len(scene.generation_metadata["prose"]["prompt_sha256"]) == 64
|
|
assert scene.generation_metadata["approval"]["forced"] is False
|
|
assert Path(scene.artifact_uri).exists()
|
|
assert Path(scene.review_artifact_uri).exists()
|
|
|
|
scene.prose += " Changed after approval."
|
|
with pytest.raises(ValueError, match="immutable"):
|
|
scene.save()
|