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.
|
Options: none. Non-empty options are rejected.
|
||||||
|
|
||||||
The chunker enforces full source-unit coverage from the first source unit to the
|
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
|
last, sequential contiguous scenes, and no overlap. Its LLM-facing schema uses
|
||||||
assigns chunk IDs such as `scene-000001` and stores scene metadata including
|
integer `start_unit_id` and `end_unit_id` values as 1-based source-unit numbers;
|
||||||
title, primary mode, participants, summary, boundary note, confidence, boundary
|
the module canonicalizes valid integer references to source-unit IDs before
|
||||||
unit IDs, and unit count. Boundary caveats become warnings with reason code
|
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
|
`scene_boundary_caveat`. Whitespace-only caveats are treated as malformed
|
||||||
structured output rather than silently dropped.
|
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
|
input materials, response schema, and session ID to the runtime; converts
|
||||||
spell-cast responses into artifact candidates; and supplies deterministic
|
spell-cast responses into artifact candidates; and supplies deterministic
|
||||||
validators.
|
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
|
Its prompt definition lives under `assets/prompts` and its schema under
|
||||||
`assets/schemas`. Shared reusable D&D prompt fragments are provided by
|
`assets/schemas`. Shared reusable D&D prompt fragments are provided by
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ Fix:
|
|||||||
follows structured response schemas reliably.
|
follows structured response schemas reliably.
|
||||||
- If the error names `boundary_caveats`, check for blank or whitespace-only
|
- If the error names `boundary_caveats`, check for blank or whitespace-only
|
||||||
caveat text in the scene response.
|
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.
|
document, be contiguous, and not overlap.
|
||||||
|
|
||||||
## Session ID
|
## Session ID
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ dnd/scenes boundary policy:
|
|||||||
- return sequential scenes with no gaps;
|
- return sequential scenes with no gaps;
|
||||||
- do not overlap scenes;
|
- do not overlap scenes;
|
||||||
- preserve source-unit order;
|
- 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;
|
- each scene must have start_unit_id and end_unit_id;
|
||||||
- do not include final chunk IDs or chunk indexes.
|
- do not include final chunk IDs or chunk indexes.
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,12 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"start_unit_id": {
|
"start_unit_id": {
|
||||||
"type": "string",
|
"type": "integer",
|
||||||
"minLength": 1
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"end_unit_id": {
|
"end_unit_id": {
|
||||||
"type": "string",
|
"type": "integer",
|
||||||
"minLength": 1
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"short_title": {
|
"short_title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ func chunksFromResponse(doc *source.SourceDocument, response chunkResponse) ([]c
|
|||||||
chunks := make([]contracts.SourceChunk, 0, len(response.Scenes))
|
chunks := make([]contracts.SourceChunk, 0, len(response.Scenes))
|
||||||
previousEnd := -1
|
previousEnd := -1
|
||||||
for i, scene := range response.Scenes {
|
for i, scene := range response.Scenes {
|
||||||
normalized, err := normalizeScene(i, scene)
|
normalized, err := normalizeScene(doc, i, scene)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -206,10 +206,19 @@ func chunksFromResponse(doc *source.SourceDocument, response chunkResponse) ([]c
|
|||||||
return chunks, nil
|
return chunks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeScene(index int, scene sceneResponse) (sceneResponse, error) {
|
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
|
||||||
out := sceneResponse{
|
startUnitID, err := dnd.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
|
||||||
StartUnitID: strings.TrimSpace(scene.StartUnitID),
|
if err != nil {
|
||||||
EndUnitID: strings.TrimSpace(scene.EndUnitID),
|
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),
|
ShortTitle: strings.TrimSpace(scene.ShortTitle),
|
||||||
PrimaryMode: strings.TrimSpace(scene.PrimaryMode),
|
PrimaryMode: strings.TrimSpace(scene.PrimaryMode),
|
||||||
Summary: strings.TrimSpace(scene.Summary),
|
Summary: strings.TrimSpace(scene.Summary),
|
||||||
@@ -228,23 +237,23 @@ func normalizeScene(index int, scene sceneResponse) (sceneResponse, error) {
|
|||||||
}
|
}
|
||||||
for field, value := range required {
|
for field, value := range required {
|
||||||
if value == "" {
|
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) {
|
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) {
|
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 {
|
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))
|
out.MainParticipants = make([]string, 0, len(scene.MainParticipants))
|
||||||
for participantIndex, participant := range scene.MainParticipants {
|
for participantIndex, participant := range scene.MainParticipants {
|
||||||
trimmed := strings.TrimSpace(participant)
|
trimmed := strings.TrimSpace(participant)
|
||||||
if trimmed == "" {
|
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)
|
out.MainParticipants = append(out.MainParticipants, trimmed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
|||||||
response: chunkResponse{
|
response: chunkResponse{
|
||||||
Scenes: []sceneResponse{
|
Scenes: []sceneResponse{
|
||||||
{
|
{
|
||||||
StartUnitID: "seg-001",
|
StartUnitID: dnd.UnitRefFromInt(1),
|
||||||
EndUnitID: "seg-002",
|
EndUnitID: dnd.UnitRefFromInt(2),
|
||||||
ShortTitle: " Goblin parley ",
|
ShortTitle: " Goblin parley ",
|
||||||
PrimaryMode: "Discussion",
|
PrimaryMode: "Discussion",
|
||||||
MainParticipants: []string{" Aria ", "Goblin scout"},
|
MainParticipants: []string{" Aria ", "Goblin scout"},
|
||||||
@@ -115,8 +115,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
|||||||
BoundaryConfidence: "High",
|
BoundaryConfidence: "High",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
StartUnitID: "seg-003",
|
StartUnitID: dnd.UnitRefFromInt(3),
|
||||||
EndUnitID: "seg-004",
|
EndUnitID: dnd.UnitRefFromInt(4),
|
||||||
ShortTitle: "Ambush at the gate",
|
ShortTitle: "Ambush at the gate",
|
||||||
PrimaryMode: "Combat",
|
PrimaryMode: "Combat",
|
||||||
MainParticipants: []string{"Aria", "Goblin ambushers"},
|
MainParticipants: []string{"Aria", "Goblin ambushers"},
|
||||||
@@ -204,8 +204,8 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
|
|||||||
client := &fakeScenesLLMClient{response: chunkResponse{
|
client := &fakeScenesLLMClient{response: chunkResponse{
|
||||||
Scenes: []sceneResponse{
|
Scenes: []sceneResponse{
|
||||||
{
|
{
|
||||||
StartUnitID: "seg-001",
|
StartUnitID: dnd.UnitRefFromInt(1),
|
||||||
EndUnitID: "seg-004",
|
EndUnitID: dnd.UnitRefFromInt(4),
|
||||||
ShortTitle: "Ambush",
|
ShortTitle: "Ambush",
|
||||||
PrimaryMode: "Combat",
|
PrimaryMode: "Combat",
|
||||||
MainParticipants: []string{"Aria"},
|
MainParticipants: []string{"Aria"},
|
||||||
@@ -445,8 +445,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
|
|||||||
name: "empty metadata field",
|
name: "empty metadata field",
|
||||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||||
{
|
{
|
||||||
StartUnitID: "seg-001",
|
StartUnitID: dnd.UnitRefFromString("seg-001"),
|
||||||
EndUnitID: "seg-004",
|
EndUnitID: dnd.UnitRefFromString("seg-004"),
|
||||||
ShortTitle: " ",
|
ShortTitle: " ",
|
||||||
PrimaryMode: "Narrative",
|
PrimaryMode: "Narrative",
|
||||||
MainParticipants: []string{"Aria"},
|
MainParticipants: []string{"Aria"},
|
||||||
@@ -461,8 +461,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
|
|||||||
name: "empty participant",
|
name: "empty participant",
|
||||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||||
{
|
{
|
||||||
StartUnitID: "seg-001",
|
StartUnitID: dnd.UnitRefFromString("seg-001"),
|
||||||
EndUnitID: "seg-004",
|
EndUnitID: dnd.UnitRefFromString("seg-004"),
|
||||||
ShortTitle: "Title",
|
ShortTitle: "Title",
|
||||||
PrimaryMode: "Narrative",
|
PrimaryMode: "Narrative",
|
||||||
MainParticipants: []string{"Aria", " "},
|
MainParticipants: []string{"Aria", " "},
|
||||||
@@ -553,8 +553,8 @@ func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse
|
|||||||
|
|
||||||
func scene(startUnitID string, endUnitID string) sceneResponse {
|
func scene(startUnitID string, endUnitID string) sceneResponse {
|
||||||
return sceneResponse{
|
return sceneResponse{
|
||||||
StartUnitID: startUnitID,
|
StartUnitID: dnd.UnitRefFromString(startUnitID),
|
||||||
EndUnitID: endUnitID,
|
EndUnitID: dnd.UnitRefFromString(endUnitID),
|
||||||
ShortTitle: "Scene title",
|
ShortTitle: "Scene title",
|
||||||
PrimaryMode: "Narrative",
|
PrimaryMode: "Narrative",
|
||||||
MainParticipants: []string{"Aria"},
|
MainParticipants: []string{"Aria"},
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
package scenes
|
package scenes
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||||
|
|
||||||
type chunkResponse struct {
|
type chunkResponse struct {
|
||||||
Scenes []sceneResponse `json:"scenes"`
|
Scenes []sceneResponse `json:"scenes"`
|
||||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
BoundaryCaveats []string `json:"boundary_caveats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sceneResponse struct {
|
type sceneResponse struct {
|
||||||
StartUnitID string `json:"start_unit_id"`
|
StartUnitID dnd.UnitRef `json:"start_unit_id"`
|
||||||
EndUnitID string `json:"end_unit_id"`
|
EndUnitID dnd.UnitRef `json:"end_unit_id"`
|
||||||
ShortTitle string `json:"short_title"`
|
ShortTitle string `json:"short_title"`
|
||||||
PrimaryMode string `json:"primary_mode"`
|
PrimaryMode string `json:"primary_mode"`
|
||||||
MainParticipants []string `json:"main_participants"`
|
MainParticipants []string `json:"main_participants"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
BoundaryNote string `json:"boundary_note"`
|
BoundaryNote string `json:"boundary_note"`
|
||||||
BoundaryConfidence string `json:"boundary_confidence"`
|
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"} {
|
for _, field := range []string{"start_unit_id", "end_unit_id"} {
|
||||||
property := sceneProperties[field].(map[string]any)
|
property := sceneProperties[field].(map[string]any)
|
||||||
if property["type"] != "string" {
|
if property["type"] != "integer" {
|
||||||
t.Fatalf("%s type = %#v, want string", field, property["type"])
|
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(`{
|
raw := []byte(`{
|
||||||
"scenes": [
|
"scenes": [
|
||||||
{
|
{
|
||||||
@@ -105,12 +108,14 @@ func TestResponseStructRejectsIntegerBoundaries(t *testing.T) {
|
|||||||
}`)
|
}`)
|
||||||
|
|
||||||
var response chunkResponse
|
var response chunkResponse
|
||||||
err := json.Unmarshal(raw, &response)
|
if err := json.Unmarshal(raw, &response); err != nil {
|
||||||
if err == nil {
|
t.Fatalf("Unmarshal() error = %v, want nil", err)
|
||||||
t.Fatalf("Unmarshal() error = nil, want integer boundary type error: %#v", response)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "string") {
|
if got := response.Scenes[0].StartUnitID.String(); got != "1" {
|
||||||
t.Fatalf("Unmarshal() error = %v, want string type error", err)
|
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
|
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
|
caster, spell name, effect, narrative description, and source references using
|
||||||
|
|||||||
@@ -47,12 +47,12 @@
|
|||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
"start_unit_id": {
|
"start_unit_id": {
|
||||||
"type": "string",
|
"type": "integer",
|
||||||
"minLength": 1
|
"minimum": 1
|
||||||
},
|
},
|
||||||
"end_unit_id": {
|
"end_unit_id": {
|
||||||
"type": "string",
|
"type": "integer",
|
||||||
"minLength": 1
|
"minimum": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
|
|||||||
}
|
}
|
||||||
candidates = append(candidates, artifacts.ArtifactCandidate{
|
candidates = append(candidates, artifacts.ArtifactCandidate{
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
SourceRefs: append([]source.SourceRef(nil), spellCast.SourceRefs...),
|
SourceRefs: sourceRefCandidates(req.Source, spellCast.SourceRefs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return contracts.ExtractionResult{Candidates: candidates}, nil
|
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 {
|
func extractorErrorf(format string, args ...any) error {
|
||||||
return fmt.Errorf("dnd spells extractor: "+format, args...)
|
return fmt.Errorf("dnd spells extractor: "+format, args...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) {
|
|||||||
Spell: " Cure Wounds ",
|
Spell: " Cure Wounds ",
|
||||||
Effect: " Heals an injured ally. ",
|
Effect: " Heals an injured ally. ",
|
||||||
NarrativeDescription: " Aria restores the fighter after the fight. ",
|
NarrativeDescription: " Aria restores the fighter after the fight. ",
|
||||||
SourceRefs: []source.SourceRef{
|
SourceRefs: responseSourceRefsInt("session-alpha", 1, 2),
|
||||||
{SourceID: "session-alpha", StartUnitID: "seg-001", EndUnitID: "seg-002"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -256,14 +254,14 @@ func TestExtractPreservesResponseOrder(t *testing.T) {
|
|||||||
Spell: "Cure Wounds",
|
Spell: "Cure Wounds",
|
||||||
Effect: "Heals.",
|
Effect: "Heals.",
|
||||||
NarrativeDescription: "First spell.",
|
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",
|
Caster: "Bandit Shaman",
|
||||||
Spell: "Fire Bolt",
|
Spell: "Fire Bolt",
|
||||||
Effect: "Burns.",
|
Effect: "Burns.",
|
||||||
NarrativeDescription: "Second spell.",
|
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",
|
Spell: "Cure Wounds",
|
||||||
Effect: "Heals.",
|
Effect: "Heals.",
|
||||||
NarrativeDescription: "Aria 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 {
|
if err != nil {
|
||||||
t.Fatalf("Extract() error = %v, want nil", err)
|
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" {
|
if got := result.Candidates[0].SourceRefs[0].StartUnitID; got != "seg-001" {
|
||||||
t.Fatalf("candidate source ref start = %q, want copied seg-001", got)
|
t.Fatalf("candidate source ref start = %q, want copied seg-001", got)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package spells
|
package spells
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||||
|
|
||||||
type SpellCast struct {
|
type SpellCast struct {
|
||||||
Caster string `json:"caster"`
|
Caster string `json:"caster"`
|
||||||
@@ -14,9 +14,9 @@ type extractionResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type spellCastResponse struct {
|
type spellCastResponse struct {
|
||||||
Caster string `json:"caster"`
|
Caster string `json:"caster"`
|
||||||
Spell string `json:"spell"`
|
Spell string `json:"spell"`
|
||||||
Effect string `json:"effect"`
|
Effect string `json:"effect"`
|
||||||
NarrativeDescription string `json:"narrative_description"`
|
NarrativeDescription string `json:"narrative_description"`
|
||||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
SourceRefs []dnd.SourceRefResponse `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,18 +26,14 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
|
|||||||
Spell: "Cure Wounds",
|
Spell: "Cure Wounds",
|
||||||
Effect: "Heals an injured ally.",
|
Effect: "Heals an injured ally.",
|
||||||
NarrativeDescription: "Aria restores the fighter after the fight.",
|
NarrativeDescription: "Aria restores the fighter after the fight.",
|
||||||
SourceRefs: []source.SourceRef{
|
SourceRefs: responseSourceRefs(expectedDoc.ID, "seg-001", "seg-001"),
|
||||||
{SourceID: expectedDoc.ID, StartUnitID: "seg-001", EndUnitID: "seg-001"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Caster: "Borin",
|
Caster: "Borin",
|
||||||
Spell: "Fire Bolt",
|
Spell: "Fire Bolt",
|
||||||
Effect: "Scorches the wight.",
|
Effect: "Scorches the wight.",
|
||||||
NarrativeDescription: "Borin hurls fire at the wight.",
|
NarrativeDescription: "Borin hurls fire at the wight.",
|
||||||
SourceRefs: []source.SourceRef{
|
SourceRefs: responseSourceRefs(expectedDoc.ID, "seg-003", "seg-003"),
|
||||||
{SourceID: expectedDoc.ID, StartUnitID: "seg-003", EndUnitID: "seg-003"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -126,9 +122,7 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
|
|||||||
Spell: "Fire Bolt",
|
Spell: "Fire Bolt",
|
||||||
Effect: "Scorches the wight.",
|
Effect: "Scorches the wight.",
|
||||||
NarrativeDescription: "Borin hurls fire at the wight.",
|
NarrativeDescription: "Borin hurls fire at the wight.",
|
||||||
SourceRefs: []source.SourceRef{
|
SourceRefs: responseSourceRefs(expectedDoc.ID, "seg-003", "seg-003"),
|
||||||
{SourceID: expectedDoc.ID, StartUnitID: "seg-003", EndUnitID: "seg-003"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -213,9 +207,7 @@ func TestRunnerRejectsDNDSpellCastWithInvalidSourceRef(t *testing.T) {
|
|||||||
Spell: "Cure Wounds",
|
Spell: "Cure Wounds",
|
||||||
Effect: "Heals an injured ally.",
|
Effect: "Heals an injured ally.",
|
||||||
NarrativeDescription: "Aria restores the fighter after the fight.",
|
NarrativeDescription: "Aria restores the fighter after the fight.",
|
||||||
SourceRefs: []source.SourceRef{
|
SourceRefs: responseSourceRefs("spell-session", "seg-999", "seg-999"),
|
||||||
{SourceID: "spell-session", StartUnitID: "seg-999", EndUnitID: "seg-999"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ func TestLoadResponseSchemaForSpells(t *testing.T) {
|
|||||||
if !json.Valid(schema.JSONSchema) {
|
if !json.Valid(schema.JSONSchema) {
|
||||||
t.Fatalf("schema.JSONSchema is invalid JSON: %s", 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) {
|
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"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"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func promptExtractionRequest() contracts.ExtractionRequest {
|
func promptExtractionRequest() contracts.ExtractionRequest {
|
||||||
@@ -59,3 +60,23 @@ func mustJSON(t *testing.T, value any) string {
|
|||||||
}
|
}
|
||||||
return string(encoded)
|
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