Minimize D&D spell extraction contracts
This commit is contained in:
@@ -13,8 +13,6 @@
|
||||
"required": [
|
||||
"caster",
|
||||
"spell",
|
||||
"effect",
|
||||
"narrative_description",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
@@ -26,14 +24,6 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"narrative_description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
||||
@@ -99,12 +99,6 @@ func validate(value dnd.SpellList) error {
|
||||
if strings.TrimSpace(spell.Spell) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].spell must not be empty", index)
|
||||
}
|
||||
if strings.TrimSpace(spell.Effect) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].effect must not be empty", index)
|
||||
}
|
||||
if strings.TrimSpace(spell.NarrativeDescription) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].narrative_description must not be empty", index)
|
||||
}
|
||||
if len(spell.SourceRefs) == 0 {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||
t.Fatalf("Decode() error = %v, want nil", err)
|
||||
}
|
||||
want := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||
{Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals an injured ally.", NarrativeDescription: "Aria restores the fighter after the fight.", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
|
||||
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Scorches the wight.", NarrativeDescription: "Borin hurls fire at the wight.", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
|
||||
{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
|
||||
{Caster: "Borin", Spell: "Fire Bolt", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
|
||||
}}
|
||||
if !reflect.DeepEqual(value, want) {
|
||||
t.Fatalf("Decode() = %#v, want %#v", value, want)
|
||||
@@ -82,9 +82,10 @@ func TestCodecStrictlyRejectsInvalidRepresentations(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "unknown", raw: `{"spell_casts":[],"unexpected":true}`, want: "unknown field"},
|
||||
{name: "unknown record field", raw: `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[],"unexpected":true}]}`, want: "unknown field"},
|
||||
{name: "trailing", raw: `{"spell_casts":[]} {}`, want: "multiple JSON values"},
|
||||
{name: "missing", raw: `{}`, want: "spell_casts must be present"},
|
||||
{name: "invalid evidence", raw: `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals","narrative_description":"Aria heals","source_refs":[{"source_id":"session","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"},
|
||||
{name: "invalid evidence", raw: `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"source_id":"session","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "Heals an injured ally.",
|
||||
"narrative_description": "Aria restores the fighter after the fight.",
|
||||
"source_refs": [
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
@@ -16,8 +14,6 @@
|
||||
{
|
||||
"caster": "Borin",
|
||||
"spell": "Fire Bolt",
|
||||
"effect": "Scorches the wight.",
|
||||
"narrative_description": "Borin hurls fire at the wight.",
|
||||
"source_refs": [
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
|
||||
@@ -33,8 +33,6 @@ messages:
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-npcs.md
|
||||
cache_control:
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
For each spell cast, source references must collectively support the caster,
|
||||
spell, effect, and narrative_description. If a detail is not supported by the
|
||||
cited transcript units, omit that detail or describe only the supported attempt
|
||||
or declaration.
|
||||
For each spell cast, source references must collectively support the in-world
|
||||
caster, spell name, and the fact that the cast or declared attempt occurred.
|
||||
|
||||
For spells, do not follow summoned creatures, persistent effects, or other
|
||||
downstream consequences through the rest of the scene.
|
||||
|
||||
Return only D&D spell-cast artifacts. For each spell cast, identify the
|
||||
in-world caster, spell name, effect, narrative description, and source
|
||||
references.
|
||||
Return only D&D spell-cast artifacts. For each record, identify the in-world
|
||||
caster, canonical spell name, and source references.
|
||||
|
||||
Use the player and party references together with transcript context to map
|
||||
first-person player speech to the associated player character and use the
|
||||
@@ -20,10 +14,3 @@ transcript; do not invent a name.
|
||||
Use the canonical spell-name catalog to select spell names. Do not return a
|
||||
spell name absent from that catalog, even when it is suggested by general D&D
|
||||
knowledge or reference material.
|
||||
|
||||
Effects and narrative descriptions are session records, not rules summaries.
|
||||
Report only mechanics, explanations, and outcomes established by the cited
|
||||
transcript units. Preserve the table's observed resolution without silently
|
||||
correcting it from general D&D knowledge. If the transcript gives a possibly
|
||||
nonstandard rationale, use wording such as "the GM rules" or "the table
|
||||
resolves" rather than asserting that rationale as a universal rule.
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
Extract Dungeons & Dragons spell-cast artifacts from the provided transcript.
|
||||
|
||||
Do not infer a spell cast from general D&D knowledge or from table chatter that
|
||||
does not identify a spell being cast.
|
||||
|
||||
Describe the session as it was played and adjudicated. The transcript is
|
||||
authoritative for what happened in this session, even when a table ruling may
|
||||
differ from published D&D rules. Do not correct the transcript or fill in
|
||||
unstated mechanics from general D&D knowledge. When a ruling or mechanical
|
||||
explanation matters, attribute it to the GM or table instead of presenting it
|
||||
as a universal game rule.
|
||||
Include an actual casting event or an unambiguous declared casting attempt.
|
||||
Exclude spell mentions, hypothetical plans, rules discussion, and catalog
|
||||
matches that do not establish a casting event in the transcript.
|
||||
|
||||
Use the provided canonical spell-name catalog when naming each extracted spell.
|
||||
Return the canonical catalog spelling exactly. The catalog is a recognition
|
||||
aid; it does not establish that a spell was cast or how the spell works.
|
||||
aid and never evidence that a spell was cast.
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
"required": [
|
||||
"caster",
|
||||
"spell",
|
||||
"effect",
|
||||
"narrative_description",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
@@ -26,17 +24,9 @@
|
||||
"type": "string",
|
||||
"description": "Canonical spell name from the provided spell-name catalog."
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"description": "Concise immediate effect or resolution established by the cited transcript units; do not infer mechanics from general D&D rules knowledge or follow persistent downstream consequences."
|
||||
},
|
||||
"narrative_description": {
|
||||
"type": "string",
|
||||
"description": "Short session-grounded description of the casting declaration and immediate resolution, containing only details established by the cited transcript units."
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"description": "Transcript ranges offered as evidence for factual claims about the casting declaration and immediate resolution in this spell-cast object.",
|
||||
"description": "Transcript ranges offered as evidence for the caster, spell name, and casting event in this spell-cast object.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -88,11 +88,9 @@ func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellL
|
||||
}
|
||||
}
|
||||
spellCasts[index] = dnd.SpellCast{
|
||||
Caster: spell.Caster,
|
||||
Spell: spell.Spell,
|
||||
Effect: spell.Effect,
|
||||
NarrativeDescription: spell.NarrativeDescription,
|
||||
SourceRefs: refs,
|
||||
Caster: spell.Caster,
|
||||
Spell: spell.Spell,
|
||||
SourceRefs: refs,
|
||||
}
|
||||
}
|
||||
if response.SpellCasts == nil {
|
||||
|
||||
@@ -19,11 +19,9 @@ import (
|
||||
func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: " Aria ",
|
||||
Spell: " Cure Wounds ",
|
||||
Effect: " Heals an injured ally. ",
|
||||
NarrativeDescription: " Aria restores the fighter after the fight. ",
|
||||
SourceRefs: responseSourceRefs(1, 2),
|
||||
Caster: " Aria ",
|
||||
Spell: " Cure Wounds ",
|
||||
SourceRefs: responseSourceRefs(1, 2),
|
||||
},
|
||||
}}}
|
||||
req := extractionRequest()
|
||||
@@ -34,11 +32,9 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
|
||||
}
|
||||
want := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||
{
|
||||
Caster: " Aria ",
|
||||
Spell: " Cure Wounds ",
|
||||
Effect: " Heals an injured ally. ",
|
||||
NarrativeDescription: " Aria restores the fighter after the fight. ",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||
Caster: " Aria ",
|
||||
Spell: " Cure Wounds ",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||
},
|
||||
}}
|
||||
if !reflect.DeepEqual(result.Value, want) {
|
||||
@@ -268,9 +264,9 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
|
||||
|
||||
func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
||||
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Burns.", NarrativeDescription: "Second.", SourceRefs: responseSourceRefs(2, 2)},
|
||||
{Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "First.", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}},
|
||||
{Caster: "Narrator", Spell: "Unknown", Effect: "Unknown.", NarrativeDescription: "Uncited."},
|
||||
{Caster: "Borin", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
|
||||
{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}},
|
||||
{Caster: "Narrator", Spell: "Unknown"},
|
||||
}}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
@@ -286,7 +282,7 @@ func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
||||
|
||||
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
||||
Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "Aria heals.",
|
||||
Caster: "Aria", Spell: "Cure Wounds",
|
||||
SourceRefs: []spellSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
}}}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
@@ -300,13 +296,13 @@ func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{content: []byte(`{"spell_casts":[{"caster":"","spell":"Cure Wounds","effect":"","narrative_description":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
|
||||
client := &fakeSpellsLLMClient{content: []byte(`{"spell_casts":[{"caster":"","spell":"Cure Wounds","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
spell := result.Value.SpellCasts[0]
|
||||
if spell.Caster != "" || spell.Effect != "" || spell.NarrativeDescription != "" {
|
||||
if spell.Caster != "" {
|
||||
t.Fatalf("spell = %#v, want blank semantic values preserved", spell)
|
||||
}
|
||||
if refs := spell.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {
|
||||
|
||||
@@ -5,11 +5,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 []spellSourceRefResponse `json:"source_refs"`
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
SourceRefs []spellSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type spellSourceRefResponse struct {
|
||||
|
||||
@@ -37,14 +37,14 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
|
||||
{
|
||||
name: "semantic blanks and empty evidence",
|
||||
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||
"caster": "", "spell": "", "effect": "", "narrative_description": "", "source_refs": []any{},
|
||||
"caster": "", "spell": "", "source_refs": []any{},
|
||||
}}},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "nonpositive unit candidates",
|
||||
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||
"caster": "Aria", "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.",
|
||||
"caster": "Aria", "spell": "Cure Wounds",
|
||||
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
|
||||
}}},
|
||||
valid: true,
|
||||
@@ -52,25 +52,25 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
|
||||
{
|
||||
name: "missing required field",
|
||||
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||
"spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.", "source_refs": []any{},
|
||||
"spell": "Cure Wounds", "source_refs": []any{},
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||
"caster": "Aria", "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.", "source_refs": []any{}, "id": "assigned later",
|
||||
"caster": "Aria", "spell": "Cure Wounds", "source_refs": []any{}, "id": "assigned later",
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "wrong field type",
|
||||
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||
"caster": 7, "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.", "source_refs": []any{},
|
||||
"caster": 7, "spell": "Cure Wounds", "source_refs": []any{},
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "noninteger source identifier",
|
||||
response: map[string]any{"spell_casts": []any{map[string]any{
|
||||
"caster": "Aria", "spell": "Cure Wounds", "effect": "Heals.", "narrative_description": "Aria heals.",
|
||||
"caster": "Aria", "spell": "Cure Wounds",
|
||||
"source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}},
|
||||
}}},
|
||||
},
|
||||
@@ -131,10 +131,8 @@ func validSpellsResponse() map[string]any {
|
||||
return map[string]any{
|
||||
"spell_casts": []any{
|
||||
map[string]any{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "The wounds close.",
|
||||
"narrative_description": "Aria casts Cure Wounds.",
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"source_refs": []any{
|
||||
map[string]any{"start_unit_id": 1, "end_unit_id": 2},
|
||||
},
|
||||
|
||||
@@ -25,7 +25,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -266,12 +266,12 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
|
||||
firstEvidence := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2}
|
||||
secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4}
|
||||
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||
{Caster: " Aria \t", Spell: " cure wounds ", Effect: "first effect", NarrativeDescription: "first narrative", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}},
|
||||
{Caster: "Borin", Spell: "Healing Word", Effect: "distinct effect", NarrativeDescription: "distinct narrative", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " Kyle ", Spell: "Cure Wounds", Effect: "kept effect", NarrativeDescription: "kept narrative", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: "aria", Spell: " cure wounds ", Effect: "removed effect", NarrativeDescription: "removed narrative", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}},
|
||||
{Caster: "KYLE", Spell: " cure wounds ", Effect: "removed effect two", NarrativeDescription: "removed narrative two", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " kyle ", Spell: "Cure Wounds", Effect: "removed effect three", NarrativeDescription: "removed narrative three", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}},
|
||||
{Caster: "Borin", Spell: "Healing Word", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " Kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: "aria", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}},
|
||||
{Caster: "KYLE", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
}}
|
||||
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||
@@ -281,8 +281,8 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
|
||||
if len(result.Value.SpellCasts) != 3 {
|
||||
t.Fatalf("normalized casts = %#v, want first occurrences plus distinct cast", result.Value.SpellCasts)
|
||||
}
|
||||
if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" || got.Effect != "first effect" || got.NarrativeDescription != "first narrative" {
|
||||
t.Fatalf("retained first cast = %#v, want first occurrence fields unchanged", got)
|
||||
if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" {
|
||||
t.Fatalf("retained first cast = %#v, want first occurrence caster unchanged", got)
|
||||
}
|
||||
if got := result.Value.SpellCasts[0].SourceRefs; !reflect.DeepEqual(got, []source.SourceRef{firstEvidence, secondEvidence}) {
|
||||
t.Fatalf("retained first evidence = %#v, want canonical first evidence only", got)
|
||||
@@ -393,11 +393,9 @@ func TestNormalizeBoundsDuplicateWarningIndices(t *testing.T) {
|
||||
func TestNormalizeIsIdempotentForAlreadyNormalizedInput(t *testing.T) {
|
||||
doc := sourceDocument(3)
|
||||
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Spell: "Cure Wounds",
|
||||
Caster: "Aria",
|
||||
Effect: "effect",
|
||||
NarrativeDescription: "narrative",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
|
||||
Spell: "Cure Wounds",
|
||||
Caster: "Aria",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
|
||||
}}}
|
||||
normalizer := newNormalizer(t)
|
||||
first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
{
|
||||
"caster": " Aria ",
|
||||
"spell": " cure wounds ",
|
||||
"effect": "first effect",
|
||||
"narrative_description": "first narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2},
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
|
||||
@@ -18,8 +16,6 @@
|
||||
{
|
||||
"caster": "aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "duplicate effect",
|
||||
"narrative_description": "duplicate narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
@@ -28,8 +24,6 @@
|
||||
{
|
||||
"caster": "aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "distinct effect",
|
||||
"narrative_description": "distinct narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
]
|
||||
@@ -41,8 +35,6 @@
|
||||
{
|
||||
"caster": " Aria ",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "first effect",
|
||||
"narrative_description": "first narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
@@ -51,8 +43,6 @@
|
||||
{
|
||||
"caster": "aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "distinct effect",
|
||||
"narrative_description": "distinct narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
]
|
||||
@@ -72,8 +62,6 @@
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "heals an ally",
|
||||
"narrative_description": "Aria restores an ally's wounds.",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
|
||||
]
|
||||
@@ -85,8 +73,6 @@
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "heals an ally",
|
||||
"narrative_description": "Aria restores an ally's wounds.",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
|
||||
]
|
||||
|
||||
@@ -17,11 +17,9 @@ type SpellList struct {
|
||||
}
|
||||
|
||||
type SpellCast 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"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type NPCList struct {
|
||||
|
||||
@@ -201,11 +201,9 @@ func spellList(names ...string) dnd.SpellList {
|
||||
|
||||
func validCast(name string) dnd.SpellCast {
|
||||
return dnd.SpellCast{
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
Effect: "heals an ally",
|
||||
NarrativeDescription: "Aria restores Borin.",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,12 +48,6 @@ func Validate(value dnd.SpellList) error {
|
||||
if strings.TrimSpace(spell.Spell) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].spell must not be empty", index)
|
||||
}
|
||||
if strings.TrimSpace(spell.Effect) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].effect must not be empty", index)
|
||||
}
|
||||
if strings.TrimSpace(spell.NarrativeDescription) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].narrative_description must not be empty", index)
|
||||
}
|
||||
if len(spell.SourceRefs) == 0 {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
|
||||
}
|
||||
|
||||
@@ -34,17 +34,23 @@ func TestValidatorRejectsMissingSpellList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
|
||||
value := validSpellList()
|
||||
value.SpellCasts[0].Spell = ""
|
||||
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*dnd.SpellCast)
|
||||
}{
|
||||
{name: "blank caster", mutate: func(cast *dnd.SpellCast) { cast.Caster = " " }},
|
||||
{name: "blank spell", mutate: func(cast *dnd.SpellCast) { cast.Spell = "" }},
|
||||
{name: "empty evidence", mutate: func(cast *dnd.SpellCast) { cast.SourceRefs = nil }},
|
||||
}
|
||||
if result.Approved {
|
||||
t.Fatalf("Approved = true, want false")
|
||||
}
|
||||
if result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
value := validSpellList()
|
||||
test.mutate(&value.SpellCasts[0])
|
||||
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("Validate() = %#v, %v; want shape rejection", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,5 +75,5 @@ func requestWithValue(value dnd.SpellList) contracts.TypedValidationRequest[dnd.
|
||||
}
|
||||
|
||||
func validSpellList() dnd.SpellList {
|
||||
return dnd.SpellList{SpellCasts: []dnd.SpellCast{{Caster: "Aria", Spell: "Cure Wounds", Effect: "heals", NarrativeDescription: "Aria heals Borin.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||
return dnd.SpellList{SpellCasts: []dnd.SpellCast{{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
|
||||
func requestWithValue(doc *source.SourceDocument, ref source.SourceRef) contracts.TypedValidationRequest[dnd.SpellList] {
|
||||
return contracts.TypedValidationRequest[dnd.SpellList]{Source: doc, Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: "Aria", Spell: "Cure Wounds", Effect: "heals", NarrativeDescription: "Aria casts Cure Wounds.", SourceRefs: []source.SourceRef{ref},
|
||||
Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{ref},
|
||||
}}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) {
|
||||
|
||||
func TestValidatorMatchesCaseInsensitiveUnicodeMultiwordSpellAcrossCitations(t *testing.T) {
|
||||
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: "Aria", Spell: "Tasha's Hideous Laughter", Effect: "effect", NarrativeDescription: "description",
|
||||
Caster: "Aria", Spell: "Tasha's Hideous Laughter",
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
|
||||
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
|
||||
@@ -108,7 +108,7 @@ func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
|
||||
func requestWithSpell(doc *source.SourceDocument, name string, unitID int) contracts.TypedValidationRequest[dnd.SpellList] {
|
||||
return contracts.TypedValidationRequest[dnd.SpellList]{Source: doc, Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: "Aria", Spell: name, Effect: "effect", NarrativeDescription: "description",
|
||||
Caster: "Aria", Spell: name,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}},
|
||||
}}}}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T) {
|
||||
@@ -112,7 +111,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
||||
if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "Mira Thorn" {
|
||||
t.Fatalf("spell output = %#v, want one registry-grounded-context spell", spellValue)
|
||||
}
|
||||
assertSpellEvidence(t, spellValue.SpellCasts[0].SourceRefs)
|
||||
assertCurrentEvidence(t, spellValue.SpellCasts[0].SourceRefs)
|
||||
case "combat":
|
||||
decoded, decodeErr := combatcodec.New().Decode(serialized.Artifact.Content)
|
||||
if decodeErr != nil {
|
||||
@@ -127,15 +126,6 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
||||
assertCurrentEvidence(t, combatValue.CombatTurns[0].SourceRefs)
|
||||
}
|
||||
|
||||
func assertSpellEvidence(t *testing.T, references []shared.SourceRefResponse) {
|
||||
t.Helper()
|
||||
for _, reference := range references {
|
||||
if reference.SourceID != "npc-session" {
|
||||
t.Fatalf("spell evidence reference = %#v, want current source only", reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertCurrentEvidence(t *testing.T, references []source.SourceRef) {
|
||||
t.Helper()
|
||||
for _, reference := range references {
|
||||
@@ -184,9 +174,8 @@ func (client *groundedDNDLLMClient) CompleteStructured(ctx context.Context, requ
|
||||
}}
|
||||
case spells.PromptID:
|
||||
payload = map[string]any{"spell_casts": []any{map[string]any{
|
||||
"caster": "Mira Thorn", "spell": "Cure Wounds", "effect": "Restores an ally.",
|
||||
"narrative_description": "Mira Thorn restores an ally.",
|
||||
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
|
||||
"caster": "Mira Thorn", "spell": "Cure Wounds",
|
||||
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
|
||||
}}}
|
||||
case combatextract.PromptID:
|
||||
payload = map[string]any{"combat_turns": []any{map[string]any{
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
type extractionResponse struct {
|
||||
@@ -14,11 +13,14 @@ type extractionResponse struct {
|
||||
}
|
||||
|
||||
type spellCastResponse struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
SourceRefs []spellSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type spellSourceRefResponse struct {
|
||||
StartUnitID int `json:"start_unit_id"`
|
||||
EndUnitID int `json:"end_unit_id"`
|
||||
}
|
||||
|
||||
type fakeSpellsLLMClient struct {
|
||||
@@ -58,12 +60,11 @@ func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req con
|
||||
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||
}
|
||||
|
||||
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
|
||||
return []shared.SourceRefResponse{
|
||||
func responseSourceRefs(startUnitID int, endUnitID int) []spellSourceRefResponse {
|
||||
return []spellSourceRefResponse{
|
||||
{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
StartUnitID: startUnitID,
|
||||
EndUnitID: endUnitID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
@@ -36,18 +37,14 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
|
||||
response: extractionResponse{
|
||||
SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: "Aria",
|
||||
Spell: "Cure Wounds",
|
||||
Effect: "Heals an injured ally.",
|
||||
NarrativeDescription: "Aria restores the fighter after the fight.",
|
||||
SourceRefs: responseSourceRefs(expectedDoc.ID, 1, 1),
|
||||
Caster: "Aria",
|
||||
Spell: "Cure Wounds",
|
||||
SourceRefs: responseSourceRefs(1, 1),
|
||||
},
|
||||
{
|
||||
Caster: "Borin",
|
||||
Spell: "Fire Bolt",
|
||||
Effect: "Scorches the wight.",
|
||||
NarrativeDescription: "Borin hurls fire at the wight.",
|
||||
SourceRefs: responseSourceRefs(expectedDoc.ID, 3, 3),
|
||||
Caster: "Borin",
|
||||
Spell: "Fire Bolt",
|
||||
SourceRefs: responseSourceRefs(3, 3),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -119,7 +116,6 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
|
||||
|
||||
func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
|
||||
raw := readDNDSpellsFixture(t)
|
||||
expectedDoc := parseDNDSpellsFixture(t, raw)
|
||||
resolved := resolveDNDSpellsPipeline(t)
|
||||
resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = dndSpellsReferenceSet(
|
||||
"Aria: party cleric\nBorin: fighter",
|
||||
@@ -129,11 +125,9 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
|
||||
response: extractionResponse{
|
||||
SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: "Borin",
|
||||
Spell: "Fire Bolt",
|
||||
Effect: "Scorches the wight.",
|
||||
NarrativeDescription: "Borin hurls fire at the wight.",
|
||||
SourceRefs: responseSourceRefs(expectedDoc.ID, 3, 3),
|
||||
Caster: "Borin",
|
||||
Spell: "Fire Bolt",
|
||||
SourceRefs: responseSourceRefs(3, 3),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -195,25 +189,25 @@ func TestProductionSpellPipelineRoutesSemanticCandidatesToDeterministicValidator
|
||||
}{
|
||||
{
|
||||
name: "blank string",
|
||||
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"","narrative_description":"Aria casts the spell.","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
|
||||
response: []byte(`{"spell_casts":[{"caster":"","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
|
||||
reasonCode: spellshape.ReasonCode,
|
||||
validatorName: spellshape.Key,
|
||||
},
|
||||
{
|
||||
name: "empty evidence",
|
||||
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[]}]}`),
|
||||
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[]}]}`),
|
||||
reasonCode: spellshape.ReasonCode,
|
||||
validatorName: spellshape.Key,
|
||||
},
|
||||
{
|
||||
name: "nonpositive unit candidate",
|
||||
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
|
||||
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
|
||||
reasonCode: spellsourcerefs.ReasonCode,
|
||||
validatorName: spellsourcerefs.Key,
|
||||
},
|
||||
{
|
||||
name: "unknown unit candidate",
|
||||
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
|
||||
response: []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
|
||||
reasonCode: spellsourcerefs.ReasonCode,
|
||||
validatorName: spellsourcerefs.Key,
|
||||
},
|
||||
@@ -320,12 +314,12 @@ func parseDNDSpellsFixture(t *testing.T, raw []byte) *source.SourceDocument {
|
||||
return doc
|
||||
}
|
||||
|
||||
func decodeRunnerSpellResponse(t *testing.T, raw []byte) extractionResponse {
|
||||
func decodeRunnerSpellResponse(t *testing.T, raw []byte) dnd.SpellList {
|
||||
t.Helper()
|
||||
|
||||
var response extractionResponse
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatalf("Unmarshal(raw output) error = %v, want nil", err)
|
||||
response, err := spellcodec.New().Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decode durable spell output: %v", err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user