From 2cbaf20e552b1d89d279ce54db07ccf5b21c86bf Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 22 Jul 2026 19:01:58 +0000 Subject: [PATCH] Minimize D&D spell extraction contracts --- docs/integrations/dnd-spell-artifacts.md | 46 ++++++----------- docs/internal/llm.md | 4 +- docs/internal/modules.md | 51 +++++++++---------- docs/internal/overview.md | 12 +++-- .../assembled_spell_pipeline_contract_test.go | 12 ++--- internal/cli/production_contract_test.go | 2 +- .../cli/spell_catalog_retry_contract_test.go | 8 ++- .../spells/assets/schemas/dnd_spells.v1.json | 10 ---- internal/modules/dnd/codec/spells/codec.go | 6 --- .../modules/dnd/codec/spells/codec_test.go | 7 +-- .../codec/spells/testdata/dnd_spells.v1.json | 4 -- .../spells/assets/prompts/dnd.spells.yaml | 2 - .../spells/assets/prompts/instructions.md | 21 ++------ .../dnd/extract/spells/assets/prompts/task.md | 14 ++--- .../assets/schemas/dnd_spells_llm.v1.json | 12 +---- .../dnd/extract/spells/canonicalize.go | 8 ++- .../dnd/extract/spells/extractor_test.go | 28 +++++----- internal/modules/dnd/extract/spells/model.go | 8 ++- .../modules/dnd/extract/spells/schema_test.go | 18 +++---- .../dnd/extract/spells/scriptorium_assets.go | 1 - .../dnd/normalize/spells/normalizer_test.go | 24 ++++----- .../testdata/normalizer_accepted_cases.json | 14 ----- internal/modules/dnd/types.go | 8 ++- .../validate/spells/catalog/validator_test.go | 8 ++- .../dnd/validate/spells/shape/validator.go | 6 --- .../validate/spells/shape/validator_test.go | 28 ++++++---- .../spells/source_refs/validator_test.go | 2 +- .../source_relatedness/validator_test.go | 4 +- .../integration/dnd_npc_grounded_test.go | 17 ++----- .../integration/dnd_spells_helpers_test.go | 23 +++++---- .../integration/dnd_spells_runner_test.go | 44 +++++++--------- 31 files changed, 169 insertions(+), 283 deletions(-) diff --git a/docs/integrations/dnd-spell-artifacts.md b/docs/integrations/dnd-spell-artifacts.md index 557e5f9..20ee6a5 100644 --- a/docs/integrations/dnd-spell-artifacts.md +++ b/docs/integrations/dnd-spell-artifacts.md @@ -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]`, diff --git a/docs/internal/llm.md b/docs/internal/llm.md index f14d32a..3c71937 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -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 diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 1915bb6..3ba0336 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -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 diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 5e2314e..6e62d9e 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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. diff --git a/internal/cli/assembled_spell_pipeline_contract_test.go b/internal/cli/assembled_spell_pipeline_contract_test.go index 61eeffe..1f9c274 100644 --- a/internal/cli/assembled_spell_pipeline_contract_test.go +++ b/internal/cli/assembled_spell_pipeline_contract_test.go @@ -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) diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index a86ec22..cbeac10 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -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) diff --git a/internal/cli/spell_catalog_retry_contract_test.go b/internal/cli/spell_catalog_retry_contract_test.go index b49888e..d4f7584 100644 --- a/internal/cli/spell_catalog_retry_contract_test.go +++ b/internal/cli/spell_catalog_retry_contract_test.go @@ -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) diff --git a/internal/modules/dnd/codec/spells/assets/schemas/dnd_spells.v1.json b/internal/modules/dnd/codec/spells/assets/schemas/dnd_spells.v1.json index 0f20a3c..d927fbe 100644 --- a/internal/modules/dnd/codec/spells/assets/schemas/dnd_spells.v1.json +++ b/internal/modules/dnd/codec/spells/assets/schemas/dnd_spells.v1.json @@ -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, diff --git a/internal/modules/dnd/codec/spells/codec.go b/internal/modules/dnd/codec/spells/codec.go index 03864bd..2e118d3 100644 --- a/internal/modules/dnd/codec/spells/codec.go +++ b/internal/modules/dnd/codec/spells/codec.go @@ -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) } diff --git a/internal/modules/dnd/codec/spells/codec_test.go b/internal/modules/dnd/codec/spells/codec_test.go index 58385c3..b6be18e 100644 --- a/internal/modules/dnd/codec/spells/codec_test.go +++ b/internal/modules/dnd/codec/spells/codec_test.go @@ -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) { diff --git a/internal/modules/dnd/codec/spells/testdata/dnd_spells.v1.json b/internal/modules/dnd/codec/spells/testdata/dnd_spells.v1.json index ba0763c..65690ca 100644 --- a/internal/modules/dnd/codec/spells/testdata/dnd_spells.v1.json +++ b/internal/modules/dnd/codec/spells/testdata/dnd_spells.v1.json @@ -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", diff --git a/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml b/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml index 5d8834b..0d31f03 100644 --- a/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml +++ b/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml @@ -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: diff --git a/internal/modules/dnd/extract/spells/assets/prompts/instructions.md b/internal/modules/dnd/extract/spells/assets/prompts/instructions.md index 7431792..e4cd186 100644 --- a/internal/modules/dnd/extract/spells/assets/prompts/instructions.md +++ b/internal/modules/dnd/extract/spells/assets/prompts/instructions.md @@ -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. diff --git a/internal/modules/dnd/extract/spells/assets/prompts/task.md b/internal/modules/dnd/extract/spells/assets/prompts/task.md index fb00827..9d09d97 100644 --- a/internal/modules/dnd/extract/spells/assets/prompts/task.md +++ b/internal/modules/dnd/extract/spells/assets/prompts/task.md @@ -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. diff --git a/internal/modules/dnd/extract/spells/assets/schemas/dnd_spells_llm.v1.json b/internal/modules/dnd/extract/spells/assets/schemas/dnd_spells_llm.v1.json index 2b6ab5e..71ec207 100644 --- a/internal/modules/dnd/extract/spells/assets/schemas/dnd_spells_llm.v1.json +++ b/internal/modules/dnd/extract/spells/assets/schemas/dnd_spells_llm.v1.json @@ -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, diff --git a/internal/modules/dnd/extract/spells/canonicalize.go b/internal/modules/dnd/extract/spells/canonicalize.go index 69ced5f..c5a508d 100644 --- a/internal/modules/dnd/extract/spells/canonicalize.go +++ b/internal/modules/dnd/extract/spells/canonicalize.go @@ -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 { diff --git a/internal/modules/dnd/extract/spells/extractor_test.go b/internal/modules/dnd/extract/spells/extractor_test.go index a63b1b3..1702426 100644 --- a/internal/modules/dnd/extract/spells/extractor_test.go +++ b/internal/modules/dnd/extract/spells/extractor_test.go @@ -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}) { diff --git a/internal/modules/dnd/extract/spells/model.go b/internal/modules/dnd/extract/spells/model.go index c765f3f..db89f11 100644 --- a/internal/modules/dnd/extract/spells/model.go +++ b/internal/modules/dnd/extract/spells/model.go @@ -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 { diff --git a/internal/modules/dnd/extract/spells/schema_test.go b/internal/modules/dnd/extract/spells/schema_test.go index fde7976..8f7b4fa 100644 --- a/internal/modules/dnd/extract/spells/schema_test.go +++ b/internal/modules/dnd/extract/spells/schema_test.go @@ -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}, }, diff --git a/internal/modules/dnd/extract/spells/scriptorium_assets.go b/internal/modules/dnd/extract/spells/scriptorium_assets.go index d309695..48205c7 100644 --- a/internal/modules/dnd/extract/spells/scriptorium_assets.go +++ b/internal/modules/dnd/extract/spells/scriptorium_assets.go @@ -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", }, } diff --git a/internal/modules/dnd/normalize/spells/normalizer_test.go b/internal/modules/dnd/normalize/spells/normalizer_test.go index a82007b..1c90da5 100644 --- a/internal/modules/dnd/normalize/spells/normalizer_test.go +++ b/internal/modules/dnd/normalize/spells/normalizer_test.go @@ -266,12 +266,12 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) { firstEvidence := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2} secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4} input := dnd.SpellList{SpellCasts: []dnd.SpellCast{ - {Caster: " Aria \t", Spell: " cure wounds ", Effect: "first effect", NarrativeDescription: "first narrative", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}}, - {Caster: "Borin", Spell: "Healing Word", Effect: "distinct effect", NarrativeDescription: "distinct narrative", SourceRefs: []source.SourceRef{firstEvidence}}, - {Caster: " Kyle ", Spell: "Cure Wounds", Effect: "kept effect", NarrativeDescription: "kept narrative", SourceRefs: []source.SourceRef{firstEvidence}}, - {Caster: "aria", Spell: " cure wounds ", Effect: "removed effect", NarrativeDescription: "removed narrative", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}}, - {Caster: "KYLE", Spell: " cure wounds ", Effect: "removed effect two", NarrativeDescription: "removed narrative two", SourceRefs: []source.SourceRef{firstEvidence}}, - {Caster: " kyle ", Spell: "Cure Wounds", Effect: "removed effect three", NarrativeDescription: "removed narrative three", SourceRefs: []source.SourceRef{firstEvidence}}, + {Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}}, + {Caster: "Borin", Spell: "Healing Word", SourceRefs: []source.SourceRef{firstEvidence}}, + {Caster: " Kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}}, + {Caster: "aria", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}}, + {Caster: "KYLE", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence}}, + {Caster: " kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}}, }} result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)) @@ -281,8 +281,8 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) { if len(result.Value.SpellCasts) != 3 { t.Fatalf("normalized casts = %#v, want first occurrences plus distinct cast", result.Value.SpellCasts) } - if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" || got.Effect != "first effect" || got.NarrativeDescription != "first narrative" { - t.Fatalf("retained first cast = %#v, want first occurrence fields unchanged", got) + if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" { + t.Fatalf("retained first cast = %#v, want first occurrence caster unchanged", got) } if got := result.Value.SpellCasts[0].SourceRefs; !reflect.DeepEqual(got, []source.SourceRef{firstEvidence, secondEvidence}) { t.Fatalf("retained first evidence = %#v, want canonical first evidence only", got) @@ -393,11 +393,9 @@ func TestNormalizeBoundsDuplicateWarningIndices(t *testing.T) { func TestNormalizeIsIdempotentForAlreadyNormalizedInput(t *testing.T) { doc := sourceDocument(3) input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{ - Spell: "Cure Wounds", - Caster: "Aria", - Effect: "effect", - NarrativeDescription: "narrative", - SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}}, + Spell: "Cure Wounds", + Caster: "Aria", + SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}}, }}} normalizer := newNormalizer(t) first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)) diff --git a/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json b/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json index ebe69b5..7f41f8b 100644 --- a/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json +++ b/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json @@ -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} ] diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 9f12a66..64b15da 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -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 { diff --git a/internal/modules/dnd/validate/spells/catalog/validator_test.go b/internal/modules/dnd/validate/spells/catalog/validator_test.go index 08f223b..861cc1e 100644 --- a/internal/modules/dnd/validate/spells/catalog/validator_test.go +++ b/internal/modules/dnd/validate/spells/catalog/validator_test.go @@ -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}}, } } diff --git a/internal/modules/dnd/validate/spells/shape/validator.go b/internal/modules/dnd/validate/spells/shape/validator.go index 43fefe0..ab4107a 100644 --- a/internal/modules/dnd/validate/spells/shape/validator.go +++ b/internal/modules/dnd/validate/spells/shape/validator.go @@ -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) } diff --git a/internal/modules/dnd/validate/spells/shape/validator_test.go b/internal/modules/dnd/validate/spells/shape/validator_test.go index 7703295..1319108 100644 --- a/internal/modules/dnd/validate/spells/shape/validator_test.go +++ b/internal/modules/dnd/validate/spells/shape/validator_test.go @@ -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}}}}} } diff --git a/internal/modules/dnd/validate/spells/source_refs/validator_test.go b/internal/modules/dnd/validate/spells/source_refs/validator_test.go index 0addc37..fb043fa 100644 --- a/internal/modules/dnd/validate/spells/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/spells/source_refs/validator_test.go @@ -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}, }}}} } diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go index e0304a8..b82a1cd 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go @@ -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}}, }}}} } diff --git a/internal/modules/integration/dnd_npc_grounded_test.go b/internal/modules/integration/dnd_npc_grounded_test.go index 411d2ba..d2321c3 100644 --- a/internal/modules/integration/dnd_npc_grounded_test.go +++ b/internal/modules/integration/dnd_npc_grounded_test.go @@ -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{ diff --git a/internal/modules/integration/dnd_spells_helpers_test.go b/internal/modules/integration/dnd_spells_helpers_test.go index 4e0ee31..b37ee53 100644 --- a/internal/modules/integration/dnd_spells_helpers_test.go +++ b/internal/modules/integration/dnd_spells_helpers_test.go @@ -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, }, } } diff --git a/internal/modules/integration/dnd_spells_runner_test.go b/internal/modules/integration/dnd_spells_runner_test.go index 8e1936e..38b294e 100644 --- a/internal/modules/integration/dnd_spells_runner_test.go +++ b/internal/modules/integration/dnd_spells_runner_test.go @@ -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 }