Update D&D schemas to require integer unit_id values

This commit is contained in:
2026-07-06 14:41:24 -05:00
parent 79a585d17e
commit aec807fcb0
18 changed files with 403 additions and 79 deletions

View File

@@ -22,7 +22,8 @@ dnd/scenes boundary policy:
- return sequential scenes with no gaps;
- do not overlap scenes;
- preserve source-unit order;
- use exact source-unit IDs from the transcript;
- use 1-based integer source-unit numbers from the transcript, where 1 is the
first provided source unit;
- each scene must have start_unit_id and end_unit_id;
- do not include final chunk IDs or chunk indexes.

View File

@@ -26,12 +26,12 @@
],
"properties": {
"start_unit_id": {
"type": "string",
"minLength": 1
"type": "integer",
"minimum": 1
},
"end_unit_id": {
"type": "string",
"minLength": 1
"type": "integer",
"minimum": 1
},
"short_title": {
"type": "string",

View File

@@ -150,7 +150,7 @@ func chunksFromResponse(doc *source.SourceDocument, response chunkResponse) ([]c
chunks := make([]contracts.SourceChunk, 0, len(response.Scenes))
previousEnd := -1
for i, scene := range response.Scenes {
normalized, err := normalizeScene(i, scene)
normalized, err := normalizeScene(doc, i, scene)
if err != nil {
return nil, err
}
@@ -206,10 +206,19 @@ func chunksFromResponse(doc *source.SourceDocument, response chunkResponse) ([]c
return chunks, nil
}
func normalizeScene(index int, scene sceneResponse) (sceneResponse, error) {
out := sceneResponse{
StartUnitID: strings.TrimSpace(scene.StartUnitID),
EndUnitID: strings.TrimSpace(scene.EndUnitID),
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
startUnitID, err := dnd.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
if err != nil {
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
}
endUnitID, err := dnd.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),
@@ -228,23 +237,23 @@ func normalizeScene(index int, scene sceneResponse) (sceneResponse, error) {
}
for field, value := range required {
if value == "" {
return sceneResponse{}, fmt.Errorf("scene[%d] %s must not be empty", index, field)
return normalizedScene{}, fmt.Errorf("scene[%d] %s must not be empty", index, field)
}
}
if !validPrimaryMode(out.PrimaryMode) {
return sceneResponse{}, fmt.Errorf("scene[%d] primary_mode %q is not supported", index, out.PrimaryMode)
return normalizedScene{}, fmt.Errorf("scene[%d] primary_mode %q is not supported", index, out.PrimaryMode)
}
if !validBoundaryConfidence(out.BoundaryConfidence) {
return sceneResponse{}, fmt.Errorf("scene[%d] boundary_confidence %q is not supported", index, out.BoundaryConfidence)
return normalizedScene{}, fmt.Errorf("scene[%d] boundary_confidence %q is not supported", index, out.BoundaryConfidence)
}
if len(scene.MainParticipants) == 0 {
return sceneResponse{}, fmt.Errorf("scene[%d] main_participants must not be empty", index)
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 sceneResponse{}, fmt.Errorf("scene[%d] main_participants[%d] must not be empty", index, participantIndex)
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants[%d] must not be empty", index, participantIndex)
}
out.MainParticipants = append(out.MainParticipants, trimmed)
}

View File

@@ -105,8 +105,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
response: chunkResponse{
Scenes: []sceneResponse{
{
StartUnitID: "seg-001",
EndUnitID: "seg-002",
StartUnitID: dnd.UnitRefFromInt(1),
EndUnitID: dnd.UnitRefFromInt(2),
ShortTitle: " Goblin parley ",
PrimaryMode: "Discussion",
MainParticipants: []string{" Aria ", "Goblin scout"},
@@ -115,8 +115,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
BoundaryConfidence: "High",
},
{
StartUnitID: "seg-003",
EndUnitID: "seg-004",
StartUnitID: dnd.UnitRefFromInt(3),
EndUnitID: dnd.UnitRefFromInt(4),
ShortTitle: "Ambush at the gate",
PrimaryMode: "Combat",
MainParticipants: []string{"Aria", "Goblin ambushers"},
@@ -204,8 +204,8 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{
Scenes: []sceneResponse{
{
StartUnitID: "seg-001",
EndUnitID: "seg-004",
StartUnitID: dnd.UnitRefFromInt(1),
EndUnitID: dnd.UnitRefFromInt(4),
ShortTitle: "Ambush",
PrimaryMode: "Combat",
MainParticipants: []string{"Aria"},
@@ -445,8 +445,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
name: "empty metadata field",
response: replaceScenes(validSceneResponse(), []sceneResponse{
{
StartUnitID: "seg-001",
EndUnitID: "seg-004",
StartUnitID: dnd.UnitRefFromString("seg-001"),
EndUnitID: dnd.UnitRefFromString("seg-004"),
ShortTitle: " ",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"},
@@ -461,8 +461,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
name: "empty participant",
response: replaceScenes(validSceneResponse(), []sceneResponse{
{
StartUnitID: "seg-001",
EndUnitID: "seg-004",
StartUnitID: dnd.UnitRefFromString("seg-001"),
EndUnitID: dnd.UnitRefFromString("seg-004"),
ShortTitle: "Title",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria", " "},
@@ -553,8 +553,8 @@ func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse
func scene(startUnitID string, endUnitID string) sceneResponse {
return sceneResponse{
StartUnitID: startUnitID,
EndUnitID: endUnitID,
StartUnitID: dnd.UnitRefFromString(startUnitID),
EndUnitID: dnd.UnitRefFromString(endUnitID),
ShortTitle: "Scene title",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"},

View File

@@ -1,17 +1,30 @@
package scenes
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
type chunkResponse struct {
Scenes []sceneResponse `json:"scenes"`
BoundaryCaveats []string `json:"boundary_caveats"`
}
type sceneResponse struct {
StartUnitID string `json:"start_unit_id"`
EndUnitID string `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"`
StartUnitID dnd.UnitRef `json:"start_unit_id"`
EndUnitID dnd.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 string
EndUnitID string
ShortTitle string
PrimaryMode string
MainParticipants []string
Summary string
BoundaryNote string
BoundaryConfidence string
}

View File

@@ -64,8 +64,11 @@ func TestResponseSchemaShapeUsesSourceUnitBoundaries(t *testing.T) {
}
for _, field := range []string{"start_unit_id", "end_unit_id"} {
property := sceneProperties[field].(map[string]any)
if property["type"] != "string" {
t.Fatalf("%s type = %#v, want string", field, property["type"])
if property["type"] != "integer" {
t.Fatalf("%s type = %#v, want integer", field, property["type"])
}
if property["minimum"] != float64(1) {
t.Fatalf("%s minimum = %#v, want 1", field, property["minimum"])
}
}
@@ -87,7 +90,7 @@ func TestResponseSchemaShapeUsesSourceUnitBoundaries(t *testing.T) {
}
}
func TestResponseStructRejectsIntegerBoundaries(t *testing.T) {
func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) {
raw := []byte(`{
"scenes": [
{
@@ -105,12 +108,14 @@ func TestResponseStructRejectsIntegerBoundaries(t *testing.T) {
}`)
var response chunkResponse
err := json.Unmarshal(raw, &response)
if err == nil {
t.Fatalf("Unmarshal() error = nil, want integer boundary type error: %#v", response)
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatalf("Unmarshal() error = %v, want nil", err)
}
if !strings.Contains(err.Error(), "string") {
t.Fatalf("Unmarshal() error = %v, want string type error", err)
if got := response.Scenes[0].StartUnitID.String(); got != "1" {
t.Fatalf("StartUnitID = %q, want 1", got)
}
if got := response.Scenes[0].EndUnitID.String(); got != "3" {
t.Fatalf("EndUnitID = %q, want 3", got)
}
}

View File

@@ -1,4 +1,5 @@
Source references must use the source-unit IDs exactly as provided.
Source references must use 1-based integer source-unit numbers from the
transcript, where 1 is the first provided source unit.
Return only D&D spell-cast artifacts. For each spell cast, identify the in-world
caster, spell name, effect, narrative description, and source references using

View File

@@ -47,12 +47,12 @@
"minLength": 1
},
"start_unit_id": {
"type": "string",
"minLength": 1
"type": "integer",
"minimum": 1
},
"end_unit_id": {
"type": "string",
"minLength": 1
"type": "integer",
"minimum": 1
}
}
}

View File

@@ -133,7 +133,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
}
candidates = append(candidates, artifacts.ArtifactCandidate{
Payload: payload,
SourceRefs: append([]source.SourceRef(nil), spellCast.SourceRefs...),
SourceRefs: sourceRefCandidates(req.Source, spellCast.SourceRefs),
})
}
return contracts.ExtractionResult{Candidates: candidates}, nil
@@ -164,6 +164,17 @@ func spellCastPayload(spellCast spellCastResponse) (json.RawMessage, error) {
})
}
func sourceRefCandidates(doc *source.SourceDocument, refs []dnd.SourceRefResponse) []source.SourceRef {
if len(refs) == 0 {
return nil
}
out := make([]source.SourceRef, 0, len(refs))
for _, ref := range refs {
out = append(out, dnd.SourceRefCandidate(doc, ref))
}
return out
}
func extractorErrorf(format string, args ...any) error {
return fmt.Errorf("dnd spells extractor: "+format, args...)
}

View File

@@ -21,9 +21,7 @@ func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) {
Spell: " Cure Wounds ",
Effect: " Heals an injured ally. ",
NarrativeDescription: " Aria restores the fighter after the fight. ",
SourceRefs: []source.SourceRef{
{SourceID: "session-alpha", StartUnitID: "seg-001", EndUnitID: "seg-002"},
},
SourceRefs: responseSourceRefsInt("session-alpha", 1, 2),
},
},
},
@@ -256,14 +254,14 @@ func TestExtractPreservesResponseOrder(t *testing.T) {
Spell: "Cure Wounds",
Effect: "Heals.",
NarrativeDescription: "First spell.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: "seg-001", EndUnitID: "seg-001"}},
SourceRefs: responseSourceRefs("session-alpha", "seg-001", "seg-001"),
},
{
Caster: "Bandit Shaman",
Spell: "Fire Bolt",
Effect: "Burns.",
NarrativeDescription: "Second spell.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: "seg-002", EndUnitID: "seg-002"}},
SourceRefs: responseSourceRefs("session-alpha", "seg-002", "seg-002"),
},
},
},
@@ -298,7 +296,7 @@ func TestExtractCopiesCandidateSourceRefs(t *testing.T) {
Spell: "Cure Wounds",
Effect: "Heals.",
NarrativeDescription: "Aria heals.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: "seg-001", EndUnitID: "seg-002"}},
SourceRefs: responseSourceRefs("session-alpha", "seg-001", "seg-002"),
},
},
},
@@ -308,7 +306,7 @@ func TestExtractCopiesCandidateSourceRefs(t *testing.T) {
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
client.response.SpellCasts[0].SourceRefs[0].StartUnitID = "mutated"
client.response.SpellCasts[0].SourceRefs[0].StartUnitID = dnd.UnitRefFromString("mutated")
if got := result.Candidates[0].SourceRefs[0].StartUnitID; got != "seg-001" {
t.Fatalf("candidate source ref start = %q, want copied seg-001", got)

View File

@@ -1,6 +1,6 @@
package spells
import "gitea.maximumdirect.net/eric/notarius/internal/core/source"
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
type SpellCast struct {
Caster string `json:"caster"`
@@ -14,9 +14,9 @@ type extractionResponse struct {
}
type spellCastResponse struct {
Caster string `json:"caster"`
Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []source.SourceRef `json:"source_refs"`
Caster string `json:"caster"`
Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []dnd.SourceRefResponse `json:"source_refs"`
}

View File

@@ -26,18 +26,14 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
Spell: "Cure Wounds",
Effect: "Heals an injured ally.",
NarrativeDescription: "Aria restores the fighter after the fight.",
SourceRefs: []source.SourceRef{
{SourceID: expectedDoc.ID, StartUnitID: "seg-001", EndUnitID: "seg-001"},
},
SourceRefs: responseSourceRefs(expectedDoc.ID, "seg-001", "seg-001"),
},
{
Caster: "Borin",
Spell: "Fire Bolt",
Effect: "Scorches the wight.",
NarrativeDescription: "Borin hurls fire at the wight.",
SourceRefs: []source.SourceRef{
{SourceID: expectedDoc.ID, StartUnitID: "seg-003", EndUnitID: "seg-003"},
},
SourceRefs: responseSourceRefs(expectedDoc.ID, "seg-003", "seg-003"),
},
},
},
@@ -126,9 +122,7 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
Spell: "Fire Bolt",
Effect: "Scorches the wight.",
NarrativeDescription: "Borin hurls fire at the wight.",
SourceRefs: []source.SourceRef{
{SourceID: expectedDoc.ID, StartUnitID: "seg-003", EndUnitID: "seg-003"},
},
SourceRefs: responseSourceRefs(expectedDoc.ID, "seg-003", "seg-003"),
},
},
},
@@ -213,9 +207,7 @@ func TestRunnerRejectsDNDSpellCastWithInvalidSourceRef(t *testing.T) {
Spell: "Cure Wounds",
Effect: "Heals an injured ally.",
NarrativeDescription: "Aria restores the fighter after the fight.",
SourceRefs: []source.SourceRef{
{SourceID: "spell-session", StartUnitID: "seg-999", EndUnitID: "seg-999"},
},
SourceRefs: responseSourceRefs("spell-session", "seg-999", "seg-999"),
},
},
},

View File

@@ -29,6 +29,23 @@ func TestLoadResponseSchemaForSpells(t *testing.T) {
if !json.Valid(schema.JSONSchema) {
t.Fatalf("schema.JSONSchema is invalid JSON: %s", schema.JSONSchema)
}
var decoded map[string]any
if err := json.Unmarshal(schema.JSONSchema, &decoded); err != nil {
t.Fatalf("Unmarshal(schema.JSONSchema) error = %v, want nil", err)
}
properties := decoded["properties"].(map[string]any)
spellCastProperties := properties["spell_casts"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
sourceRefProperties := spellCastProperties["source_refs"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
for _, field := range []string{"start_unit_id", "end_unit_id"} {
property := sourceRefProperties[field].(map[string]any)
if property["type"] != "integer" {
t.Fatalf("%s type = %#v, want integer", field, property["type"])
}
if property["minimum"] != float64(1) {
t.Fatalf("%s minimum = %#v, want 1", field, property["minimum"])
}
}
}
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
)
func promptExtractionRequest() contracts.ExtractionRequest {
@@ -59,3 +60,23 @@ func mustJSON(t *testing.T, value any) string {
}
return string(encoded)
}
func responseSourceRefs(sourceID string, startUnitID string, endUnitID string) []dnd.SourceRefResponse {
return []dnd.SourceRefResponse{
{
SourceID: sourceID,
StartUnitID: dnd.UnitRefFromString(startUnitID),
EndUnitID: dnd.UnitRefFromString(endUnitID),
},
}
}
func responseSourceRefsInt(sourceID string, startUnitID int, endUnitID int) []dnd.SourceRefResponse {
return []dnd.SourceRefResponse{
{
SourceID: sourceID,
StartUnitID: dnd.UnitRefFromInt(startUnitID),
EndUnitID: dnd.UnitRefFromInt(endUnitID),
},
}
}

View File

@@ -0,0 +1,133 @@
package dnd
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
type UnitRef struct {
value string
fromNumber bool
number int
}
type SourceRefResponse struct {
SourceID string `json:"source_id"`
StartUnitID UnitRef `json:"start_unit_id"`
EndUnitID UnitRef `json:"end_unit_id"`
}
func UnitRefFromString(value string) UnitRef {
return UnitRef{value: value}
}
func UnitRefFromInt(value int) UnitRef {
return UnitRef{
value: strconv.Itoa(value),
fromNumber: true,
number: value,
}
}
func (ref UnitRef) String() string {
return ref.value
}
func (ref *UnitRef) UnmarshalJSON(raw []byte) error {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return fmt.Errorf("unit ref must be a string or integer")
}
if raw[0] == '"' {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return err
}
*ref = UnitRefFromString(value)
return nil
}
number, err := strconv.Atoi(string(raw))
if err != nil {
return fmt.Errorf("unit ref must be a string or integer")
}
*ref = UnitRefFromInt(number)
return nil
}
func (ref UnitRef) MarshalJSON() ([]byte, error) {
if ref.fromNumber {
return []byte(strconv.Itoa(ref.number)), nil
}
return json.Marshal(ref.value)
}
func ResolveUnitID(doc *source.SourceDocument, field string, ref UnitRef) (string, error) {
value := strings.TrimSpace(ref.value)
if value == "" {
return "", fmt.Errorf("%s must not be empty", field)
}
if id, ok := canonicalUnitID(doc, value); ok {
return id, nil
}
if number, ok := unitNumber(value); ok {
if id, ok := unitIDByNumber(doc, number); ok {
return id, nil
}
return "", fmt.Errorf("%s %d was not found as a source-unit ID or 1-based unit number", field, number)
}
return "", fmt.Errorf("%s %q was not found", field, value)
}
func SourceRefCandidate(doc *source.SourceDocument, ref SourceRefResponse) source.SourceRef {
return source.SourceRef{
SourceID: strings.TrimSpace(ref.SourceID),
StartUnitID: unitIDCandidate(doc, ref.StartUnitID),
EndUnitID: unitIDCandidate(doc, ref.EndUnitID),
}
}
func unitIDCandidate(doc *source.SourceDocument, ref UnitRef) string {
value := strings.TrimSpace(ref.value)
if id, ok := canonicalUnitID(doc, value); ok {
return id
}
if number, ok := unitNumber(value); ok {
if id, ok := unitIDByNumber(doc, number); ok {
return id
}
}
return value
}
func canonicalUnitID(doc *source.SourceDocument, value string) (string, bool) {
if doc == nil {
return "", false
}
for _, unit := range doc.Units {
if unit.ID == value {
return unit.ID, true
}
}
return "", false
}
func unitIDByNumber(doc *source.SourceDocument, number int) (string, bool) {
if doc == nil || number < 1 || number > len(doc.Units) {
return "", false
}
return doc.Units[number-1].ID, true
}
func unitNumber(value string) (int, bool) {
number, err := strconv.Atoi(value)
if err != nil {
return 0, false
}
return number, true
}

View File

@@ -0,0 +1,117 @@
package dnd
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestUnitRefUnmarshalAcceptsIntegerAndString(t *testing.T) {
var integerRef UnitRef
if err := json.Unmarshal([]byte(`12`), &integerRef); err != nil {
t.Fatalf("Unmarshal(integer) error = %v, want nil", err)
}
if got := integerRef.String(); got != "12" {
t.Fatalf("integer ref = %q, want 12", got)
}
var stringRef UnitRef
if err := json.Unmarshal([]byte(`"seg-001"`), &stringRef); err != nil {
t.Fatalf("Unmarshal(string) error = %v, want nil", err)
}
if got := stringRef.String(); got != "seg-001" {
t.Fatalf("string ref = %q, want seg-001", got)
}
}
func TestUnitRefUnmarshalRejectsNonIntegerTypes(t *testing.T) {
for _, raw := range []string{`true`, `null`, `1.5`, `{}`} {
t.Run(raw, func(t *testing.T) {
var ref UnitRef
err := json.Unmarshal([]byte(raw), &ref)
if err == nil {
t.Fatal("Unmarshal() error = nil, want error")
}
if !strings.Contains(err.Error(), "string or integer") {
t.Fatalf("Unmarshal() error = %q, want type context", err.Error())
}
})
}
}
func TestResolveUnitIDPrefersExactSourceUnitID(t *testing.T) {
doc := unitRefSourceDocument("2", "10")
got, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(2))
if err != nil {
t.Fatalf("ResolveUnitID() error = %v, want nil", err)
}
if got != "2" {
t.Fatalf("ResolveUnitID() = %q, want exact source unit ID", got)
}
}
func TestResolveUnitIDFallsBackToOneBasedUnitNumber(t *testing.T) {
doc := unitRefSourceDocument("seg-001", "seg-002")
got, err := ResolveUnitID(doc, "end_unit_id", UnitRefFromInt(2))
if err != nil {
t.Fatalf("ResolveUnitID() error = %v, want nil", err)
}
if got != "seg-002" {
t.Fatalf("ResolveUnitID() = %q, want second source unit ID", got)
}
}
func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
doc := unitRefSourceDocument("seg-001")
_, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(9))
if err == nil {
t.Fatal("ResolveUnitID() error = nil, want error")
}
if !strings.Contains(err.Error(), "start_unit_id 9") {
t.Fatalf("ResolveUnitID() error = %q, want field and value context", err.Error())
}
}
func TestSourceRefCandidateCanonicalizesValidRefsAndPreservesInvalidRefs(t *testing.T) {
doc := unitRefSourceDocument("seg-001", "seg-002")
valid := SourceRefCandidate(doc, SourceRefResponse{
SourceID: " session-alpha ",
StartUnitID: UnitRefFromInt(1),
EndUnitID: UnitRefFromInt(2),
})
if valid != (source.SourceRef{SourceID: "session-alpha", StartUnitID: "seg-001", EndUnitID: "seg-002"}) {
t.Fatalf("valid candidate = %#v, want canonical source ref", valid)
}
invalid := SourceRefCandidate(doc, SourceRefResponse{
SourceID: "session-alpha",
StartUnitID: UnitRefFromInt(9),
EndUnitID: UnitRefFromString("missing"),
})
if invalid != (source.SourceRef{SourceID: "session-alpha", StartUnitID: "9", EndUnitID: "missing"}) {
t.Fatalf("invalid candidate = %#v, want unresolved values for validator", invalid)
}
}
func unitRefSourceDocument(ids ...string) *source.SourceDocument {
doc := &source.SourceDocument{
ID: "session-alpha",
Kind: "transcript",
Format: "application/json",
Digest: "sha256:test",
}
for _, id := range ids {
doc.Units = append(doc.Units, source.SourceUnit{
ID: id,
Kind: "transcript_segment",
Text: "text",
})
}
return doc
}