diff --git a/docs/internal/modules.md b/docs/internal/modules.md index d28b26c..1422160 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -125,10 +125,13 @@ Provides: Options: none. Non-empty options are rejected. The chunker enforces full source-unit coverage from the first source unit to the -last, exact source-unit IDs, sequential contiguous scenes, and no overlap. It -assigns chunk IDs such as `scene-000001` and stores scene metadata including -title, primary mode, participants, summary, boundary note, confidence, boundary -unit IDs, and unit count. Boundary caveats become warnings with reason code +last, sequential contiguous scenes, and no overlap. Its LLM-facing schema uses +integer `start_unit_id` and `end_unit_id` values as 1-based source-unit numbers; +the module canonicalizes valid integer references to source-unit IDs before +producing chunks. It assigns chunk IDs such as `scene-000001` and stores scene +metadata including title, primary mode, participants, summary, boundary note, +confidence, boundary unit IDs, and unit count. Boundary caveats become warnings +with reason code `scene_boundary_caveat`. Whitespace-only caveats are treated as malformed structured output rather than silently dropped. @@ -146,6 +149,9 @@ the embedded Scriptorium prompt ID, prompt version, transcript and reference input materials, response schema, and session ID to the runtime; converts spell-cast responses into artifact candidates; and supplies deterministic validators. +Its LLM-facing source-reference schema uses integer `start_unit_id` and +`end_unit_id` values as 1-based source-unit numbers; the module canonicalizes +valid integer references to source-unit IDs before validation and output. Its prompt definition lives under `assets/prompts` and its schema under `assets/schemas`. Shared reusable D&D prompt fragments are provided by diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5c14f93..5df3506 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -263,7 +263,7 @@ Fix: follows structured response schemas reliably. - If the error names `boundary_caveats`, check for blank or whitespace-only caveat text in the scene response. -- Scene boundaries must use exact source-unit IDs, cover the full source +- Scene boundaries must resolve to valid source units, cover the full source document, be contiguous, and not overlap. ## Session ID diff --git a/internal/modules/chunk/dnd/scenes/assets/prompts/instructions.md b/internal/modules/chunk/dnd/scenes/assets/prompts/instructions.md index 78afb03..54669da 100644 --- a/internal/modules/chunk/dnd/scenes/assets/prompts/instructions.md +++ b/internal/modules/chunk/dnd/scenes/assets/prompts/instructions.md @@ -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. diff --git a/internal/modules/chunk/dnd/scenes/assets/schemas/dnd_scenes.v1.json b/internal/modules/chunk/dnd/scenes/assets/schemas/dnd_scenes.v1.json index 7fdafd8..6dfe075 100644 --- a/internal/modules/chunk/dnd/scenes/assets/schemas/dnd_scenes.v1.json +++ b/internal/modules/chunk/dnd/scenes/assets/schemas/dnd_scenes.v1.json @@ -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", diff --git a/internal/modules/chunk/dnd/scenes/chunker.go b/internal/modules/chunk/dnd/scenes/chunker.go index b360dbd..01cb0a4 100644 --- a/internal/modules/chunk/dnd/scenes/chunker.go +++ b/internal/modules/chunk/dnd/scenes/chunker.go @@ -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) } diff --git a/internal/modules/chunk/dnd/scenes/chunker_test.go b/internal/modules/chunk/dnd/scenes/chunker_test.go index 42c198b..da65705 100644 --- a/internal/modules/chunk/dnd/scenes/chunker_test.go +++ b/internal/modules/chunk/dnd/scenes/chunker_test.go @@ -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"}, diff --git a/internal/modules/chunk/dnd/scenes/model.go b/internal/modules/chunk/dnd/scenes/model.go index a038ddd..b114fa1 100644 --- a/internal/modules/chunk/dnd/scenes/model.go +++ b/internal/modules/chunk/dnd/scenes/model.go @@ -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 } diff --git a/internal/modules/chunk/dnd/scenes/schema_test.go b/internal/modules/chunk/dnd/scenes/schema_test.go index 7e1a4cd..5cd9cd1 100644 --- a/internal/modules/chunk/dnd/scenes/schema_test.go +++ b/internal/modules/chunk/dnd/scenes/schema_test.go @@ -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) } } diff --git a/internal/modules/extract/dnd/spells/assets/prompts/instructions.md b/internal/modules/extract/dnd/spells/assets/prompts/instructions.md index 7f28a1c..fbdfdec 100644 --- a/internal/modules/extract/dnd/spells/assets/prompts/instructions.md +++ b/internal/modules/extract/dnd/spells/assets/prompts/instructions.md @@ -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 diff --git a/internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json b/internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json index 4a3da7d..0f20a3c 100644 --- a/internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json +++ b/internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json @@ -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 } } } diff --git a/internal/modules/extract/dnd/spells/extractor.go b/internal/modules/extract/dnd/spells/extractor.go index 7009bfd..a602122 100644 --- a/internal/modules/extract/dnd/spells/extractor.go +++ b/internal/modules/extract/dnd/spells/extractor.go @@ -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...) } diff --git a/internal/modules/extract/dnd/spells/extractor_test.go b/internal/modules/extract/dnd/spells/extractor_test.go index e26c04b..47d141c 100644 --- a/internal/modules/extract/dnd/spells/extractor_test.go +++ b/internal/modules/extract/dnd/spells/extractor_test.go @@ -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) diff --git a/internal/modules/extract/dnd/spells/model.go b/internal/modules/extract/dnd/spells/model.go index 9454d07..6980868 100644 --- a/internal/modules/extract/dnd/spells/model.go +++ b/internal/modules/extract/dnd/spells/model.go @@ -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"` } diff --git a/internal/modules/extract/dnd/spells/runner_test.go b/internal/modules/extract/dnd/spells/runner_test.go index 6348ff0..0790f75 100644 --- a/internal/modules/extract/dnd/spells/runner_test.go +++ b/internal/modules/extract/dnd/spells/runner_test.go @@ -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"), }, }, }, diff --git a/internal/modules/extract/dnd/spells/schema_test.go b/internal/modules/extract/dnd/spells/schema_test.go index 3955a2c..6579609 100644 --- a/internal/modules/extract/dnd/spells/schema_test.go +++ b/internal/modules/extract/dnd/spells/schema_test.go @@ -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) { diff --git a/internal/modules/extract/dnd/spells/test_helpers_test.go b/internal/modules/extract/dnd/spells/test_helpers_test.go index b51464b..2f668bc 100644 --- a/internal/modules/extract/dnd/spells/test_helpers_test.go +++ b/internal/modules/extract/dnd/spells/test_helpers_test.go @@ -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), + }, + } +} diff --git a/internal/modules/sharedassets/dnd/unit_refs.go b/internal/modules/sharedassets/dnd/unit_refs.go new file mode 100644 index 0000000..ba31328 --- /dev/null +++ b/internal/modules/sharedassets/dnd/unit_refs.go @@ -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 +} diff --git a/internal/modules/sharedassets/dnd/unit_refs_test.go b/internal/modules/sharedassets/dnd/unit_refs_test.go new file mode 100644 index 0000000..0c34c14 --- /dev/null +++ b/internal/modules/sharedassets/dnd/unit_refs_test.go @@ -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 +}