Simplify D&D scene chunking responses

This commit is contained in:
2026-07-24 00:35:10 +00:00
parent f08ca4ddfa
commit cacf3f24e7
7 changed files with 118 additions and 465 deletions

View File

@@ -110,32 +110,13 @@ func wantReferenceSlots() []contracts.ReferenceSlot {
}
}
func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput(t *testing.T) {
func TestPlanReturnsAnnotationFreeSceneRangesFromStructuredOutput(t *testing.T) {
client := &fakeScenesLLMClient{
response: chunkResponse{
Scenes: []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(2),
ShortTitle: " Goblin parley ",
PrimaryMode: "Discussion",
MainParticipants: []string{" Aria ", "Goblin scout"},
Summary: " The party negotiates with a scout. ",
BoundaryNote: " The scene covers the discussion before fighting starts. ",
BoundaryConfidence: "High",
},
{
StartUnitID: shared.UnitRefFromInt(3),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Ambush at the gate",
PrimaryMode: "Combat",
MainParticipants: []string{"Aria", "Goblin ambushers"},
Summary: "The goblins attack at the gate.",
BoundaryNote: "Combat begins and resolves the immediate threat.",
BoundaryConfidence: "Medium",
},
scene(1, 2),
scene(3, 4),
},
BoundaryCaveats: []string{" The transition into combat is gradual. "},
},
}
@@ -185,55 +166,67 @@ func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput(t *testing.T)
t.Fatalf("ranges = %#v, want two", result.Plan.Ranges)
}
for i, want := range wantRanges {
if result.Plan.Ranges[i].StartUnitID != want.StartUnitID || result.Plan.Ranges[i].EndUnitID != want.EndUnitID {
got := result.Plan.Ranges[i]
if got.StartUnitID != want.StartUnitID || got.EndUnitID != want.EndUnitID {
t.Fatalf("range[%d] = %#v, want %#v", i, result.Plan.Ranges[i], want)
}
if got.Annotations != nil {
t.Fatalf("range[%d] annotations = %#v, want absent", i, got.Annotations)
}
}
var firstAnnotation map[string]any
if err := json.Unmarshal(result.Plan.Ranges[0].Annotations[annotationNamespace], &firstAnnotation); err != nil {
t.Fatalf("decode first scene annotation: %v", err)
if result.Plan.Annotations != nil {
t.Fatalf("plan annotations = %#v, want absent", result.Plan.Annotations)
}
wantFirst := map[string]any{
"short_title": "Goblin parley", "primary_mode": "Discussion",
"main_participants": []any{"Aria", "Goblin scout"},
"summary": "The party negotiates with a scout.",
"boundary_note": "The scene covers the discussion before fighting starts.",
"boundary_confidence": "High",
if result.Warnings != nil {
t.Fatalf("warnings = %#v, want absent", result.Warnings)
}
if !reflect.DeepEqual(firstAnnotation, wantFirst) {
t.Fatalf("first scene annotation = %#v, want %#v", firstAnnotation, wantFirst)
}
if len(firstAnnotation) != 6 {
t.Fatalf("first scene annotation keys = %#v, want exact six fields", firstAnnotation)
}
var planAnnotation map[string]any
if err := json.Unmarshal(result.Plan.Annotations[annotationNamespace], &planAnnotation); err != nil {
t.Fatalf("decode plan annotation: %v", err)
}
if !reflect.DeepEqual(planAnnotation, map[string]any{"boundary_caveats": []any{"The transition into combat is gradual."}}) || len(planAnnotation) != 1 {
t.Fatalf("plan annotation = %#v, want normalized caveats only", planAnnotation)
}
if got := result.Warnings; len(got) != 1 ||
got[0].Scope != Key ||
got[0].ReasonCode != "scene_boundary_caveat" ||
got[0].Message != "The transition into combat is gradual." {
t.Fatalf("Warnings = %#v, want boundary caveat warning", got)
}
func TestPlanUsesDocumentOrderForNonconsecutiveUnitIDs(t *testing.T) {
doc := &source.SourceDocument{
ID: "session-nonnumeric",
Kind: "transcript",
Format: "application/vnd.seriatim.minimal+json",
Digest: "sha256:nonnumeric",
Units: []source.SourceUnit{
{ID: 10, Kind: "transcript_segment", Text: "The party arrives.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 10, EndUnitID: 10}},
{ID: 3, Kind: "transcript_segment", Text: "The party explores.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 3, EndUnitID: 3}},
{ID: 20, Kind: "transcript_segment", Text: "The party rests.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 20, EndUnitID: 20}},
},
}
req := chunkRequest()
req.Source = doc
t.Run("accepts document-ordered ranges", func(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{Scenes: []sceneResponse{
scene(10, 3),
scene(20, 20),
}}}
result, err := newChunker(t, client).Plan(context.Background(), req)
if err != nil {
t.Fatalf("Plan() error = %v, want nil", err)
}
if got := result.Plan.Ranges; !reflect.DeepEqual(got, []source.ChunkRange{{StartUnitID: 10, EndUnitID: 3}, {StartUnitID: 20, EndUnitID: 20}}) {
t.Fatalf("ranges = %#v, want source-document order", got)
}
})
t.Run("rejects reversed document positions", func(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{Scenes: []sceneResponse{
scene(3, 10),
scene(20, 20),
}}}
_, err := newChunker(t, client).Plan(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "appears after") {
t.Fatalf("Plan() error = %v, want document-position reversal", err)
}
})
}
func TestPlanPassesReferencesAsPromptInputs(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{
Scenes: []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Ambush",
PrimaryMode: "Combat",
MainParticipants: []string{"Aria"},
Summary: "The party is ambushed.",
BoundaryNote: "One scene covers the short fixture.",
BoundaryConfidence: "High",
},
scene(1, 4),
},
}}
req := chunkRequest()
@@ -298,51 +291,6 @@ func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
}
}
func TestPlanRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) {
client := &fakeScenesLLMClient{
response: chunkResponse{
Scenes: validSceneResponse().Scenes,
BoundaryCaveats: []string{
" ",
},
},
}
_, err := newChunker(t, client).Plan(context.Background(), chunkRequest())
if err == nil {
t.Fatal("Plan() error = nil, want malformed structured output error")
}
if !strings.Contains(err.Error(), "dnd scenes chunker") || !strings.Contains(err.Error(), "malformed structured output") || !strings.Contains(err.Error(), "boundary_caveats[0]") {
t.Fatalf("Plan() error = %q, want malformed boundary caveat context", err.Error())
}
}
func TestPlanDefensivelyCopiesAnnotationValues(t *testing.T) {
doc := sceneSourceDocument()
client := &fakeScenesLLMClient{response: validSceneResponse()}
result, err := newChunker(t, client).Plan(context.Background(), contracts.ChunkRequest{
Source: doc,
SourceInput: sceneSourceInput(),
SessionID: "session-123",
LLMProfile: "profile-scenes",
})
if err != nil {
t.Fatalf("Plan() error = %v, want nil", err)
}
client.response.Scenes[0].MainParticipants[0] = "mutated"
var annotation struct {
MainParticipants []string `json:"main_participants"`
}
if err := json.Unmarshal(result.Plan.Ranges[0].Annotations[annotationNamespace], &annotation); err != nil {
t.Fatalf("decode annotation: %v", err)
}
if !reflect.DeepEqual(annotation.MainParticipants, []string{"Aria"}) {
t.Fatalf("participants = %#v, want defensive copy", annotation.MainParticipants)
}
}
func TestChunkerManifestMetadataIncludesPromptAndSchemaProvenance(t *testing.T) {
metadata := newChunker(t, &fakeScenesLLMClient{}).ManifestMetadata()
@@ -455,38 +403,6 @@ func TestPlanRejectsMalformedStructuredOutput(t *testing.T) {
}),
want: "final scene",
},
{
name: "empty metadata field",
response: replaceScenes(validSceneResponse(), []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: " ",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"},
Summary: "Summary.",
BoundaryNote: "Note.",
BoundaryConfidence: "High",
},
}),
want: "short_title",
},
{
name: "empty participant",
response: replaceScenes(validSceneResponse(), []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Title",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria", " "},
Summary: "Summary.",
BoundaryNote: "Note.",
BoundaryConfidence: "High",
},
}),
want: "main_participants",
},
}
for _, tt := range tests {
@@ -559,7 +475,6 @@ func validSceneResponse() chunkResponse {
Scenes: []sceneResponse{
scene(1, 4),
},
BoundaryCaveats: []string{},
}
}
@@ -570,14 +485,8 @@ func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse
func scene(startUnitID int, endUnitID int) sceneResponse {
return sceneResponse{
StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: shared.UnitRefFromInt(endUnitID),
ShortTitle: "Scene title",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"},
Summary: "A compact summary.",
BoundaryNote: "The source units form one coherent scene.",
BoundaryConfidence: "High",
StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: shared.UnitRefFromInt(endUnitID),
}
}