Simplify D&D scene chunking responses
This commit is contained in:
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user