Update D&D schemas to require integer unit_id values
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
133
internal/modules/sharedassets/dnd/unit_refs.go
Normal file
133
internal/modules/sharedassets/dnd/unit_refs.go
Normal 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
|
||||
}
|
||||
117
internal/modules/sharedassets/dnd/unit_refs_test.go
Normal file
117
internal/modules/sharedassets/dnd/unit_refs_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user