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; - `caster`: in-world character or creature casting the spell;
- `spell`: spell name; - `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 - `source_refs`: transcript source references with extractor-assigned source
IDs and evidence unit ranges. It must contain at least one entry. 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 caster, not the human player, transcript speaker, or GM when the associated
character or creature can be identified. Player and party references may character or creature can be identified. Player and party references may
disambiguate that identity, but do not independently establish that a cast 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 catalog as either a canonical name or alias. Catalog validation accepts aliases
but does not rewrite them; unknown fields are rejected. but does not rewrite them; unknown fields are rejected.
`effect` and `narrative_description` record the casting declaration and its The artifact includes an actual casting event or an unambiguous declared
immediate resolution as established by the transcript. They do not follow casting attempt. Spell mentions, hypothetical plans, rules discussion, and
summoned creatures, persistent spell effects, or other downstream consequences catalog matches without a casting event are excluded. The spell catalog is a
through the rest of the scene. They also do not correct the table from name-recognition policy and never evidence that a cast occurred.
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.
## Source References ## 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. must not appear after the end unit. Unknown fields are rejected.
For each cast, the complete `source_refs` collection identifies the transcript For each cast, the complete `source_refs` collection identifies the transcript
evidence for every factual claim in `caster`, `spell`, `effect`, and evidence for the caster, spell name, and occurrence of the cast or declared
`narrative_description`. A cast declaration and its immediate resolution may be attempt. The deterministic validators establish that ranges are structurally
cited with separate narrow ranges when intervening units are unrelated. A valid and that the spell name is related to cited text. Semantic evidence
reported target, roll, amount, condition, interruption, or immediate outcome sufficiency is an extraction policy and remains subject to evaluation rather
must be supported by the cited units; otherwise the artifact describes only the than deterministic proof.
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.
Reference slot keys and accepted file types are defined in Reference slot keys and accepted file types are defined in
[Configuration](../config.md#implemented-production-modules). References are [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 validated during preparation; an ordered generated binding is validated at the
step handoff. Both paths use the approved NPC codec and identity policy, step handoff. Both paths use the approved NPC codec and identity policy,
re-encode canonical durable JSON for registry provenance, and supply only the 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 registry's ordered names as the operation-time spell prompt input. It helps the
caster names; it does not establish that a spell was cast. 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 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 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 After those per-cast changes, duplicate identity requires the same canonical
spell name, the same caster after case folding and whitespace normalization, 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 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, document. Only the first occurrence is retained, in stable order. Its caster
effect, narrative description, and canonical references are preserved without and canonical references are preserved. Unknown names, empty or invalid
prose merging or source union. Unknown names, empty or invalid evidence, and evidence, and casts with different evidence remain separate.
casts with different evidence remain separate.
Mutation and duplicate decisions are returned through the normal warnings Mutation and duplicate decisions are returned through the normal warnings
surface. Warning scopes use the merged input index, such as `spell_casts[0]`, 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, Accordingly, the common prefix of all three extraction prompts is system,
extraction evidence, identity, and campaign references. The NPC prompt then extraction evidence, identity, and campaign references. The NPC prompt then
renders task, instructions, and transcript. Spell renders immediate resolution, renders task, instructions, and transcript. Spell renders the NPC registry,
NPC registry, catalog, task, instructions, and transcript. Combat renders catalog, task, instructions, and transcript. Combat renders
immediate resolution, NPC registry, task, instructions, and transcript. The immediate resolution, NPC registry, task, instructions, and transcript. The
scene chunker is not an extraction lane: it retains its separate system, scene chunker is not an extraction lane: it retains its separate system,
transcript, campaign-reference, task, and instruction order and marks its 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 campaign references remain separate disambiguation inputs and never become
source evidence. source evidence.
The prompt limits each cast to its declaration and immediate resolution; it The prompt includes only actual casting events and unambiguous declared casting
does not follow summoned creatures, persistent effects, or other downstream attempts. Spell mentions, plans, rules discussion, and catalog matches without
consequences through the scene. Shared extraction-evidence and identity rules a casting event are excluded. Shared extraction-evidence and identity rules
require transcript-supported factual claims and the most specific in-world require transcript-supported caster and spell facts, while the catalog,
caster identity, while campaign references only disambiguate source text. campaign references, and NPC names only disambiguate source text. Structural
Effects describe the session as played: model rules knowledge cannot supplement source validation remains deterministic; semantic evidence sufficiency is
or correct the transcript, and nonstandard adjudication is attributed to the GM enforced through extraction policy and evaluation.
or table rather than stated as a universal rule. Structural source validation
remains deterministic; semantic claim completeness is enforced through
extraction policy and evaluation.
Both the extractor and deterministic catalog validator expose Both the extractor and deterministic catalog validator expose
the effective base-plus-overlay semantic digest as scoped prepared-component 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 changes, while the semantic digest also invalidates reuse when the embedded
catalog or catalog composition changes. The extractor additionally fingerprints catalog or catalog composition changes. The extractor additionally fingerprints
its complete prompt assets and private response schema, so either semantic its complete prompt assets and private response schema, so either semantic
contract changing invalidates previously recorded extraction checkpoints. The separate contract changing invalidates previously recorded extraction checkpoints. The
`internal/modules/dnd/codec/spells` package separate `internal/modules/dnd/codec/spells` package
owns the durable schema and stable JSON representation for artifact kind owns the durable schema and stable JSON representation for artifact kind
`dnd/spell-list`. The runner keeps the result typed through validators and later `dnd/spell-list`. The runner keeps the result typed through validators and later
stages, using the codec only for checkpoint, debug, and output boundaries. stages, using the codec only for checkpoint, debug, and output boundaries.
Shared D&D helpers keep prompt input Shared D&D helpers keep prompt input names and source-unit reference conversion
names and source-unit reference conversion consistent with the scene chunker. consistent with the scene chunker.
The extractor also declares the optional `npcs` registry slot and consumes the The extractor also declares the optional `npcs` registry slot and consumes the
immutable registry boundary from `internal/modules/dnd/npcs/registry`. An immutable registry boundary from `internal/modules/dnd/npcs/registry`. An
external registry is prepared before execution; a generated registry is external registry is prepared before execution; a generated registry is
validated and supplied at operation time. External bindings may add only validated and supplied at operation time. Bound external registries add only
`npc_registry_digest` and `npc_count` to module metadata and an the full `npc_registry_digest` and `npc_count` to module metadata. The local
`npc_registry` checkpoint fingerprint. Generated bindings are represented by `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 framework handoff provenance and dependency fingerprints. The unbound prompt
input is exactly `{"npcs":[]}`, has a projection fingerprint, and has no input is exactly `{"npcs":[]}` and has no registry provenance.
registry provenance.
The shared NPC grounding fragment is placed immediately after the common The shared NPC grounding fragment is placed immediately after the common
campaign reference message and is included in the spell prompt fingerprint. 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 After those per-cast changes, it collapses only casts with the same canonical
spell, case-folded and whitespace-normalized caster, and complete non-empty spell, case-folded and whitespace-normalized caster, and complete non-empty
valid source-reference set. It retains the first occurrence and its caster, valid source-reference set. It retains the first occurrence and its caster,
effect, narrative description, and stable order. Unknown names, empty or source references, and stable order. Unknown names, empty or invalid evidence,
invalid evidence, and adjacent or overlapping but different ranges remain and adjacent or overlapping but different ranges remain unchanged for
unchanged for validation. validation.
The normalizer exposes the effective catalog digest as its independently scoped The normalizer exposes the effective catalog digest as its independently scoped
`effective_catalog` checkpoint fingerprint and reports catalog base ID, digest, `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 ## D&D Spell Validators
All four validators receive `dnd.SpellList` directly. The shape validator All four validators receive `dnd.SpellList` directly. The shape validator
rejects missing or empty spell fields and empty reference lists. The catalog rejects a missing list, blank caster or spell names, and empty reference lists.
validator defers when shape is invalid, then checks every non-empty spell name The catalog validator defers when shape is invalid, then checks every non-empty
against the immutable effective SRD and overlay catalog. It accepts normalized spell name against the immutable effective SRD and overlay catalog. It accepts
canonical names and aliases without rewriting the artifact; unknown names normalized canonical names and aliases without rewriting the artifact; unknown
reject the complete result with bounded, stable index/name diagnostics. The names reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator defers malformed shapes, validates every cited source-reference validator defers malformed shapes, validates every cited
range, and reports all range defects through a bounded aggregate while range, and reports all range defects through a bounded aggregate while
preserving `invalid_source_refs`. The relatedness validator resolves all cited 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. The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
External references are strictly decoded and identity-validated during External references are strictly decoded and identity-validated during
preparation; generated references are decoded and identity-validated at the preparation; generated references are decoded and identity-validated at the
ordered step handoff. Both paths emit canonical registry JSON to operation-time ordered step handoff. Both paths retain canonical registry JSON for provenance
spell and combat prompt or normalization requests. The framework records and emit a names-only projection to operation-time spell and combat prompts.
generated identity and bounded producer provenance, while the raw external Combat normalization uses the canonical registry for exact actor lookup. The
reference remains independently tracked by pipeline provenance. An absent framework records generated identity and bounded producer provenance, while
registry is represented only by the empty prompt value `{"npcs":[]}`. Spell 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 and combat consumers use this shared boundary without changing their public
module contracts. 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) t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts)
} }
first, distinct := normalized.SpellCasts[0], normalized.SpellCasts[1] 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" { if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" {
t.Fatalf("retained cast = %#v, want canonical spell with first occurrence fields", first) 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}}) { 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) 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 e.unknownSpell {
if req.Chunk.Index == 0 { if req.Chunk.Index == 0 {
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{ 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 }}}}, nil
} }
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, 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 { switch req.Chunk.Index {
case 0: case 0:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{ 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 }}}}, nil
case 1: case 1:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{ 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", 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{refTwo}},
}}}, nil }}}, nil
default: default:
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index) 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 != "" { if client.spellResponse != "" {
content = []byte(client.spellResponse) content = []byte(client.spellResponse)
} else { } 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: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID) 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 { func productionSpellResponse(name string) string {
content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{ content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Caster: "Aria",
Spell: name, Spell: name,
Effect: "The spell takes effect.", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
NarrativeDescription: "Aria casts the spell.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
}}}) }}})
if err != nil { if err != nil {
panic(err) panic(err)

View File

@@ -13,8 +13,6 @@
"required": [ "required": [
"caster", "caster",
"spell", "spell",
"effect",
"narrative_description",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
@@ -26,14 +24,6 @@
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
}, },
"effect": {
"type": "string",
"minLength": 1
},
"narrative_description": {
"type": "string",
"minLength": 1
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,

View File

@@ -99,12 +99,6 @@ func validate(value dnd.SpellList) error {
if strings.TrimSpace(spell.Spell) == "" { if strings.TrimSpace(spell.Spell) == "" {
return fmt.Errorf("spell_casts[%d].spell must not be empty", index) 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 { if len(spell.SourceRefs) == 0 {
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index) 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) t.Fatalf("Decode() error = %v, want nil", err)
} }
want := dnd.SpellList{SpellCasts: []dnd.SpellCast{ 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}}},
{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: "Borin", Spell: "Fire Bolt", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
}} }}
if !reflect.DeepEqual(value, want) { if !reflect.DeepEqual(value, want) {
t.Fatalf("Decode() = %#v, want %#v", value, want) t.Fatalf("Decode() = %#v, want %#v", value, want)
@@ -82,9 +82,10 @@ func TestCodecStrictlyRejectsInvalidRepresentations(t *testing.T) {
want string want string
}{ }{
{name: "unknown", raw: `{"spell_casts":[],"unexpected":true}`, want: "unknown field"}, {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: "trailing", raw: `{"spell_casts":[]} {}`, want: "multiple JSON values"},
{name: "missing", raw: `{}`, want: "spell_casts must be present"}, {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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@@ -3,8 +3,6 @@
{ {
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "Heals an injured ally.",
"narrative_description": "Aria restores the fighter after the fight.",
"source_refs": [ "source_refs": [
{ {
"source_id": "session-alpha", "source_id": "session-alpha",
@@ -16,8 +14,6 @@
{ {
"caster": "Borin", "caster": "Borin",
"spell": "Fire Bolt", "spell": "Fire Bolt",
"effect": "Scorches the wight.",
"narrative_description": "Borin hurls fire at the wight.",
"source_refs": [ "source_refs": [
{ {
"source_id": "session-alpha", "source_id": "session-alpha",

View File

@@ -33,8 +33,6 @@ messages:
content_file: ./sharedassets/common-dnd-references.md content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-immediate-resolution.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-npcs.md content_file: ./sharedassets/common-dnd-npcs.md
cache_control: cache_control:

View File

@@ -1,14 +1,8 @@
For each spell cast, source references must collectively support the caster, For each spell cast, source references must collectively support the in-world
spell, effect, and narrative_description. If a detail is not supported by the caster, spell name, and the fact that the cast or declared attempt occurred.
cited transcript units, omit that detail or describe only the supported attempt
or declaration.
For spells, do not follow summoned creatures, persistent effects, or other Return only D&D spell-cast artifacts. For each record, identify the in-world
downstream consequences through the rest of the scene. caster, canonical spell name, and source references.
Return only D&D spell-cast artifacts. For each spell cast, identify the
in-world caster, spell name, effect, narrative description, and source
references.
Use the player and party references together with transcript context to map Use the player and party references together with transcript context to map
first-person player speech to the associated player character and use the 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 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 spell name absent from that catalog, even when it is suggested by general D&D
knowledge or reference material. 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. 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 Include an actual casting event or an unambiguous declared casting attempt.
does not identify a spell being cast. Exclude spell mentions, hypothetical plans, rules discussion, and catalog
matches that do not establish a casting event in the transcript.
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.
Use the provided canonical spell-name catalog when naming each extracted spell. Use the provided canonical spell-name catalog when naming each extracted spell.
Return the canonical catalog spelling exactly. The catalog is a recognition 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": [ "required": [
"caster", "caster",
"spell", "spell",
"effect",
"narrative_description",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
@@ -26,17 +24,9 @@
"type": "string", "type": "string",
"description": "Canonical spell name from the provided spell-name catalog." "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": { "source_refs": {
"type": "array", "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": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,

View File

@@ -88,11 +88,9 @@ func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellL
} }
} }
spellCasts[index] = dnd.SpellCast{ spellCasts[index] = dnd.SpellCast{
Caster: spell.Caster, Caster: spell.Caster,
Spell: spell.Spell, Spell: spell.Spell,
Effect: spell.Effect, SourceRefs: refs,
NarrativeDescription: spell.NarrativeDescription,
SourceRefs: refs,
} }
} }
if response.SpellCasts == nil { if response.SpellCasts == nil {

View File

@@ -19,11 +19,9 @@ import (
func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) { func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{ client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
{ {
Caster: " Aria ", Caster: " Aria ",
Spell: " Cure Wounds ", Spell: " Cure Wounds ",
Effect: " Heals an injured ally. ", SourceRefs: responseSourceRefs(1, 2),
NarrativeDescription: " Aria restores the fighter after the fight. ",
SourceRefs: responseSourceRefs(1, 2),
}, },
}}} }}}
req := extractionRequest() req := extractionRequest()
@@ -34,11 +32,9 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
} }
want := dnd.SpellList{SpellCasts: []dnd.SpellCast{ want := dnd.SpellList{SpellCasts: []dnd.SpellCast{
{ {
Caster: " Aria ", Caster: " Aria ",
Spell: " Cure Wounds ", Spell: " Cure Wounds ",
Effect: " Heals an injured ally. ", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
NarrativeDescription: " Aria restores the fighter after the fight. ",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
}, },
}} }}
if !reflect.DeepEqual(result.Value, want) { if !reflect.DeepEqual(result.Value, want) {
@@ -268,9 +264,9 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) { func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{ client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Burns.", NarrativeDescription: "Second.", SourceRefs: responseSourceRefs(2, 2)}, {Caster: "Borin", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
{Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "First.", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}}, {Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}},
{Caster: "Narrator", Spell: "Unknown", Effect: "Unknown.", NarrativeDescription: "Uncited."}, {Caster: "Narrator", Spell: "Unknown"},
}}} }}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil { if err != nil {
@@ -286,7 +282,7 @@ func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) { func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{ 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}}, SourceRefs: []spellSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}}}} }}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
@@ -300,13 +296,13 @@ func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
} }
func TestExtractMapsRawSemanticCandidatesWithoutRepair(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()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil { if err != nil {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
spell := result.Value.SpellCasts[0] 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) 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}) { 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 { type spellCastResponse struct {
Caster string `json:"caster"` Caster string `json:"caster"`
Spell string `json:"spell"` Spell string `json:"spell"`
Effect string `json:"effect"` SourceRefs []spellSourceRefResponse `json:"source_refs"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []spellSourceRefResponse `json:"source_refs"`
} }
type spellSourceRefResponse struct { type spellSourceRefResponse struct {

View File

@@ -37,14 +37,14 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
{ {
name: "semantic blanks and empty evidence", name: "semantic blanks and empty evidence",
response: map[string]any{"spell_casts": []any{map[string]any{ 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, valid: true,
}, },
{ {
name: "nonpositive unit candidates", name: "nonpositive unit candidates",
response: map[string]any{"spell_casts": []any{map[string]any{ 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}}, "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
}}}, }}},
valid: true, valid: true,
@@ -52,25 +52,25 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
{ {
name: "missing required field", name: "missing required field",
response: map[string]any{"spell_casts": []any{map[string]any{ 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", name: "unknown field",
response: map[string]any{"spell_casts": []any{map[string]any{ 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", name: "wrong field type",
response: map[string]any{"spell_casts": []any{map[string]any{ 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", name: "noninteger source identifier",
response: map[string]any{"spell_casts": []any{map[string]any{ 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}}, "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{ return map[string]any{
"spell_casts": []any{ "spell_casts": []any{
map[string]any{ map[string]any{
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "The wounds close.",
"narrative_description": "Aria casts Cure Wounds.",
"source_refs": []any{ "source_refs": []any{
map[string]any{"start_unit_id": 1, "end_unit_id": 2}, 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-identity.md",
"common-dnd-transcript.md", "common-dnd-transcript.md",
"common-dnd-references.md", "common-dnd-references.md",
"common-dnd-immediate-resolution.md",
"common-dnd-npcs.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} firstEvidence := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2}
secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4} secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{ 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: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}},
{Caster: "Borin", Spell: "Healing Word", Effect: "distinct effect", NarrativeDescription: "distinct narrative", SourceRefs: []source.SourceRef{firstEvidence}}, {Caster: "Borin", Spell: "Healing Word", SourceRefs: []source.SourceRef{firstEvidence}},
{Caster: " yle ", Spell: "Cure Wounds", Effect: "kept effect", NarrativeDescription: "kept narrative", SourceRefs: []source.SourceRef{firstEvidence}}, {Caster: " yle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
{Caster: "aria", Spell: " cure wounds ", Effect: "removed effect", NarrativeDescription: "removed narrative", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}}, {Caster: "aria", Spell: " cure wounds ", 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 ", SourceRefs: []source.SourceRef{firstEvidence}},
{Caster: " kyle ", Spell: "Cure Wounds", Effect: "removed effect three", NarrativeDescription: "removed narrative three", SourceRefs: []source.SourceRef{firstEvidence}}, {Caster: " kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
}} }}
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)) result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
@@ -281,8 +281,8 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
if len(result.Value.SpellCasts) != 3 { if len(result.Value.SpellCasts) != 3 {
t.Fatalf("normalized casts = %#v, want first occurrences plus distinct cast", result.Value.SpellCasts) 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" { if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" {
t.Fatalf("retained first cast = %#v, want first occurrence fields unchanged", got) 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}) { 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) 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) { func TestNormalizeIsIdempotentForAlreadyNormalizedInput(t *testing.T) {
doc := sourceDocument(3) doc := sourceDocument(3)
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{ input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Spell: "Cure Wounds", Spell: "Cure Wounds",
Caster: "Aria", Caster: "Aria",
Effect: "effect", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
NarrativeDescription: "narrative",
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
}}} }}}
normalizer := newNormalizer(t) normalizer := newNormalizer(t)
first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)) first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))

View File

@@ -7,8 +7,6 @@
{ {
"caster": " Aria ", "caster": " Aria ",
"spell": " cure wounds ", "spell": " cure wounds ",
"effect": "first effect",
"narrative_description": "first narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}, {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2},
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}, {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
@@ -18,8 +16,6 @@
{ {
"caster": "aria", "caster": "aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "duplicate effect",
"narrative_description": "duplicate narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}, {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
@@ -28,8 +24,6 @@
{ {
"caster": "aria", "caster": "aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "distinct effect",
"narrative_description": "distinct narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
] ]
@@ -41,8 +35,6 @@
{ {
"caster": " Aria ", "caster": " Aria ",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "first effect",
"narrative_description": "first narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}, {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
@@ -51,8 +43,6 @@
{ {
"caster": "aria", "caster": "aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "distinct effect",
"narrative_description": "distinct narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
] ]
@@ -72,8 +62,6 @@
{ {
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "heals an ally",
"narrative_description": "Aria restores an ally's wounds.",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1} {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
] ]
@@ -85,8 +73,6 @@
{ {
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "heals an ally",
"narrative_description": "Aria restores an ally's wounds.",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1} {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
] ]

View File

@@ -17,11 +17,9 @@ type SpellList struct {
} }
type SpellCast struct { type SpellCast struct {
Caster string `json:"caster"` Caster string `json:"caster"`
Spell string `json:"spell"` Spell string `json:"spell"`
Effect string `json:"effect"` SourceRefs []source.SourceRef `json:"source_refs"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []source.SourceRef `json:"source_refs"`
} }
type NPCList struct { type NPCList struct {

View File

@@ -201,11 +201,9 @@ func spellList(names ...string) dnd.SpellList {
func validCast(name string) dnd.SpellCast { func validCast(name string) dnd.SpellCast {
return dnd.SpellCast{ return dnd.SpellCast{
Caster: "Aria", Caster: "Aria",
Spell: name, Spell: name,
Effect: "heals an ally", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
NarrativeDescription: "Aria restores Borin.",
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) == "" { if strings.TrimSpace(spell.Spell) == "" {
return fmt.Errorf("spell_casts[%d].spell must not be empty", index) 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 { if len(spell.SourceRefs) == 0 {
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index) 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) { func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
value := validSpellList() tests := []struct {
value.SpellCasts[0].Spell = "" name string
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) mutate func(*dnd.SpellCast)
if err != nil { }{
t.Fatalf("Validate() error = %v, want nil", err) {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 { for _, test := range tests {
t.Fatalf("Approved = true, want false") t.Run(test.name, func(t *testing.T) {
} value := validSpellList()
if result.ReasonCode != ReasonCode { test.mutate(&value.SpellCasts[0])
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode) 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 { 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] { 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{{ 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) { func TestValidatorMatchesCaseInsensitiveUnicodeMultiwordSpellAcrossCitations(t *testing.T) {
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{ 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{ SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, 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] { 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{{ 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}}, 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" 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/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T) { 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" { if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "Mira Thorn" {
t.Fatalf("spell output = %#v, want one registry-grounded-context spell", spellValue) 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": case "combat":
decoded, decodeErr := combatcodec.New().Decode(serialized.Artifact.Content) decoded, decodeErr := combatcodec.New().Decode(serialized.Artifact.Content)
if decodeErr != nil { if decodeErr != nil {
@@ -127,15 +126,6 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
assertCurrentEvidence(t, combatValue.CombatTurns[0].SourceRefs) 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) { func assertCurrentEvidence(t *testing.T, references []source.SourceRef) {
t.Helper() t.Helper()
for _, reference := range references { for _, reference := range references {
@@ -184,9 +174,8 @@ func (client *groundedDNDLLMClient) CompleteStructured(ctx context.Context, requ
}} }}
case spells.PromptID: case spells.PromptID:
payload = map[string]any{"spell_casts": []any{map[string]any{ payload = map[string]any{"spell_casts": []any{map[string]any{
"caster": "Mira Thorn", "spell": "Cure Wounds", "effect": "Restores an ally.", "caster": "Mira Thorn", "spell": "Cure Wounds",
"narrative_description": "Mira Thorn restores an ally.", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
}}} }}}
case combatextract.PromptID: case combatextract.PromptID:
payload = map[string]any{"combat_turns": []any{map[string]any{ payload = map[string]any{"combat_turns": []any{map[string]any{

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
type extractionResponse struct { type extractionResponse struct {
@@ -14,11 +13,14 @@ type extractionResponse struct {
} }
type spellCastResponse struct { type spellCastResponse struct {
Caster string `json:"caster"` Caster string `json:"caster"`
Spell string `json:"spell"` Spell string `json:"spell"`
Effect string `json:"effect"` SourceRefs []spellSourceRefResponse `json:"source_refs"`
NarrativeDescription string `json:"narrative_description"` }
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
type spellSourceRefResponse struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
} }
type fakeSpellsLLMClient struct { type fakeSpellsLLMClient struct {
@@ -58,12 +60,11 @@ func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req con
return contracts.StructuredCompletionResponse{Content: content}, nil return contracts.StructuredCompletionResponse{Content: content}, nil
} }
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse { func responseSourceRefs(startUnitID int, endUnitID int) []spellSourceRefResponse {
return []shared.SourceRefResponse{ return []spellSourceRefResponse{
{ {
SourceID: sourceID, StartUnitID: startUnitID,
StartUnitID: shared.UnitRefFromInt(startUnitID), EndUnitID: endUnitID,
EndUnitID: shared.UnitRefFromInt(endUnitID),
}, },
} }
} }

View File

@@ -2,7 +2,6 @@ package integration_test
import ( import (
"context" "context"
"encoding/json"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -11,6 +10,8 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
@@ -36,18 +37,14 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
response: extractionResponse{ response: extractionResponse{
SpellCasts: []spellCastResponse{ SpellCasts: []spellCastResponse{
{ {
Caster: "Aria", Caster: "Aria",
Spell: "Cure Wounds", Spell: "Cure Wounds",
Effect: "Heals an injured ally.", SourceRefs: responseSourceRefs(1, 1),
NarrativeDescription: "Aria restores the fighter after the fight.",
SourceRefs: responseSourceRefs(expectedDoc.ID, 1, 1),
}, },
{ {
Caster: "Borin", Caster: "Borin",
Spell: "Fire Bolt", Spell: "Fire Bolt",
Effect: "Scorches the wight.", SourceRefs: responseSourceRefs(3, 3),
NarrativeDescription: "Borin hurls fire at the wight.",
SourceRefs: responseSourceRefs(expectedDoc.ID, 3, 3),
}, },
}, },
}, },
@@ -119,7 +116,6 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) { func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
raw := readDNDSpellsFixture(t) raw := readDNDSpellsFixture(t)
expectedDoc := parseDNDSpellsFixture(t, raw)
resolved := resolveDNDSpellsPipeline(t) resolved := resolveDNDSpellsPipeline(t)
resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = dndSpellsReferenceSet( resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = dndSpellsReferenceSet(
"Aria: party cleric\nBorin: fighter", "Aria: party cleric\nBorin: fighter",
@@ -129,11 +125,9 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
response: extractionResponse{ response: extractionResponse{
SpellCasts: []spellCastResponse{ SpellCasts: []spellCastResponse{
{ {
Caster: "Borin", Caster: "Borin",
Spell: "Fire Bolt", Spell: "Fire Bolt",
Effect: "Scorches the wight.", SourceRefs: responseSourceRefs(3, 3),
NarrativeDescription: "Borin hurls fire at the wight.",
SourceRefs: responseSourceRefs(expectedDoc.ID, 3, 3),
}, },
}, },
}, },
@@ -195,25 +189,25 @@ func TestProductionSpellPipelineRoutesSemanticCandidatesToDeterministicValidator
}{ }{
{ {
name: "blank string", 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, reasonCode: spellshape.ReasonCode,
validatorName: spellshape.Key, validatorName: spellshape.Key,
}, },
{ {
name: "empty evidence", 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, reasonCode: spellshape.ReasonCode,
validatorName: spellshape.Key, validatorName: spellshape.Key,
}, },
{ {
name: "nonpositive unit candidate", 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, reasonCode: spellsourcerefs.ReasonCode,
validatorName: spellsourcerefs.Key, validatorName: spellsourcerefs.Key,
}, },
{ {
name: "unknown unit candidate", 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, reasonCode: spellsourcerefs.ReasonCode,
validatorName: spellsourcerefs.Key, validatorName: spellsourcerefs.Key,
}, },
@@ -320,12 +314,12 @@ func parseDNDSpellsFixture(t *testing.T, raw []byte) *source.SourceDocument {
return doc return doc
} }
func decodeRunnerSpellResponse(t *testing.T, raw []byte) extractionResponse { func decodeRunnerSpellResponse(t *testing.T, raw []byte) dnd.SpellList {
t.Helper() t.Helper()
var response extractionResponse response, err := spellcodec.New().Decode(raw)
if err := json.Unmarshal(raw, &response); err != nil { if err != nil {
t.Fatalf("Unmarshal(raw output) error = %v, want nil", err) t.Fatalf("decode durable spell output: %v", err)
} }
return response return response
} }