Minimize D&D spell extraction contracts

This commit is contained in:
2026-07-22 19:01:58 +00:00
parent a263a0840c
commit 2cbaf20e55
31 changed files with 169 additions and 283 deletions

View File

@@ -47,12 +47,10 @@ Each spell cast contains exactly these required fields:
- `caster`: in-world character or creature casting the spell;
- `spell`: spell name;
- `effect`: concise spell effect in the scene;
- `narrative_description`: short description of the spell cast in context;
- `source_refs`: transcript source references with extractor-assigned source
IDs and evidence unit ranges. It must contain at least one entry.
All four string fields must be non-empty. `caster` is the canonical in-world
Both string fields must be non-empty. `caster` is the canonical in-world
caster, not the human player, transcript speaker, or GM when the associated
character or creature can be identified. Player and party references may
disambiguate that identity, but do not independently establish that a cast
@@ -60,16 +58,10 @@ occurred. The `spell` value must resolve through the effective SRD-plus-overlay
catalog as either a canonical name or alias. Catalog validation accepts aliases
but does not rewrite them; unknown fields are rejected.
`effect` and `narrative_description` record the casting declaration and its
immediate resolution as established by the transcript. They do not follow
summoned creatures, persistent spell effects, or other downstream consequences
through the rest of the scene. They also do not correct the table from
published D&D rules or supplement the transcript with model knowledge. When the
transcript contains a nonstandard or disputed ruling, the artifact may preserve
the immediate observed resolution and attribute relevant reasoning to the GM or
table; it must not present that reasoning as a universal game rule. The spell
catalog is name-recognition policy, not evidence for spell mechanics or
outcomes.
The artifact includes an actual casting event or an unambiguous declared
casting attempt. Spell mentions, hypothetical plans, rules discussion, and
catalog matches without a casting event are excluded. The spell catalog is a
name-recognition policy and never evidence that a cast occurred.
## Source References
@@ -79,17 +71,11 @@ The unit IDs must be positive integers present in the input, and the start unit
must not appear after the end unit. Unknown fields are rejected.
For each cast, the complete `source_refs` collection identifies the transcript
evidence for every factual claim in `caster`, `spell`, `effect`, and
`narrative_description`. A cast declaration and its immediate resolution may be
cited with separate narrow ranges when intervening units are unrelated. A
reported target, roll, amount, condition, interruption, or immediate outcome
must be supported by the cited units; otherwise the artifact describes only the
supported attempt or declaration. Later behavior by summoned creatures,
recurring effects, and other downstream consequences are outside the cast
artifact's evidence scope. The deterministic validators establish that ranges
are structurally valid and that the spell name is related to cited text.
Semantic claim completeness is an extraction policy and remains subject to
evaluation rather than deterministic proof.
evidence for the caster, spell name, and occurrence of the cast or declared
attempt. The deterministic validators establish that ranges are structurally
valid and that the spell name is related to cited text. Semantic evidence
sufficiency is an extraction policy and remains subject to evaluation rather
than deterministic proof.
Reference slot keys and accepted file types are defined in
[Configuration](../config.md#implemented-production-modules). References are
@@ -103,8 +89,9 @@ normalized NPC artifact as `application/json`, up to 1 MiB. An external file is
validated during preparation; an ordered generated binding is validated at the
step handoff. Both paths use the approved NPC codec and identity policy,
re-encode canonical durable JSON for registry provenance, and supply only the
registry's ordered names as the operation-time spell prompt input. It helps the model prefer canonical
caster names; it does not establish that a spell was cast.
registry's ordered names as the operation-time spell prompt input. It helps the
model prefer canonical caster names; it does not establish that a spell was
cast.
NPC source references may identify the run that produced the registry or any
other session. They remain registry provenance and are never copied into a
@@ -130,10 +117,9 @@ does not synthesize references or change their boundaries.
After those per-cast changes, duplicate identity requires the same canonical
spell name, the same caster after case folding and whitespace normalization,
and the same complete, non-empty set of source references valid for the source
document. Only the first occurrence is retained, in stable order. Its caster,
effect, narrative description, and canonical references are preserved without
prose merging or source union. Unknown names, empty or invalid evidence, and
casts with different evidence remain separate.
document. Only the first occurrence is retained, in stable order. Its caster
and canonical references are preserved. Unknown names, empty or invalid
evidence, and casts with different evidence remain separate.
Mutation and duplicate decisions are returned through the normal warnings
surface. Warning scopes use the merged input index, such as `spell_casts[0]`,

View File

@@ -134,8 +134,8 @@ reusable content follows it.
Accordingly, the common prefix of all three extraction prompts is system,
extraction evidence, identity, and campaign references. The NPC prompt then
renders task, instructions, and transcript. Spell renders immediate resolution,
NPC registry, catalog, task, instructions, and transcript. Combat renders
renders task, instructions, and transcript. Spell renders the NPC registry,
catalog, task, instructions, and transcript. Combat renders
immediate resolution, NPC registry, task, instructions, and transcript. The
scene chunker is not an extraction lane: it retains its separate system,
transcript, campaign-reference, task, and instruction order and marks its

View File

@@ -207,16 +207,13 @@ failures therefore stop construction before source parsing or an LLM call;
campaign references remain separate disambiguation inputs and never become
source evidence.
The prompt limits each cast to its declaration and immediate resolution; it
does not follow summoned creatures, persistent effects, or other downstream
consequences through the scene. Shared extraction-evidence and identity rules
require transcript-supported factual claims and the most specific in-world
caster identity, while campaign references only disambiguate source text.
Effects describe the session as played: model rules knowledge cannot supplement
or correct the transcript, and nonstandard adjudication is attributed to the GM
or table rather than stated as a universal rule. Structural source validation
remains deterministic; semantic claim completeness is enforced through
extraction policy and evaluation.
The prompt includes only actual casting events and unambiguous declared casting
attempts. Spell mentions, plans, rules discussion, and catalog matches without
a casting event are excluded. Shared extraction-evidence and identity rules
require transcript-supported caster and spell facts, while the catalog,
campaign references, and NPC names only disambiguate source text. Structural
source validation remains deterministic; semantic evidence sufficiency is
enforced through extraction policy and evaluation.
Both the extractor and deterministic catalog validator expose
the effective base-plus-overlay semantic digest as scoped prepared-component
@@ -224,23 +221,23 @@ checkpoint identity. Raw overlay provenance independently covers file-byte
changes, while the semantic digest also invalidates reuse when the embedded
catalog or catalog composition changes. The extractor additionally fingerprints
its complete prompt assets and private response schema, so either semantic
contract changing invalidates previously recorded extraction checkpoints. The separate
`internal/modules/dnd/codec/spells` package
contract changing invalidates previously recorded extraction checkpoints. The
separate `internal/modules/dnd/codec/spells` package
owns the durable schema and stable JSON representation for artifact kind
`dnd/spell-list`. The runner keeps the result typed through validators and later
stages, using the codec only for checkpoint, debug, and output boundaries.
Shared D&D helpers keep prompt input
names and source-unit reference conversion consistent with the scene chunker.
Shared D&D helpers keep prompt input names and source-unit reference conversion
consistent with the scene chunker.
The extractor also declares the optional `npcs` registry slot and consumes the
immutable registry boundary from `internal/modules/dnd/npcs/registry`. An
external registry is prepared before execution; a generated registry is
validated and supplied at operation time. External bindings may add only
`npc_registry_digest` and `npc_count` to module metadata and an
`npc_registry` checkpoint fingerprint. Generated bindings are represented by
validated and supplied at operation time. Bound external registries add only
the full `npc_registry_digest` and `npc_count` to module metadata. The local
`npc_registry` checkpoint fingerprint always covers the names-only projection,
including its exact unbound value. Generated bindings are represented by
framework handoff provenance and dependency fingerprints. The unbound prompt
input is exactly `{"npcs":[]}`, has a projection fingerprint, and has no
registry provenance.
input is exactly `{"npcs":[]}` and has no registry provenance.
The shared NPC grounding fragment is placed immediately after the common
campaign reference message and is included in the spell prompt fingerprint.
@@ -321,9 +318,9 @@ scoped warnings for each mutation or unresolved name.
After those per-cast changes, it collapses only casts with the same canonical
spell, case-folded and whitespace-normalized caster, and complete non-empty
valid source-reference set. It retains the first occurrence and its caster,
effect, narrative description, and stable order. Unknown names, empty or
invalid evidence, and adjacent or overlapping but different ranges remain
unchanged for validation.
source references, and stable order. Unknown names, empty or invalid evidence,
and adjacent or overlapping but different ranges remain unchanged for
validation.
The normalizer exposes the effective catalog digest as its independently scoped
`effective_catalog` checkpoint fingerprint and reports catalog base ID, digest,
@@ -378,11 +375,11 @@ codec bytes according to its target context. Neither validator calls the LLM.
## D&D Spell Validators
All four validators receive `dnd.SpellList` directly. The shape validator
rejects missing or empty spell fields and empty reference lists. The catalog
validator defers when shape is invalid, then checks every non-empty spell name
against the immutable effective SRD and overlay catalog. It accepts normalized
canonical names and aliases without rewriting the artifact; unknown names
reject the complete result with bounded, stable index/name diagnostics. The
rejects a missing list, blank caster or spell names, and empty reference lists.
The catalog validator defers when shape is invalid, then checks every non-empty
spell name against the immutable effective SRD and overlay catalog. It accepts
normalized canonical names and aliases without rewriting the artifact; unknown
names reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator defers malformed shapes, validates every cited
range, and reports all range defects through a bounded aggregate while
preserving `invalid_source_refs`. The relatedness validator resolves all cited

View File

@@ -115,11 +115,13 @@ this package. Domain-neutral prompt filesystem composition lives in
The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
External references are strictly decoded and identity-validated during
preparation; generated references are decoded and identity-validated at the
ordered step handoff. Both paths emit canonical registry JSON to operation-time
spell and combat prompt or normalization requests. The framework records
generated identity and bounded producer provenance, while the raw external
reference remains independently tracked by pipeline provenance. An absent
registry is represented only by the empty prompt value `{"npcs":[]}`. Spell
ordered step handoff. Both paths retain canonical registry JSON for provenance
and emit a names-only projection to operation-time spell and combat prompts.
Combat normalization uses the canonical registry for exact actor lookup. The
framework records generated identity and bounded producer provenance, while
the raw external reference remains independently tracked by pipeline
provenance. An absent registry is represented only by the empty prompt value
`{"npcs":[]}`. Spell
and combat consumers use this shared boundary without changing their public
module contracts.

View File

@@ -57,8 +57,8 @@ func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) {
t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts)
}
first, distinct := normalized.SpellCasts[0], normalized.SpellCasts[1]
if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" || first.Effect != "first occurrence" || first.NarrativeDescription != "first narrative" {
t.Fatalf("retained cast = %#v, want canonical spell with first occurrence fields", first)
if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" {
t.Fatalf("retained cast = %#v, want canonical spell with first occurrence caster", first)
}
if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("retained refs = %#v, want sorted complete evidence", first.SourceRefs)
@@ -271,7 +271,7 @@ func (e *assembledSpellExtractor) Extract(ctx context.Context, req contracts.Typ
if e.unknownSpell {
if req.Chunk.Index == 0 {
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Spell: "Mysterious Burst", Effect: "an unknown magical effect", NarrativeDescription: "Aria produces a mysterious burst.", SourceRefs: []source.SourceRef{refOne},
Caster: "Aria", Spell: "Mysterious Burst", SourceRefs: []source.SourceRef{refOne},
}}}}, nil
}
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, nil
@@ -279,12 +279,12 @@ func (e *assembledSpellExtractor) Extract(ctx context.Context, req contracts.Typ
switch req.Chunk.Index {
case 0:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: " Aria \t", Spell: " cure wounds ", Effect: "first occurrence", NarrativeDescription: "first narrative", SourceRefs: []source.SourceRef{refTwo, refOne},
Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{refTwo, refOne},
}}}}, nil
case 1:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{
{Caster: "aria", Spell: "Cure Wounds", Effect: "removed occurrence", NarrativeDescription: "removed narrative", SourceRefs: []source.SourceRef{refOne, refTwo}},
{Caster: "aria", Spell: "Cure Wounds", Effect: "different evidence", NarrativeDescription: "different narrative", SourceRefs: []source.SourceRef{refTwo}},
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refOne, refTwo}},
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refTwo}},
}}}, nil
default:
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index)

View File

@@ -672,7 +672,7 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
if client.spellResponse != "" {
content = []byte(client.spellResponse)
} else {
content = []byte(`{"spell_casts":[{"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","start_unit_id":1,"end_unit_id":1}]}]}`)
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
}
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)

View File

@@ -148,11 +148,9 @@ func (client *catalogRetryLLMClient) CallCount() int {
func productionSpellResponse(name string) string {
content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria",
Spell: name,
Effect: "The spell takes effect.",
NarrativeDescription: "Aria casts the spell.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
Caster: "Aria",
Spell: name,
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
}}})
if err != nil {
panic(err)

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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}) {

View File

@@ -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 {

View File

@@ -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},
},

View File

@@ -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",
},
}

View File

@@ -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: " yle ", 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: " yle ", 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))

View File

@@ -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}
]

View File

@@ -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 {

View File

@@ -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}},
}
}

View File

@@ -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)
}

View File

@@ -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}}}}}
}

View File

@@ -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},
}}}}
}

View File

@@ -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}},
}}}}
}

View File

@@ -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{

View File

@@ -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,
},
}
}

View File

@@ -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
}