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

@@ -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...)
}