Refactor the D&D spells module to apply deterministic fields where appropriate
This commit is contained in:
@@ -14,8 +14,10 @@ This document is the durable raw output contract for the implemented
|
|||||||
- Media type: `application/json`
|
- Media type: `application/json`
|
||||||
|
|
||||||
The extractor requires source chunks and transcript source capability. It
|
The extractor requires source chunks and transcript source capability. It
|
||||||
returns the structured LLM response as raw JSON. The default `appendorder`
|
returns canonical spell-cast JSON derived from the structured LLM response. The
|
||||||
merger passes a single chunk output through and concatenates multiple
|
extractor assigns source IDs deterministically and keeps source-unit ranges as
|
||||||
|
model-authored evidence locations. The default `appendorder` merger passes a
|
||||||
|
single chunk output through and concatenates multiple
|
||||||
`spell_casts` arrays in chunk order. The default `noop` normalizer passes the
|
`spell_casts` arrays in chunk order. The default `noop` normalizer passes the
|
||||||
merge output through unchanged.
|
merge output through unchanged.
|
||||||
|
|
||||||
@@ -76,7 +78,8 @@ Each spell cast contains:
|
|||||||
- `spell`: spell name;
|
- `spell`: spell name;
|
||||||
- `effect`: concise spell effect in the scene;
|
- `effect`: concise spell effect in the scene;
|
||||||
- `narrative_description`: short description of the spell cast in context;
|
- `narrative_description`: short description of the spell cast in context;
|
||||||
- `source_refs`: transcript source references supplied by the model.
|
- `source_refs`: transcript source references with extractor-assigned source
|
||||||
|
IDs and model-supplied unit ranges.
|
||||||
|
|
||||||
`caster` is the in-world caster, not the transcript speaker.
|
`caster` is the in-world caster, not the transcript speaker.
|
||||||
|
|
||||||
@@ -88,8 +91,10 @@ Each source reference uses the generic source-reference shape:
|
|||||||
- `start_unit_id`
|
- `start_unit_id`
|
||||||
- `end_unit_id`
|
- `end_unit_id`
|
||||||
|
|
||||||
The extractor prompt and schema use integer `start_unit_id` and `end_unit_id`
|
The LLM-facing prompt schema asks only for integer `start_unit_id` and
|
||||||
values matching source-unit IDs.
|
`end_unit_id` values matching source-unit IDs. `source_id` is assigned by the
|
||||||
|
extractor from the source document ID before validation and output, and is
|
||||||
|
required in this durable output contract.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -32,5 +32,5 @@ messages:
|
|||||||
output:
|
output:
|
||||||
format: json
|
format: json
|
||||||
validation_mode: json_schema
|
validation_mode: json_schema
|
||||||
schema_path: dnd_spells.v1.json
|
schema_path: dnd_spells_llm.v1.json
|
||||||
repair_attempts: 0
|
repair_attempts: 0
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
Source references must use integer source-unit IDs from the transcript.
|
Source references must use integer source-unit IDs from the transcript. Provide
|
||||||
|
start_unit_id and end_unit_id for each source reference; the extractor assigns
|
||||||
|
source_id automatically.
|
||||||
|
|
||||||
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.
|
||||||
source_id, start_unit_id, and end_unit_id.
|
|
||||||
|
|
||||||
Use player, party, and glossary reference material only to clarify source text.
|
Use player, party, and glossary reference material only to clarify source text.
|
||||||
Do not return spells, casters, or effects that are mentioned only in reference
|
Do not return spells, casters, or effects that are mentioned only in reference
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "notarius.dnd.spells.llm",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["spell_casts"],
|
||||||
|
"properties": {
|
||||||
|
"spell_casts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"caster",
|
||||||
|
"spell",
|
||||||
|
"effect",
|
||||||
|
"narrative_description",
|
||||||
|
"source_refs"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"caster": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"spell": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"narrative_description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"source_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
|
"properties": {
|
||||||
|
"start_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"end_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
internal/modules/extract/dnd/spells/canonicalize.go
Normal file
92
internal/modules/extract/dnd/spells/canonicalize.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package spells
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func canonicalizeResponse(response *extractionResponse, sourceID string) {
|
||||||
|
if response == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index := range response.SpellCasts {
|
||||||
|
canonicalizeSpellCast(&response.SpellCasts[index], sourceID)
|
||||||
|
}
|
||||||
|
sort.SliceStable(response.SpellCasts, func(i, j int) bool {
|
||||||
|
left, leftOK := earliestSourceUnit(response.SpellCasts[i])
|
||||||
|
right, rightOK := earliestSourceUnit(response.SpellCasts[j])
|
||||||
|
if leftOK != rightOK {
|
||||||
|
return leftOK
|
||||||
|
}
|
||||||
|
if !leftOK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return left < right
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeSpellCast(spell *spellCastResponse, sourceID string) {
|
||||||
|
for index := range spell.SourceRefs {
|
||||||
|
spell.SourceRefs[index].SourceID = sourceID
|
||||||
|
spell.SourceRefs[index].StartUnitID = canonicalUnitRef(spell.SourceRefs[index].StartUnitID)
|
||||||
|
spell.SourceRefs[index].EndUnitID = canonicalUnitRef(spell.SourceRefs[index].EndUnitID)
|
||||||
|
}
|
||||||
|
sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
|
||||||
|
left := spell.SourceRefs[i]
|
||||||
|
right := spell.SourceRefs[j]
|
||||||
|
if left.StartUnitID.Int() != right.StartUnitID.Int() {
|
||||||
|
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
||||||
|
}
|
||||||
|
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
||||||
|
})
|
||||||
|
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalUnitRef(ref dnd.UnitRef) dnd.UnitRef {
|
||||||
|
value := ref.Int()
|
||||||
|
if value <= 0 {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
return dnd.UnitRefFromInt(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeSourceRefs(refs []dnd.SourceRefResponse) []dnd.SourceRefResponse {
|
||||||
|
if len(refs) < 2 {
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
out := refs[:0]
|
||||||
|
var previous dnd.SourceRefResponse
|
||||||
|
for index, ref := range refs {
|
||||||
|
if index > 0 && sameSourceRef(previous, ref) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ref)
|
||||||
|
previous = ref
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameSourceRef(left dnd.SourceRefResponse, right dnd.SourceRefResponse) bool {
|
||||||
|
return left.SourceID == right.SourceID &&
|
||||||
|
left.StartUnitID.Int() == right.StartUnitID.Int() &&
|
||||||
|
left.EndUnitID.Int() == right.EndUnitID.Int()
|
||||||
|
}
|
||||||
|
|
||||||
|
func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
||||||
|
for _, ref := range spell.SourceRefs {
|
||||||
|
start := ref.StartUnitID.Int()
|
||||||
|
if start > 0 {
|
||||||
|
return start, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func unitSortValue(ref dnd.UnitRef) int {
|
||||||
|
value := ref.Int()
|
||||||
|
if value <= 0 {
|
||||||
|
return int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
@@ -96,24 +95,20 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
completion, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: Key,
|
StageName: Key,
|
||||||
PromptID: PromptID,
|
PromptID: PromptID,
|
||||||
PromptVersion: SchemaVersion,
|
PromptVersion: SchemaVersion,
|
||||||
ProfileID: req.LLMProfile,
|
ProfileID: req.LLMProfile,
|
||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
Inputs: dnd.PromptInputs(sourceInput, req.References),
|
Inputs: dnd.PromptInputs(sourceInput, req.References),
|
||||||
}, &response)
|
}, &response); err != nil {
|
||||||
if err != nil {
|
|
||||||
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
content := append([]byte(nil), completion.Content...)
|
canonicalizeResponse(&response, req.Source.ID)
|
||||||
if len(strings.TrimSpace(string(content))) == 0 {
|
content, err := json.Marshal(response)
|
||||||
var err error
|
if err != nil {
|
||||||
content, err = json.Marshal(response)
|
return contracts.ExtractionResult{}, extractorErrorf("marshal canonical output: %w", err)
|
||||||
if err != nil {
|
|
||||||
return contracts.ExtractionResult{}, extractorErrorf("marshal raw output: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
schema, err := loadResponseSchema()
|
schema, err := loadResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractReturnsRawOutputFromStructuredResponse(t *testing.T) {
|
func TestExtractReturnsCanonicalOutputFromStructuredResponse(t *testing.T) {
|
||||||
client := &fakeSpellsLLMClient{
|
client := &fakeSpellsLLMClient{
|
||||||
response: extractionResponse{
|
response: extractionResponse{
|
||||||
SpellCasts: []spellCastResponse{
|
SpellCasts: []spellCastResponse{
|
||||||
@@ -20,7 +20,7 @@ func TestExtractReturnsRawOutputFromStructuredResponse(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: responseSourceRefsInt("session-alpha", 1, 2),
|
SourceRefs: responseSourceRefsInt("transcript", 1, 2),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,8 +63,8 @@ func TestExtractReturnsRawOutputFromStructuredResponse(t *testing.T) {
|
|||||||
if !json.Valid(result.Output.Schema.JSONSchema) {
|
if !json.Valid(result.Output.Schema.JSONSchema) {
|
||||||
t.Fatalf("schema JSON is invalid or missing: %s", result.Output.Schema.JSONSchema)
|
t.Fatalf("schema JSON is invalid or missing: %s", result.Output.Schema.JSONSchema)
|
||||||
}
|
}
|
||||||
if got := string(result.Output.Payload.Content); got != string(client.content) {
|
if strings.Contains(string(result.Output.Payload.Content), "raw_marker") {
|
||||||
t.Fatalf("content = %q, want exact raw completion content", got)
|
t.Fatalf("content = %q, want canonical payload without raw completion marker", result.Output.Payload.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload extractionResponse
|
var payload extractionResponse
|
||||||
@@ -72,7 +72,10 @@ func TestExtractReturnsRawOutputFromStructuredResponse(t *testing.T) {
|
|||||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if len(payload.SpellCasts) != 1 || payload.SpellCasts[0].Spell != " Cure Wounds " {
|
if len(payload.SpellCasts) != 1 || payload.SpellCasts[0].Spell != " Cure Wounds " {
|
||||||
t.Fatalf("payload = %#v, want raw structured response", payload)
|
t.Fatalf("payload = %#v, want structured response fields", payload)
|
||||||
|
}
|
||||||
|
if got := payload.SpellCasts[0].SourceRefs[0].SourceID; got != "session-alpha" {
|
||||||
|
t.Fatalf("source_id = %q, want canonical source document ID", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +190,7 @@ func TestExtractReturnsRawOutputForEmptyResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractCarriesMalformedStructuredContentAsRawOutput(t *testing.T) {
|
func TestExtractReturnsCanonicalOutputForMalformedStructuredResponse(t *testing.T) {
|
||||||
client := &fakeSpellsLLMClient{response: extractionResponse{}}
|
client := &fakeSpellsLLMClient{response: extractionResponse{}}
|
||||||
|
|
||||||
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||||
@@ -195,7 +198,7 @@ func TestExtractCarriesMalformedStructuredContentAsRawOutput(t *testing.T) {
|
|||||||
t.Fatalf("Extract() error = %v, want nil", err)
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if string(result.Output.Payload.Content) != `{"spell_casts":null}` {
|
if string(result.Output.Payload.Content) != `{"spell_casts":null}` {
|
||||||
t.Fatalf("content = %s, want raw structured output", result.Output.Payload.Content)
|
t.Fatalf("content = %s, want canonical structured output", result.Output.Payload.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +250,17 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractPreservesResponseOrder(t *testing.T) {
|
func TestExtractOrdersSpellCastsByEarliestSourceUnit(t *testing.T) {
|
||||||
client := &fakeSpellsLLMClient{
|
client := &fakeSpellsLLMClient{
|
||||||
response: extractionResponse{
|
response: extractionResponse{
|
||||||
SpellCasts: []spellCastResponse{
|
SpellCasts: []spellCastResponse{
|
||||||
|
{
|
||||||
|
Caster: "Bandit Shaman",
|
||||||
|
Spell: "Fire Bolt",
|
||||||
|
Effect: "Burns.",
|
||||||
|
NarrativeDescription: "Second spell.",
|
||||||
|
SourceRefs: responseSourceRefs("session-alpha", 2, 2),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Caster: "Aria",
|
Caster: "Aria",
|
||||||
Spell: "Cure Wounds",
|
Spell: "Cure Wounds",
|
||||||
@@ -259,11 +269,10 @@ func TestExtractPreservesResponseOrder(t *testing.T) {
|
|||||||
SourceRefs: responseSourceRefs("session-alpha", 1, 1),
|
SourceRefs: responseSourceRefs("session-alpha", 1, 1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Caster: "Bandit Shaman",
|
Caster: "Narrator",
|
||||||
Spell: "Fire Bolt",
|
Spell: "Unknown Spell",
|
||||||
Effect: "Burns.",
|
Effect: "No cited range.",
|
||||||
NarrativeDescription: "Second spell.",
|
NarrativeDescription: "This should sort after cited spell casts.",
|
||||||
SourceRefs: responseSourceRefs("session-alpha", 2, 2),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -277,8 +286,84 @@ func TestExtractPreservesResponseOrder(t *testing.T) {
|
|||||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if len(payload.SpellCasts) != 2 || payload.SpellCasts[0].Spell != "Cure Wounds" || payload.SpellCasts[1].Spell != "Fire Bolt" {
|
if len(payload.SpellCasts) != 3 ||
|
||||||
t.Fatalf("spell order = %#v, want response order", payload.SpellCasts)
|
payload.SpellCasts[0].Spell != "Cure Wounds" ||
|
||||||
|
payload.SpellCasts[1].Spell != "Fire Bolt" ||
|
||||||
|
payload.SpellCasts[2].Spell != "Unknown Spell" {
|
||||||
|
t.Fatalf("spell order = %#v, want earliest source-unit order with uncited spell last", payload.SpellCasts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractCanonicalizesSourceRefs(t *testing.T) {
|
||||||
|
client := &fakeSpellsLLMClient{
|
||||||
|
response: extractionResponse{
|
||||||
|
SpellCasts: []spellCastResponse{
|
||||||
|
{
|
||||||
|
Caster: "Aria",
|
||||||
|
Spell: "Cure Wounds",
|
||||||
|
Effect: "Heals.",
|
||||||
|
NarrativeDescription: "Aria heals.",
|
||||||
|
SourceRefs: []dnd.SourceRefResponse{
|
||||||
|
{SourceID: "gameplay_transcript", StartUnitID: dnd.UnitRefFromInt(2), EndUnitID: dnd.UnitRefFromInt(2)},
|
||||||
|
{SourceID: "", StartUnitID: dnd.UnitRefFromInt(1), EndUnitID: dnd.UnitRefFromInt(2)},
|
||||||
|
{SourceID: "transcript", StartUnitID: dnd.UnitRefFromInt(1), EndUnitID: dnd.UnitRefFromInt(2)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
var payload extractionResponse
|
||||||
|
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
refs := payload.SpellCasts[0].SourceRefs
|
||||||
|
if len(refs) != 2 {
|
||||||
|
t.Fatalf("source refs = %#v, want duplicate collapsed", refs)
|
||||||
|
}
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.SourceID != "session-alpha" {
|
||||||
|
t.Fatalf("source ref = %#v, want canonical source_id", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if refs[0].StartUnitID.Int() != 1 || refs[0].EndUnitID.Int() != 2 ||
|
||||||
|
refs[1].StartUnitID.Int() != 2 || refs[1].EndUnitID.Int() != 2 {
|
||||||
|
t.Fatalf("source refs = %#v, want sorted unit ranges", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPreservesInvalidSourceRefsForValidators(t *testing.T) {
|
||||||
|
client := &fakeSpellsLLMClient{
|
||||||
|
response: extractionResponse{
|
||||||
|
SpellCasts: []spellCastResponse{
|
||||||
|
{
|
||||||
|
Caster: "Aria",
|
||||||
|
Spell: "Cure Wounds",
|
||||||
|
Effect: "Heals.",
|
||||||
|
NarrativeDescription: "Aria heals.",
|
||||||
|
SourceRefs: []dnd.SourceRefResponse{
|
||||||
|
{SourceID: "transcript", StartUnitID: dnd.UnitRefFromInt(99), EndUnitID: dnd.UnitRefFromString("missing")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
var payload map[string][]map[string]any
|
||||||
|
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||||
|
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
ref := payload["spell_casts"][0]["source_refs"].([]any)[0].(map[string]any)
|
||||||
|
if ref["source_id"] != "session-alpha" || ref["start_unit_id"] != float64(99) || ref["end_unit_id"] != "" {
|
||||||
|
t.Fatalf("source ref = %#v, want source_id canonicalized without unit repair", ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
first, second := response.SpellCasts[0], response.SpellCasts[1]
|
first, second := response.SpellCasts[0], response.SpellCasts[1]
|
||||||
if first.Spell != "Cure Wounds" || second.Spell != "Fire Bolt" {
|
if first.Spell != "Cure Wounds" || second.Spell != "Fire Bolt" {
|
||||||
t.Fatalf("spell order = %q, %q; want response order", first.Spell, second.Spell)
|
t.Fatalf("spell order = %q, %q; want source-unit order", first.Spell, second.Spell)
|
||||||
}
|
}
|
||||||
if first.Caster != "Aria" || second.Caster != "Borin" {
|
if first.Caster != "Aria" || second.Caster != "Borin" {
|
||||||
t.Fatalf("casters = %q, %q; want spell data", first.Caster, second.Caster)
|
t.Fatalf("casters = %q, %q; want spell data", first.Caster, second.Caster)
|
||||||
@@ -296,7 +296,7 @@ func TestRunnerCarriesMalformedDNDSpellsExtractorOutput(t *testing.T) {
|
|||||||
t.Fatalf("len(NormalizeOutputs) = %d, want raw output", len(output.NormalizeOutputs))
|
t.Fatalf("len(NormalizeOutputs) = %d, want raw output", len(output.NormalizeOutputs))
|
||||||
}
|
}
|
||||||
if string(output.NormalizeOutputs[0].Payload.Content) != `{"spell_casts":null}` {
|
if string(output.NormalizeOutputs[0].Payload.Content) != `{"spell_casts":null}` {
|
||||||
t.Fatalf("content = %s, want raw structured output", output.NormalizeOutputs[0].Payload.Content)
|
t.Fatalf("content = %s, want canonical structured output", output.NormalizeOutputs[0].Payload.Content)
|
||||||
}
|
}
|
||||||
if output.Manifest.ValidationStatus != "approved" {
|
if output.Manifest.ValidationStatus != "approved" {
|
||||||
t.Fatalf("ValidationStatus = %q, want approved", output.Manifest.ValidationStatus)
|
t.Fatalf("ValidationStatus = %q, want approved", output.Manifest.ValidationStatus)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func TestLoadResponseSchemaForSpells(t *testing.T) {
|
|||||||
properties := decoded["properties"].(map[string]any)
|
properties := decoded["properties"].(map[string]any)
|
||||||
spellCastProperties := properties["spell_casts"].(map[string]any)["items"].(map[string]any)["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)
|
sourceRefProperties := spellCastProperties["source_refs"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
|
||||||
|
sourceRefRequired := spellCastProperties["source_refs"].(map[string]any)["items"].(map[string]any)["required"].([]any)
|
||||||
|
if !containsJSONField(sourceRefRequired, "source_id") {
|
||||||
|
t.Fatalf("canonical source refs required = %#v, want source_id", sourceRefRequired)
|
||||||
|
}
|
||||||
for _, field := range []string{"start_unit_id", "end_unit_id"} {
|
for _, field := range []string{"start_unit_id", "end_unit_id"} {
|
||||||
property := sourceRefProperties[field].(map[string]any)
|
property := sourceRefProperties[field].(map[string]any)
|
||||||
if property["type"] != "integer" {
|
if property["type"] != "integer" {
|
||||||
@@ -48,6 +52,32 @@ func TestLoadResponseSchemaForSpells(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLLMResponseSchemaOmitsSourceID(t *testing.T) {
|
||||||
|
raw, err := embeddedAssets.ReadFile("assets/schemas/dnd_spells_llm.v1.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(LLM schema) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
t.Fatalf("LLM schema is invalid JSON: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("Unmarshal(LLM schema) 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)
|
||||||
|
sourceRefItems := spellCastProperties["source_refs"].(map[string]any)["items"].(map[string]any)
|
||||||
|
sourceRefProperties := sourceRefItems["properties"].(map[string]any)
|
||||||
|
sourceRefRequired := sourceRefItems["required"].([]any)
|
||||||
|
if _, ok := sourceRefProperties["source_id"]; ok {
|
||||||
|
t.Fatalf("LLM source ref schema contains source_id property: %#v", sourceRefProperties)
|
||||||
|
}
|
||||||
|
if containsJSONField(sourceRefRequired, "source_id") {
|
||||||
|
t.Fatalf("LLM source refs required = %#v, want no source_id", sourceRefRequired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
||||||
first, err := loadResponseSchema()
|
first, err := loadResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,3 +119,12 @@ func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) {
|
|||||||
t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics)
|
t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsJSONField(fields []any, want string) bool {
|
||||||
|
for _, field := range fields {
|
||||||
|
if field == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
|
|||||||
if prepared.PromptID != PromptID {
|
if prepared.PromptID != PromptID {
|
||||||
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
|
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
|
||||||
}
|
}
|
||||||
|
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
|
||||||
|
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
|
||||||
|
}
|
||||||
if got := len(prepared.Messages); got != 5 {
|
if got := len(prepared.Messages); got != 5 {
|
||||||
t.Fatalf("message count = %d, want 5", got)
|
t.Fatalf("message count = %d, want 5", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ func (ref UnitRef) String() string {
|
|||||||
return strconv.Itoa(ref.value)
|
return strconv.Itoa(ref.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ref UnitRef) Int() int {
|
||||||
|
return ref.value
|
||||||
|
}
|
||||||
|
|
||||||
func (ref *UnitRef) UnmarshalJSON(raw []byte) error {
|
func (ref *UnitRef) UnmarshalJSON(raw []byte) error {
|
||||||
raw = bytes.TrimSpace(raw)
|
raw = bytes.TrimSpace(raw)
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ func TestUnitRefUnmarshalAcceptsIntegerAndNumericString(t *testing.T) {
|
|||||||
if got := integerRef.String(); got != "12" {
|
if got := integerRef.String(); got != "12" {
|
||||||
t.Fatalf("integer ref = %q, want 12", got)
|
t.Fatalf("integer ref = %q, want 12", got)
|
||||||
}
|
}
|
||||||
|
if got := integerRef.Int(); got != 12 {
|
||||||
|
t.Fatalf("integer ref value = %d, want 12", got)
|
||||||
|
}
|
||||||
|
|
||||||
var stringRef UnitRef
|
var stringRef UnitRef
|
||||||
if err := json.Unmarshal([]byte(`"12"`), &stringRef); err != nil {
|
if err := json.Unmarshal([]byte(`"12"`), &stringRef); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user