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

@@ -1,52 +1,8 @@
Good reasons to start a new scene include: Cover the complete provided transcript from its first source unit to its last
- the party moves to a new location; source unit. Return scenes in source-unit order with no gaps or overlaps. Use
- a combat encounter begins or ends; only positive integer source-unit IDs from the transcript, and give every scene
- combat changes into a substantially different phase; one inclusive `start_unit_id` and one inclusive `end_unit_id`.
- the party shifts between combat, exploration, social interaction, discussion,
planning, travel, rest, or downtime;
- a new NPC, faction, threat, or objective becomes central;
- the party completes one immediate goal and begins another;
- a major table-level rules discussion interrupts and materially changes play.
Do not start a new scene merely because: Return exactly one JSON object and no explanatory text. The object must contain
- the speaker changes; only a non-empty `scenes` array. Each scene object must contain only
- a new combat round begins; `start_unit_id` and `end_unit_id`.
- a player asks a brief rules question;
- there is a joke, aside, or short table comment;
- a character takes a routine turn;
- the same encounter continues without a meaningful change in situation.
dnd/scenes boundary policy:
- cover the full provided transcript from the first source unit to the last
source unit;
- return sequential scenes with no gaps;
- do not overlap scenes;
- preserve source-unit order;
- use integer source-unit IDs from the transcript;
- each scene must have start_unit_id and end_unit_id;
- do not include final chunk IDs or chunk indexes.
For each scene:
- short_title should be brief and factual;
- primary_mode must be Recap, Discussion, Combat, or Narrative;
- main_participants should include only principal characters, NPCs, factions, or
groups involved;
- summary should be factual and compact, usually one to three sentences;
- boundary_note should explain why the scene begins at start_unit_id and ends at
end_unit_id;
- boundary_confidence must be High, Medium, or Low.
Primary mode guidance:
- Use Recap for opening recap, initiative setup, session framing, or immediate
continuation from prior events.
- Use Discussion when the party is primarily discussing options or choosing a
course of action.
- Use Combat when active combat or combat-resolution mechanics dominate.
- Use Narrative for all other non-combat gameplay, including exploration, social
interactions, shopping, preparation, travel, rest, and downtime.
In boundary_caveats, list overall caveats about scene divisions. Include scenes
that could reasonably be split differently, combat phases that were kept
together, gradual transitions, or places where map context would have helped.
Return exactly one JSON object and no explanatory text.

View File

@@ -1,5 +1,18 @@
Divide the provided transcript into coherent Dungeons & Dragons scenes for the Divide the provided transcript into coherent Dungeons & Dragons scenes for the
dnd/scenes chunk module. `dnd/scenes` chunk module.
A scene is a coherent unit of play. Start a new scene when there is a meaningful A scene is a coherent unit of play. Start a new scene when the transcript
change in location, objective, threat, activity, encounter, or mode of play. establishes a meaningful change in location, objective, threat, activity,
encounter, or mode of play. Good reasons include a material move, beginning or
ending combat, a substantially different encounter phase, a shift between
combat, exploration, social interaction, planning, travel, rest, or downtime,
a change in the central NPC, faction, threat, or objective, or a sustained
table-level interruption that materially changes the activity.
Do not split a scene merely because a speaker or combat round changes, a
routine turn occurs, or the table briefly digresses. Prefer fewer coherent
scenes over speculative or fine-grained boundaries.
Return only inclusive `start_unit_id` and `end_unit_id` endpoints for each
scene. Do not return titles, modes, participants, summaries, boundary notes,
confidence, caveats, final chunk IDs, or chunk indexes.

View File

@@ -3,10 +3,7 @@
"$id": "notarius.dnd.scenes", "$id": "notarius.dnd.scenes",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": ["scenes"],
"scenes",
"boundary_caveats"
],
"properties": { "properties": {
"scenes": { "scenes": {
"type": "array", "type": "array",
@@ -14,16 +11,7 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": ["start_unit_id", "end_unit_id"],
"start_unit_id",
"end_unit_id",
"short_title",
"primary_mode",
"main_participants",
"summary",
"boundary_note",
"boundary_confidence"
],
"properties": { "properties": {
"start_unit_id": { "start_unit_id": {
"type": "integer", "type": "integer",
@@ -32,53 +20,9 @@
"end_unit_id": { "end_unit_id": {
"type": "integer", "type": "integer",
"minimum": 1 "minimum": 1
},
"short_title": {
"type": "string",
"minLength": 1
},
"primary_mode": {
"type": "string",
"enum": [
"Recap",
"Discussion",
"Combat",
"Narrative"
]
},
"main_participants": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"summary": {
"type": "string",
"minLength": 1
},
"boundary_note": {
"type": "string",
"minLength": 1
},
"boundary_confidence": {
"type": "string",
"enum": [
"High",
"Medium",
"Low"
]
} }
} }
} }
},
"boundary_caveats": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
} }
} }
} }

View File

@@ -2,9 +2,7 @@ package scenes
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -22,8 +20,6 @@ var providedCapabilities = []string{
"chunks", "chunks",
} }
const annotationNamespace = "dnd/scenes"
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.", Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
Party: "Optional party roster reference material used only for scene disambiguation.", Party: "Optional party roster reference material used only for scene disambiguation.",
@@ -113,18 +109,11 @@ func (c *Chunker) Plan(ctx context.Context, req contracts.ChunkRequest) (contrac
return contracts.ChunkPlanResult{}, chunkerErrorf("complete structured output: %w", err) return contracts.ChunkPlanResult{}, chunkerErrorf("complete structured output: %w", err)
} }
warnings, err := warningsFromCaveats(response.BoundaryCaveats) plan, err := planFromResponse(req.Source, response)
if err != nil { if err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err) return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
} }
plan, err := planFromResponse(req.Source, response, warnings) return contracts.ChunkPlanResult{Plan: plan}, nil
if err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
}
return contracts.ChunkPlanResult{
Plan: plan,
Warnings: warnings,
}, nil
} }
func ModuleSpec() pipeline.ModuleSpec { func ModuleSpec() pipeline.ModuleSpec {
@@ -159,7 +148,7 @@ func DecodeOptions(options map[string]any) (Options, error) {
return Options{}, nil return Options{}, nil
} }
func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnings []contracts.Warning) (source.ChunkPlan, error) { func planFromResponse(doc *source.SourceDocument, response chunkResponse) (source.ChunkPlan, error) {
if response.Scenes == nil { if response.Scenes == nil {
return source.ChunkPlan{}, fmt.Errorf("scenes must be present") return source.ChunkPlan{}, fmt.Errorf("scenes must be present")
} }
@@ -175,21 +164,25 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin
ranges := make([]source.ChunkRange, 0, len(response.Scenes)) ranges := make([]source.ChunkRange, 0, len(response.Scenes))
previousEnd := -1 previousEnd := -1
for i, scene := range response.Scenes { for i, scene := range response.Scenes {
normalized, err := normalizeScene(doc, i, scene) startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
if err != nil { if err != nil {
return source.ChunkPlan{}, err return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
}
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
} }
startIndex, ok := unitIndexes[normalized.StartUnitID] startIndex, ok := unitIndexes[startUnitID]
if !ok { if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, normalized.StartUnitID) return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
} }
endIndex, ok := unitIndexes[normalized.EndUnitID] endIndex, ok := unitIndexes[endUnitID]
if !ok { if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, normalized.EndUnitID) return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
} }
if startIndex > endIndex { if startIndex > endIndex {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, normalized.StartUnitID, normalized.EndUnitID) return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, startUnitID, endUnitID)
} }
if i == 0 && startIndex != 0 { if i == 0 && startIndex != 0 {
@@ -205,142 +198,21 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin
} }
previousEnd = endIndex previousEnd = endIndex
annotation, err := json.Marshal(struct {
ShortTitle string `json:"short_title"`
PrimaryMode string `json:"primary_mode"`
MainParticipants []string `json:"main_participants"`
Summary string `json:"summary"`
BoundaryNote string `json:"boundary_note"`
BoundaryConfidence string `json:"boundary_confidence"`
}{normalized.ShortTitle, normalized.PrimaryMode, normalized.MainParticipants, normalized.Summary, normalized.BoundaryNote, normalized.BoundaryConfidence})
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("encode scene[%d] annotation: %w", i, err)
}
ranges = append(ranges, source.ChunkRange{ ranges = append(ranges, source.ChunkRange{
StartUnitID: normalized.StartUnitID, StartUnitID: startUnitID,
EndUnitID: normalized.EndUnitID, EndUnitID: endUnitID,
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
}) })
} }
if previousEnd != len(doc.Units)-1 { if previousEnd != len(doc.Units)-1 {
return source.ChunkPlan{}, fmt.Errorf("final scene must end at final source unit %d", doc.Units[len(doc.Units)-1].ID) return source.ChunkPlan{}, fmt.Errorf("final scene must end at final source unit %d", doc.Units[len(doc.Units)-1].ID)
} }
caveats := make([]string, 0, len(warnings))
for _, warning := range warnings {
caveats = append(caveats, warning.Message)
}
annotation, err := json.Marshal(struct {
BoundaryCaveats []string `json:"boundary_caveats"`
}{BoundaryCaveats: caveats})
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("encode plan annotation: %w", err)
}
return source.ChunkPlan{ return source.ChunkPlan{
SourceDigest: doc.Digest, SourceDigest: doc.Digest,
Ranges: ranges, Ranges: ranges,
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
}, nil }, nil
} }
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
if err != nil {
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
}
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
if err != nil {
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
}
out := normalizedScene{
StartUnitID: startUnitID,
EndUnitID: endUnitID,
ShortTitle: strings.TrimSpace(scene.ShortTitle),
PrimaryMode: strings.TrimSpace(scene.PrimaryMode),
Summary: strings.TrimSpace(scene.Summary),
BoundaryNote: strings.TrimSpace(scene.BoundaryNote),
BoundaryConfidence: strings.TrimSpace(scene.BoundaryConfidence),
}
requiredInts := map[string]int{
"start_unit_id": out.StartUnitID,
"end_unit_id": out.EndUnitID,
}
for field, value := range requiredInts {
if value <= 0 {
return normalizedScene{}, fmt.Errorf("scene[%d] %s must be positive", index, field)
}
}
required := map[string]string{
"short_title": out.ShortTitle,
"primary_mode": out.PrimaryMode,
"summary": out.Summary,
"boundary_note": out.BoundaryNote,
"boundary_confidence": out.BoundaryConfidence,
}
for field, value := range required {
if value == "" {
return normalizedScene{}, fmt.Errorf("scene[%d] %s must not be empty", index, field)
}
}
if !validPrimaryMode(out.PrimaryMode) {
return normalizedScene{}, fmt.Errorf("scene[%d] primary_mode %q is not supported", index, out.PrimaryMode)
}
if !validBoundaryConfidence(out.BoundaryConfidence) {
return normalizedScene{}, fmt.Errorf("scene[%d] boundary_confidence %q is not supported", index, out.BoundaryConfidence)
}
if len(scene.MainParticipants) == 0 {
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants must not be empty", index)
}
out.MainParticipants = make([]string, 0, len(scene.MainParticipants))
for participantIndex, participant := range scene.MainParticipants {
trimmed := strings.TrimSpace(participant)
if trimmed == "" {
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants[%d] must not be empty", index, participantIndex)
}
out.MainParticipants = append(out.MainParticipants, trimmed)
}
return out, nil
}
func validPrimaryMode(value string) bool {
switch value {
case "Recap", "Discussion", "Combat", "Narrative":
return true
default:
return false
}
}
func validBoundaryConfidence(value string) bool {
switch value {
case "High", "Medium", "Low":
return true
default:
return false
}
}
func warningsFromCaveats(caveats []string) ([]contracts.Warning, error) {
if len(caveats) == 0 {
return nil, nil
}
warnings := make([]contracts.Warning, 0, len(caveats))
for i, caveat := range caveats {
trimmed := strings.TrimSpace(caveat)
if trimmed == "" {
return nil, fmt.Errorf("boundary_caveats[%d] must not be empty after trimming", i)
}
warnings = append(warnings, contracts.Warning{
Scope: Key,
ReasonCode: "scene_boundary_caveat",
Message: trimmed,
})
}
return warnings, nil
}
func chunkerErrorf(format string, args ...any) error { func chunkerErrorf(format string, args ...any) error {
return fmt.Errorf("dnd scenes chunker: "+format, args...) return fmt.Errorf("dnd scenes chunker: "+format, args...)
} }

View File

@@ -110,32 +110,13 @@ func wantReferenceSlots() []contracts.ReferenceSlot {
} }
} }
func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput(t *testing.T) { func TestPlanReturnsAnnotationFreeSceneRangesFromStructuredOutput(t *testing.T) {
client := &fakeScenesLLMClient{ client := &fakeScenesLLMClient{
response: chunkResponse{ response: chunkResponse{
Scenes: []sceneResponse{ Scenes: []sceneResponse{
{ scene(1, 2),
StartUnitID: shared.UnitRefFromInt(1), scene(3, 4),
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",
},
}, },
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) t.Fatalf("ranges = %#v, want two", result.Plan.Ranges)
} }
for i, want := range wantRanges { 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) 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 result.Plan.Annotations != nil {
if err := json.Unmarshal(result.Plan.Ranges[0].Annotations[annotationNamespace], &firstAnnotation); err != nil { t.Fatalf("plan annotations = %#v, want absent", result.Plan.Annotations)
t.Fatalf("decode first scene annotation: %v", err)
} }
wantFirst := map[string]any{ if result.Warnings != nil {
"short_title": "Goblin parley", "primary_mode": "Discussion", t.Fatalf("warnings = %#v, want absent", result.Warnings)
"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 !reflect.DeepEqual(firstAnnotation, wantFirst) { }
t.Fatalf("first scene annotation = %#v, want %#v", firstAnnotation, wantFirst)
} func TestPlanUsesDocumentOrderForNonconsecutiveUnitIDs(t *testing.T) {
if len(firstAnnotation) != 6 { doc := &source.SourceDocument{
t.Fatalf("first scene annotation keys = %#v, want exact six fields", firstAnnotation) ID: "session-nonnumeric",
} Kind: "transcript",
var planAnnotation map[string]any Format: "application/vnd.seriatim.minimal+json",
if err := json.Unmarshal(result.Plan.Annotations[annotationNamespace], &planAnnotation); err != nil { Digest: "sha256:nonnumeric",
t.Fatalf("decode plan annotation: %v", err) Units: []source.SourceUnit{
} {ID: 10, Kind: "transcript_segment", Text: "The party arrives.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 10, EndUnitID: 10}},
if !reflect.DeepEqual(planAnnotation, map[string]any{"boundary_caveats": []any{"The transition into combat is gradual."}}) || len(planAnnotation) != 1 { {ID: 3, Kind: "transcript_segment", Text: "The party explores.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 3, EndUnitID: 3}},
t.Fatalf("plan annotation = %#v, want normalized caveats only", planAnnotation) {ID: 20, Kind: "transcript_segment", Text: "The party rests.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 20, EndUnitID: 20}},
} },
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)
} }
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) { func TestPlanPassesReferencesAsPromptInputs(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{ client := &fakeScenesLLMClient{response: chunkResponse{
Scenes: []sceneResponse{ Scenes: []sceneResponse{
{ scene(1, 4),
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",
},
}, },
}} }}
req := chunkRequest() 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) { func TestChunkerManifestMetadataIncludesPromptAndSchemaProvenance(t *testing.T) {
metadata := newChunker(t, &fakeScenesLLMClient{}).ManifestMetadata() metadata := newChunker(t, &fakeScenesLLMClient{}).ManifestMetadata()
@@ -455,38 +403,6 @@ func TestPlanRejectsMalformedStructuredOutput(t *testing.T) {
}), }),
want: "final scene", 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 { for _, tt := range tests {
@@ -559,7 +475,6 @@ func validSceneResponse() chunkResponse {
Scenes: []sceneResponse{ Scenes: []sceneResponse{
scene(1, 4), scene(1, 4),
}, },
BoundaryCaveats: []string{},
} }
} }
@@ -570,14 +485,8 @@ func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse
func scene(startUnitID int, endUnitID int) sceneResponse { func scene(startUnitID int, endUnitID int) sceneResponse {
return sceneResponse{ return sceneResponse{
StartUnitID: shared.UnitRefFromInt(startUnitID), StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: shared.UnitRefFromInt(endUnitID), 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",
} }
} }

View File

@@ -3,28 +3,10 @@ package scenes
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
type chunkResponse struct { type chunkResponse struct {
Scenes []sceneResponse `json:"scenes"` Scenes []sceneResponse `json:"scenes"`
BoundaryCaveats []string `json:"boundary_caveats"`
} }
type sceneResponse struct { type sceneResponse struct {
StartUnitID shared.UnitRef `json:"start_unit_id"` StartUnitID shared.UnitRef `json:"start_unit_id"`
EndUnitID shared.UnitRef `json:"end_unit_id"` EndUnitID shared.UnitRef `json:"end_unit_id"`
ShortTitle string `json:"short_title"`
PrimaryMode string `json:"primary_mode"`
MainParticipants []string `json:"main_participants"`
Summary string `json:"summary"`
BoundaryNote string `json:"boundary_note"`
BoundaryConfidence string `json:"boundary_confidence"`
}
type normalizedScene struct {
StartUnitID int
EndUnitID int
ShortTitle string
PrimaryMode string
MainParticipants []string
Summary string
BoundaryNote string
BoundaryConfidence string
} }

View File

@@ -35,7 +35,7 @@ func TestLoadResponseSchemaForScenes(t *testing.T) {
} }
} }
func TestResponseSchemaValidatesSceneResponses(t *testing.T) { func TestResponseSchemaValidatesMinimalSceneResponses(t *testing.T) {
schema, err := loadResponseSchema() schema, err := loadResponseSchema()
if err != nil { if err != nil {
t.Fatalf("loadResponseSchema() error = %v, want nil", err) t.Fatalf("loadResponseSchema() error = %v, want nil", err)
@@ -55,11 +55,15 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
mutate func(map[string]any) mutate func(map[string]any)
}{ }{
{ {
name: "obsolete segment boundaries", name: "missing scenes",
mutate: func(response map[string]any) { mutate: func(response map[string]any) {
scene := response["scenes"].([]any)[0].(map[string]any) delete(response, "scenes")
scene["start_segment_id"] = 1 },
scene["end_segment_id"] = 2 },
{
name: "empty scenes",
mutate: func(response map[string]any) {
response["scenes"] = []any{}
}, },
}, },
{ {
@@ -75,27 +79,21 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
}, },
}, },
{ {
name: "invalid primary mode", name: "non-integer endpoint",
mutate: func(response map[string]any) { mutate: func(response map[string]any) {
response["scenes"].([]any)[0].(map[string]any)["primary_mode"] = "Unknown" response["scenes"].([]any)[0].(map[string]any)["start_unit_id"] = 1.5
}, },
}, },
{ {
name: "invalid boundary confidence", name: "unknown top-level field",
mutate: func(response map[string]any) { mutate: func(response map[string]any) {
response["scenes"].([]any)[0].(map[string]any)["boundary_confidence"] = "Unknown" response["boundary_caveats"] = []any{}
}, },
}, },
{ {
name: "empty boundary caveat", name: "unknown scene field",
mutate: func(response map[string]any) { mutate: func(response map[string]any) {
response["boundary_caveats"] = []any{""} response["scenes"].([]any)[0].(map[string]any)["short_title"] = "Old contract"
},
},
{
name: "unknown property",
mutate: func(response map[string]any) {
response["unexpected"] = true
}, },
}, },
} }
@@ -116,21 +114,7 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
} }
func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) { func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) {
raw := []byte(`{ raw := []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":3}]}`)
"scenes": [
{
"start_unit_id": 1,
"end_unit_id": 3,
"short_title": "Ambush",
"primary_mode": "Combat",
"main_participants": ["Aria"],
"summary": "The party fights.",
"boundary_note": "Combat starts and resolves.",
"boundary_confidence": "High"
}
],
"boundary_caveats": []
}`)
var response chunkResponse var response chunkResponse
if err := json.Unmarshal(raw, &response); err != nil { if err := json.Unmarshal(raw, &response); err != nil {
@@ -159,7 +143,7 @@ func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
t.Fatalf("schema JSON was mutated: %s", second.JSONSchema) t.Fatalf("schema JSON was mutated: %s", second.JSONSchema)
} }
if len(second.JSONSchema) > 0 && second.JSONSchema[0] == '[' { if len(second.JSONSchema) > 0 && second.JSONSchema[0] == '[' {
t.Fatalf("schema JSON did not use defensive copy") t.Fatal("schema JSON did not use defensive copy")
} }
} }
@@ -167,17 +151,10 @@ func validSceneSchemaResponse() map[string]any {
return map[string]any{ return map[string]any{
"scenes": []any{ "scenes": []any{
map[string]any{ map[string]any{
"start_unit_id": 1, "start_unit_id": 1,
"end_unit_id": 3, "end_unit_id": 3,
"short_title": "Ambush",
"primary_mode": "Combat",
"main_participants": []any{"Aria"},
"summary": "The party fights.",
"boundary_note": "Combat starts and resolves.",
"boundary_confidence": "High",
}, },
}, },
"boundary_caveats": []any{},
} }
} }