From cacf3f24e7493a9f352121b3cd1bc1e3900b1f3c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 24 Jul 2026 00:35:10 +0000 Subject: [PATCH] Simplify D&D scene chunking responses --- .../scenes/assets/prompts/instructions.md | 58 +---- .../dnd/chunk/scenes/assets/prompts/task.md | 19 +- .../scenes/assets/schemas/dnd_scenes.v1.json | 60 +----- internal/modules/dnd/chunk/scenes/chunker.go | 160 ++------------ .../modules/dnd/chunk/scenes/chunker_test.go | 201 +++++------------- internal/modules/dnd/chunk/scenes/model.go | 24 +-- .../modules/dnd/chunk/scenes/schema_test.go | 61 ++---- 7 files changed, 118 insertions(+), 465 deletions(-) diff --git a/internal/modules/dnd/chunk/scenes/assets/prompts/instructions.md b/internal/modules/dnd/chunk/scenes/assets/prompts/instructions.md index fe0437c..1ef7fa5 100644 --- a/internal/modules/dnd/chunk/scenes/assets/prompts/instructions.md +++ b/internal/modules/dnd/chunk/scenes/assets/prompts/instructions.md @@ -1,52 +1,8 @@ -Good reasons to start a new scene include: -- the party moves to a new location; -- a combat encounter begins or ends; -- combat changes into a substantially different phase; -- 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. +Cover the complete provided transcript from its first source unit to its last +source unit. Return scenes in source-unit order with no gaps or overlaps. Use +only positive integer source-unit IDs from the transcript, and give every scene +one inclusive `start_unit_id` and one inclusive `end_unit_id`. -Do not start a new scene merely because: -- the speaker changes; -- a new combat round begins; -- 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. +Return exactly one JSON object and no explanatory text. The object must contain +only a non-empty `scenes` array. Each scene object must contain only +`start_unit_id` and `end_unit_id`. diff --git a/internal/modules/dnd/chunk/scenes/assets/prompts/task.md b/internal/modules/dnd/chunk/scenes/assets/prompts/task.md index aede806..7ac9666 100644 --- a/internal/modules/dnd/chunk/scenes/assets/prompts/task.md +++ b/internal/modules/dnd/chunk/scenes/assets/prompts/task.md @@ -1,5 +1,18 @@ 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 -change in location, objective, threat, activity, encounter, or mode of play. +A scene is a coherent unit of play. Start a new scene when the transcript +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. diff --git a/internal/modules/dnd/chunk/scenes/assets/schemas/dnd_scenes.v1.json b/internal/modules/dnd/chunk/scenes/assets/schemas/dnd_scenes.v1.json index 6dfe075..3f5713c 100644 --- a/internal/modules/dnd/chunk/scenes/assets/schemas/dnd_scenes.v1.json +++ b/internal/modules/dnd/chunk/scenes/assets/schemas/dnd_scenes.v1.json @@ -3,10 +3,7 @@ "$id": "notarius.dnd.scenes", "type": "object", "additionalProperties": false, - "required": [ - "scenes", - "boundary_caveats" - ], + "required": ["scenes"], "properties": { "scenes": { "type": "array", @@ -14,16 +11,7 @@ "items": { "type": "object", "additionalProperties": false, - "required": [ - "start_unit_id", - "end_unit_id", - "short_title", - "primary_mode", - "main_participants", - "summary", - "boundary_note", - "boundary_confidence" - ], + "required": ["start_unit_id", "end_unit_id"], "properties": { "start_unit_id": { "type": "integer", @@ -32,53 +20,9 @@ "end_unit_id": { "type": "integer", "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 - } } } } diff --git a/internal/modules/dnd/chunk/scenes/chunker.go b/internal/modules/dnd/chunk/scenes/chunker.go index b3b72de..ee0d781 100644 --- a/internal/modules/dnd/chunk/scenes/chunker.go +++ b/internal/modules/dnd/chunk/scenes/chunker.go @@ -2,9 +2,7 @@ package scenes import ( "context" - "encoding/json" "fmt" - "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" @@ -22,8 +20,6 @@ var providedCapabilities = []string{ "chunks", } -const annotationNamespace = "dnd/scenes" - var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ Glossary: "Optional campaign glossary 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) } - warnings, err := warningsFromCaveats(response.BoundaryCaveats) + plan, err := planFromResponse(req.Source, response) if err != nil { return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err) } - plan, err := planFromResponse(req.Source, response, warnings) - if err != nil { - return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err) - } - return contracts.ChunkPlanResult{ - Plan: plan, - Warnings: warnings, - }, nil + return contracts.ChunkPlanResult{Plan: plan}, nil } func ModuleSpec() pipeline.ModuleSpec { @@ -159,7 +148,7 @@ func DecodeOptions(options map[string]any) (Options, error) { 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 { 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)) previousEnd := -1 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 { - 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 { - 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 { - 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 { - 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 { @@ -205,142 +198,21 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin } 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{ - StartUnitID: normalized.StartUnitID, - EndUnitID: normalized.EndUnitID, - Annotations: source.ChunkAnnotations{annotationNamespace: annotation}, + StartUnitID: startUnitID, + EndUnitID: endUnitID, }) } 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) } - 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{ SourceDigest: doc.Digest, Ranges: ranges, - Annotations: source.ChunkAnnotations{annotationNamespace: annotation}, }, 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 { return fmt.Errorf("dnd scenes chunker: "+format, args...) } diff --git a/internal/modules/dnd/chunk/scenes/chunker_test.go b/internal/modules/dnd/chunk/scenes/chunker_test.go index 0cadbea..cf67b7f 100644 --- a/internal/modules/dnd/chunk/scenes/chunker_test.go +++ b/internal/modules/dnd/chunk/scenes/chunker_test.go @@ -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), } } diff --git a/internal/modules/dnd/chunk/scenes/model.go b/internal/modules/dnd/chunk/scenes/model.go index a8778d4..9d656a2 100644 --- a/internal/modules/dnd/chunk/scenes/model.go +++ b/internal/modules/dnd/chunk/scenes/model.go @@ -3,28 +3,10 @@ package scenes import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" type chunkResponse struct { - Scenes []sceneResponse `json:"scenes"` - BoundaryCaveats []string `json:"boundary_caveats"` + Scenes []sceneResponse `json:"scenes"` } type sceneResponse struct { - StartUnitID shared.UnitRef `json:"start_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 + StartUnitID shared.UnitRef `json:"start_unit_id"` + EndUnitID shared.UnitRef `json:"end_unit_id"` } diff --git a/internal/modules/dnd/chunk/scenes/schema_test.go b/internal/modules/dnd/chunk/scenes/schema_test.go index d23f4f0..4ce2688 100644 --- a/internal/modules/dnd/chunk/scenes/schema_test.go +++ b/internal/modules/dnd/chunk/scenes/schema_test.go @@ -35,7 +35,7 @@ func TestLoadResponseSchemaForScenes(t *testing.T) { } } -func TestResponseSchemaValidatesSceneResponses(t *testing.T) { +func TestResponseSchemaValidatesMinimalSceneResponses(t *testing.T) { schema, err := loadResponseSchema() if err != nil { t.Fatalf("loadResponseSchema() error = %v, want nil", err) @@ -55,11 +55,15 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) { mutate func(map[string]any) }{ { - name: "obsolete segment boundaries", + name: "missing scenes", mutate: func(response map[string]any) { - scene := response["scenes"].([]any)[0].(map[string]any) - scene["start_segment_id"] = 1 - scene["end_segment_id"] = 2 + delete(response, "scenes") + }, + }, + { + 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) { - 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) { - 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) { - response["boundary_caveats"] = []any{""} - }, - }, - { - name: "unknown property", - mutate: func(response map[string]any) { - response["unexpected"] = true + response["scenes"].([]any)[0].(map[string]any)["short_title"] = "Old contract" }, }, } @@ -116,21 +114,7 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) { } func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) { - raw := []byte(`{ - "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": [] - }`) + raw := []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":3}]}`) var response chunkResponse 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) } 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{ "scenes": []any{ map[string]any{ - "start_unit_id": 1, - "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", + "start_unit_id": 1, + "end_unit_id": 3, }, }, - "boundary_caveats": []any{}, } }