From 2fbb3813aa74778c67f6fd764a51f68b9770d2e5 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 21 Jul 2026 05:08:02 +0000 Subject: [PATCH] Add standalone D&D combat turn extraction --- .../integrations/dnd-combat-turn-artifacts.md | 56 ++- docs/internal/modules.md | 29 ++ docs/internal/overview.md | 5 +- .../modules/dnd/extract/combatturns/assets.go | 6 + .../assets/prompts/dnd.combat_turns.yaml | 41 ++ .../assets/prompts/instructions.md | 12 + .../combatturns/assets/prompts/task.md | 30 ++ .../schemas/dnd_combat_turns_llm.v1.json | 84 ++++ .../dnd/extract/combatturns/canonicalize.go | 156 ++++++++ .../dnd/extract/combatturns/extractor.go | 239 ++++++++++++ .../dnd/extract/combatturns/extractor_test.go | 367 ++++++++++++++++++ .../modules/dnd/extract/combatturns/model.go | 28 ++ .../modules/dnd/extract/combatturns/schema.go | 21 + .../dnd/extract/combatturns/schema_test.go | 99 +++++ .../extract/combatturns/scriptorium_assets.go | 45 +++ .../combatturns/scriptorium_assets_test.go | 44 +++ .../validate/combatturns/shape/validator.go | 143 +++++++ .../combatturns/shape/validator_test.go | 95 +++++ .../combatturns/source_refs/validator.go | 84 ++++ .../combatturns/source_refs/validator_test.go | 87 +++++ .../source_relatedness/validator.go | 156 ++++++++ .../source_relatedness/validator_test.go | 101 +++++ 22 files changed, 1924 insertions(+), 4 deletions(-) create mode 100644 internal/modules/dnd/extract/combatturns/assets.go create mode 100644 internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml create mode 100644 internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md create mode 100644 internal/modules/dnd/extract/combatturns/assets/prompts/task.md create mode 100644 internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json create mode 100644 internal/modules/dnd/extract/combatturns/canonicalize.go create mode 100644 internal/modules/dnd/extract/combatturns/extractor.go create mode 100644 internal/modules/dnd/extract/combatturns/extractor_test.go create mode 100644 internal/modules/dnd/extract/combatturns/model.go create mode 100644 internal/modules/dnd/extract/combatturns/schema.go create mode 100644 internal/modules/dnd/extract/combatturns/schema_test.go create mode 100644 internal/modules/dnd/extract/combatturns/scriptorium_assets.go create mode 100644 internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go create mode 100644 internal/modules/dnd/validate/combatturns/shape/validator.go create mode 100644 internal/modules/dnd/validate/combatturns/shape/validator_test.go create mode 100644 internal/modules/dnd/validate/combatturns/source_refs/validator.go create mode 100644 internal/modules/dnd/validate/combatturns/source_refs/validator_test.go create mode 100644 internal/modules/dnd/validate/combatturns/source_relatedness/validator.go create mode 100644 internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go diff --git a/docs/integrations/dnd-combat-turn-artifacts.md b/docs/integrations/dnd-combat-turn-artifacts.md index ccbf78a..c3d2927 100644 --- a/docs/integrations/dnd-combat-turn-artifacts.md +++ b/docs/integrations/dnd-combat-turn-artifacts.md @@ -1,8 +1,8 @@ # D&D Combat-Turn Artifact Contract -This document defines the durable artifact and serialization boundary for -D&D combat turns. It does not define extraction, validation beyond the codec's -structural checks, normalization, prompts, or a selectable pipeline lane. +This document defines the durable artifact, serialization, extraction, and +candidate-validation boundaries for D&D combat turns. Normalization and a +selectable production pipeline lane are not part of this contract yet. ## Artifact identity @@ -71,3 +71,53 @@ The codec owns the durable JSON Schema, whose object layers all set The maintained compact fixture is `internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json`. + +## Extraction boundary + +The standalone extractor uses these identities: + +| Property | Value | +| --- | --- | +| Extractor key | `dnd/combat-turns` | +| Capability | `dnd.combat_turns` | +| Prompt ID | `dnd.combat_turns` | +| Prompt version | `v1` | +| Private response-schema key | `dnd_combat_turns_llm` | +| Private response-schema ID | `notarius.dnd.combat_turns.llm` | +| Default profile | `gemini-2-flash` | + +It requires `chunks` and `source.transcript`, accepts no options, and makes one +structured completion for each supplied chunk. The prompt receives the +chunk-scoped transcript plus the existing `players`, `party`, and `glossary` +inputs, and optionally the deprecated `roster` reference through the shared +party mapping. The optional `npcs` reference is an approved normalized NPC +artifact used only for identity grounding; it never supplies combat evidence. + +The private response shape is the same as the durable turn/action shape except +that source references contain only `start_unit_id` and `end_unit_id`. The +extractor assigns the current source ID, removes exact duplicate ranges, and +stable-sorts turns by the earliest valid source-document position. Numeric unit +IDs are identifiers; source-document slice position determines chronology. +Malformed candidate fields remain in the typed result for deterministic +validators to report. + +## Deterministic candidate validation + +The standalone validator keys are: + +| Validator | Responsibility | +| --- | --- | +| `extract/dnd/combat-turns/shape` | Required arrays, strings, nullable fields, positive rounds, and supported enum values. | +| `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. | +| `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor or declared action is not related to cited transcript text. | + +Source-reference and relatedness validators defer malformed shape to the shape +validator. Relatedness also defers when any cited source range is invalid. It +combines overlapping cited ranges once in document order, compares actors with +the shared Unicode-aware NPC identity policy, and checks declaration tokens of +at least four Unicode code points against complete cited-text tokens. Targets +are not checked deterministically. + +The extractor and validators are package-complete but are not registered by the +production D&D family registrar yet. Normalization, production composition, +and selectable configuration are defined when implemented. diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 4caaa96..c077d3d 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -96,6 +96,11 @@ provenance and are not treated as evidence for a consuming pipeline. The NPC codec owns the strict durable `dnd/npc-list` JSON boundary and exposes candidate versus approved encode/decode operations. +The `internal/modules/dnd/codec/combatturns` package owns the durable +`dnd/combat-turn-list` schema and candidate versus approved JSON boundary. It +is available as a package contract but is not registered by the production D&D +family registrar yet. + ## Input Adapter ### `internal/modules/seriatim/input/transcript` @@ -220,6 +225,19 @@ references for deterministic validation. It uses the shared campaign references only for disambiguation and does not consume the optional NPC registry slot. Its prompt and private response schema are package-owned. +### `internal/modules/dnd/extract/combatturns` + +The combat extractor prepares one structured request per supplied chunk using +the shared transcript, campaign-reference, and NPC-grounding prompt inputs. It +maps the private response to `dnd.CombatTurnList`, assigns the current source +identity, removes exact duplicate source ranges, and orders turns by valid +source-document position while preserving malformed candidate fields for +deterministic validators. Its prompt and private response schema are +package-owned, and its prepared metadata and checkpoint fingerprints contain +only prompt/schema/mapping identities plus an optional NPC registry digest. +The package exposes typed registration for focused composition but is not in +the production D&D registrar yet. + ### `internal/modules/dnd/normalize/npcs` The NPC normalizer performs deterministic identity-aware consolidation in @@ -323,6 +341,17 @@ canonical names, aliases, and cross-record ownership or canonical collisions. All are deterministic and expose the policy fingerprints used by the production chains. +## D&D Combat Validators + +Combat shape validation owns required arrays, strings, nullable values, positive +rounds, and supported enums. Combat source-reference validation defers invalid +shape and checks source identity, unit existence, and range order. Combat +source-relatedness defers invalid shape or ranges, combines overlapping cited +units in document order, and emits at most one bounded advisory warning per +turn for unrelated actor or declaration text. All three validators are +deterministic and expose local policy fingerprints; they are package-complete +but not yet in a production validator chain. + ## Production Registration Production composition occurs through family registrars. The CLI allocates one diff --git a/docs/internal/overview.md b/docs/internal/overview.md index e04c33b..23e81af 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -84,11 +84,14 @@ Configuration. The implemented module packages are: | `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. | | `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. | | `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. | -| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, and relationship artifact types. | +| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, relationship, combat-turn-list, combat-turn, and combat-action artifact types. | | `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. | | `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. | +| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. | | `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. | | `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. | +| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. | +| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, and source-relatedness validation for combat-turn candidates without production composition. | | `internal/modules/dnd/npcs/registry` | Resolves validated normalized NPC references into immutable grounding data and exact identity lookup. | | `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. | | `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. | diff --git a/internal/modules/dnd/extract/combatturns/assets.go b/internal/modules/dnd/extract/combatturns/assets.go new file mode 100644 index 0000000..ca856cf --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/assets.go @@ -0,0 +1,6 @@ +package combatturns + +import "embed" + +//go:embed assets/schemas/dnd_combat_turns_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md +var embeddedAssets embed.FS diff --git a/internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml b/internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml new file mode 100644 index 0000000..9e18f76 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml @@ -0,0 +1,41 @@ +id: dnd.combat_turns +version: "v1" +default_profile: gemini-2-flash +inputs: + - name: transcript + required: true + content_type: application/json + - name: players + required: false + content_type: text/plain + - name: party + required: false + content_type: text/plain + - name: glossary + required: false + content_type: text/plain + - name: npcs + required: false + content_type: application/json +messages: + - role: system + content_file: ./sharedassets/common-dnd-system.md + - role: user + content_file: ./sharedassets/common-dnd-transcript.md + cache_control: + type: ephemeral + - role: user + content_file: ./sharedassets/common-dnd-references.md + cache_control: + type: ephemeral + - role: user + content_file: ./sharedassets/common-dnd-npcs.md + - role: user + content_file: ./task.md + - role: user + content_file: ./instructions.md +output: + format: json + validation_mode: json_schema + schema_path: dnd_combat_turns_llm.v1.json + repair_attempts: 0 diff --git a/internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md b/internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md new file mode 100644 index 0000000..e9a56fe --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md @@ -0,0 +1,12 @@ +Return exactly one JSON object and no explanatory text. + +Return the combat_turns array even when no combat turn is established. Return +one or more actions for every turn. Use one of the supported turn_kind and +action category values. Set round to null when the transcript does not state +an explicit or unambiguous positive round number. Set resolution to null when +the transcript establishes the declaration but not an immediate resolution. + +Every source reference must contain start_unit_id and end_unit_id from the +provided transcript. Do not add source_id; the extraction mapper assigns the +current source identity. Do not include fields not defined by the response +schema. diff --git a/internal/modules/dnd/extract/combatturns/assets/prompts/task.md b/internal/modules/dnd/extract/combatturns/assets/prompts/task.md new file mode 100644 index 0000000..c4d731f --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/assets/prompts/task.md @@ -0,0 +1,30 @@ +Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript. + +Include a record only when the transcript establishes that an in-world +participant takes a combat turn or performs a discrete interrupting combat +event. Reactions, legendary actions, lair actions, and other out-of-turn events +belong at the point where they occur in transcript chronology. + +Report only the declaration and its immediate observed resolution. Immediate +resolution may include directly associated rolls, damage, healing, movement, +conditions, target outcomes, or an interruption. Do not follow consequences +that occur on later turns or elsewhere in the scene. + +Exclude initiative setup without a turn or combat event, tactical planning, +table talk, rules lookup, hypothetical actions, abandoned declarations, recap +of combat outside the current passage, and downstream consequences. + +Use only the supplied transcript as evidence. Do not infer a round, target, +roll, amount, condition, outcome, or action classification from D&D rules +knowledge. Preserve the session as played; attribute relevant nonstandard +rulings to the GM or table. + +The actor must be the in-world character or creature, not a player, transcript +speaker, or GM. Use player, party, glossary, and NPC reference material only +to disambiguate identities. Reference material is context, never combat +evidence. Unmatched actors and targets remain permitted. + +For every factual detail in a turn, cite all supporting transcript units in the +turn-level source_refs collection. Use narrow ranges when evidence is +non-contiguous. Numeric source-unit IDs identify transcript units; they do not +establish chronology outside the supplied transcript. diff --git a/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json b/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json new file mode 100644 index 0000000..6425411 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "notarius.dnd.combat_turns.llm", + "type": "object", + "additionalProperties": false, + "required": ["combat_turns"], + "properties": { + "combat_turns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"], + "properties": { + "actor": { + "type": "string", + "minLength": 1 + }, + "turn_kind": { + "type": "string", + "enum": ["turn", "reaction", "legendary_action", "lair_action", "other"] + }, + "round": { + "type": ["integer", "null"], + "minimum": 1 + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["category", "declaration", "targets", "resolution"], + "properties": { + "category": { + "type": "string", + "enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"] + }, + "declaration": { + "type": "string", + "minLength": 1 + }, + "targets": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "resolution": { + "type": ["string", "null"], + "minLength": 1 + } + } + } + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["start_unit_id", "end_unit_id"], + "properties": { + "start_unit_id": { + "type": "integer", + "minimum": 1 + }, + "end_unit_id": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + } + } + } +} diff --git a/internal/modules/dnd/extract/combatturns/canonicalize.go b/internal/modules/dnd/extract/combatturns/canonicalize.go new file mode 100644 index 0000000..27a960d --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/canonicalize.go @@ -0,0 +1,156 @@ +package combatturns + +import ( + "sort" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) { + if response == nil { + return + } + for index := range response.CombatTurns { + canonicalizeCombatTurn(&response.CombatTurns[index]) + } + sort.SliceStable(response.CombatTurns, func(i, j int) bool { + left, leftOK := earliestSourcePosition(doc, response.CombatTurns[i]) + right, rightOK := earliestSourcePosition(doc, response.CombatTurns[j]) + if leftOK != rightOK { + return leftOK + } + if !leftOK { + return false + } + return left < right + }) +} + +func canonicalizeCombatTurn(turn *combatTurnResponse) { + if turn == nil { + return + } + sort.SliceStable(turn.SourceRefs, func(i, j int) bool { + left := turn.SourceRefs[i] + right := turn.SourceRefs[j] + if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) { + return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID) + } + return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID) + }) + turn.SourceRefs = dedupeSourceRefs(turn.SourceRefs) +} + +func dedupeSourceRefs(refs []combatSourceRefResponse) []combatSourceRefResponse { + if len(refs) < 2 { + return refs + } + out := refs[:0] + var previous combatSourceRefResponse + for index, ref := range refs { + if index > 0 && sameSourceRef(previous, ref) { + continue + } + out = append(out, ref) + previous = ref + } + return out +} + +func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool { + return left.StartUnitID.Int() == right.StartUnitID.Int() && left.EndUnitID.Int() == right.EndUnitID.Int() +} + +func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) { + if doc == nil { + return 0, false + } + earliest := 0 + found := false + for _, ref := range turn.SourceRefs { + candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()} + if err := source.ValidateRef(doc, candidate); err != nil { + continue + } + index, ok := source.UnitIndex(doc, candidate.StartUnitID) + if !ok || (found && index >= earliest) { + continue + } + earliest = index + found = true + } + return earliest, found +} + +func unitSortValue(ref interface{ Int() int }) int { + value := ref.Int() + if value <= 0 { + return int(^uint(0) >> 1) + } + return value +} + +func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.CombatTurnList { + turns := make([]dnd.CombatTurn, len(response.CombatTurns)) + for index, turn := range response.CombatTurns { + turns[index] = dnd.CombatTurn{ + Actor: turn.Actor, + TurnKind: dnd.CombatTurnKind(turn.TurnKind), + Round: cloneIntPointer(turn.Round), + Actions: canonicalActions(turn.Actions), + Summary: turn.Summary, + SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID), + } + } + if response.CombatTurns == nil { + turns = nil + } + return dnd.CombatTurnList{CombatTurns: turns} +} + +func canonicalActions(actions []combatActionResponse) []dnd.CombatAction { + if actions == nil { + return nil + } + out := make([]dnd.CombatAction, len(actions)) + for index, action := range actions { + out[index] = dnd.CombatAction{ + Category: dnd.CombatActionCategory(action.Category), + Declaration: action.Declaration, + Targets: append([]string(nil), action.Targets...), + Resolution: cloneStringPointer(action.Resolution), + } + if action.Targets != nil { + out[index].Targets = append([]string{}, action.Targets...) + } + } + return out +} + +func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []source.SourceRef { + if refs == nil { + return nil + } + out := make([]source.SourceRef, len(refs)) + for index, ref := range refs { + out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()} + } + return out +} + +func cloneIntPointer(value *int) *int { + if value == nil { + return nil + } + out := *value + return &out +} + +func cloneStringPointer(value *string) *string { + if value == nil { + return nil + } + out := *value + return &out +} diff --git a/internal/modules/dnd/extract/combatturns/extractor.go b/internal/modules/dnd/extract/combatturns/extractor.go new file mode 100644 index 0000000..8782671 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/extractor.go @@ -0,0 +1,239 @@ +package combatturns + +import ( + "bytes" + "context" + "fmt" + "sort" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" +) + +const ( + Key = "dnd/combat-turns" + ArtifactType = "dnd.combat_turn" + mappingPolicy = "dnd.combat_turns.extract_mapping.v1" +) + +const ( + NPCRegistryReferenceSlot = npcregistry.ReferenceSlot + NPCRegistryMaxBytes = npcregistry.MaxBytes +) + +var requiredCapabilities = []string{ + "chunks", + "source.transcript", +} + +var providedCapabilities = []string{ + "dnd.combat_turns", +} + +var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ + Glossary: "Optional campaign glossary reference material used only for disambiguation.", + Party: "Optional party roster reference material used only for disambiguation.", + Players: "Optional player list reference material used only for disambiguation.", + Roster: "Deprecated alias for party roster reference material used only for disambiguation.", +} + +func referenceSlots() []contracts.ReferenceSlot { + slots := shared.ReferenceSlots(referenceSlotDescriptions) + slots = append(slots, contracts.ReferenceSlot{ + Name: NPCRegistryReferenceSlot, + Description: "Optional normalized NPC registry used for canonical actor and target grounding.", + AcceptedMediaTypes: []string{"application/json"}, + MaxBytes: NPCRegistryMaxBytes, + }) + sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name }) + return slots +} + +var _ contracts.Extractor[dnd.CombatTurnList] = (*Extractor)(nil) +var _ contracts.ManifestMetadataProvider = (*Extractor)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) + +type Options struct{} + +type Extractor struct { + llm contracts.StructuredLLMClient + npcRegistry *npcregistry.Registry + promptSHA string + responseSchemaSHA string +} + +func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contracts.ReferenceSet) (*Extractor, error) { + if llmClient == nil { + return nil, extractorErrorf("LLM client must not be nil") + } + if len(references) > 1 { + return nil, extractorErrorf("at most one reference set may be supplied") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + npcRegistry, err := npcregistry.Resolve(referenceSet) + if err != nil { + return nil, extractorErrorf("prepare NPC registry prompt input: %w", err) + } + promptSHA, err := scriptoriumPromptMetadata() + if err != nil { + return nil, extractorErrorf("load prompt metadata: %w", err) + } + responseSchema, err := loadResponseSchema() + if err != nil { + return nil, extractorErrorf("load response schema: %w", err) + } + return &Extractor{ + llm: llmClient, + npcRegistry: npcRegistry, + promptSHA: promptSHA, + responseSchemaSHA: responseSchema.SHA256, + }, nil +} + +func (e *Extractor) Key() string { return Key } + +func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() } + +func (e *Extractor) ManifestMetadata() map[string]any { + if e == nil { + return nil + } + metadata := map[string]any{ + "prompt_id": PromptID, + "prompt_version": SchemaVersion, + "prompt_sha256": e.promptSHA, + "mapping_policy": mappingPolicy, + "response_schema_key": string(ResponseSchemaKey), + "response_schema_id": ResponseSchemaID, + "response_schema_name": ResponseSchemaName, + "response_schema_version": SchemaVersion, + "response_schema_sha256": e.responseSchemaSHA, + } + if e.npcRegistry.Bound() { + metadata["npc_registry_digest"] = e.npcRegistry.Digest() + metadata["npc_count"] = e.npcRegistry.Count() + } + return metadata +} + +func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if e == nil { + return nil + } + fingerprints := []pipeline.CheckpointFingerprint{ + {Name: "prompt", Value: e.promptSHA}, + {Name: "response_schema", Value: e.responseSchemaSHA}, + {Name: "mapping_policy", Value: mappingPolicy}, + } + if e.npcRegistry.Bound() { + fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: e.npcRegistry.Digest()}) + } + return fingerprints +} + +func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.CombatTurnList], error) { + if e == nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("extractor must not be nil") + } + if e.llm == nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("LLM client must not be nil") + } + if ctx == nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context must not be nil") + } + if err := ctx.Err(); err != nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context error before extraction: %w", err) + } + if req.Source == nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("source must not be nil") + } + if req.Chunk == nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk must not be nil") + } + if len(req.Chunk.Units) == 0 { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) + } + sourceInput, err := chunkSourceInput(req) + if err != nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, err + } + + var response extractionResponse + inputs := shared.PromptInputs(sourceInput, req.References) + inputs[NPCRegistryReferenceSlot] = e.npcRegistry.PromptInput() + if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ + StageName: Key, + PromptID: PromptID, + PromptVersion: SchemaVersion, + ProfileID: req.LLMProfile, + SessionID: req.SessionID, + Inputs: inputs, + }, &response); err != nil { + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("complete structured output: %w", err) + } + canonicalizeResponse(&response, req.Source) + return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil +} + +func chunkSourceInput(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) { + material := req.SourceInput.Clone() + if len(material.Content) == 0 { + material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "") + } + if !bytes.Equal(material.Content, req.Chunk.Content) { + return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID) + } + if material.Name == "" { + material.Name = "source" + } + if material.MediaType == "" { + material.MediaType = req.Chunk.MediaType + } + if material.SizeBytes == 0 { + material.SizeBytes = int64(len(material.Content)) + } + return material, nil +} + +func ModuleSpec() pipeline.ModuleSpec { + return pipeline.ModuleSpec{ + Key: Key, + Stage: pipeline.StageExtract, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ArtifactKind: dnd.CombatTurnListKind, + ReferenceSlots: referenceSlots(), + } +} + +func Register(registry *pipeline.ExtractorRegistry) error { + return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.CombatTurnList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(request.Dependencies.LLM, options, request.References) + }) +} + +func validateOptions(options map[string]any) error { + _, err := DecodeOptions(options) + return err +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, extractorErrorf("%w", err) + } + return Options{}, nil +} + +func extractorErrorf(format string, args ...any) error { + return fmt.Errorf("dnd combat turns extractor: "+format, args...) +} diff --git a/internal/modules/dnd/extract/combatturns/extractor_test.go b/internal/modules/dnd/extract/combatturns/extractor_test.go new file mode 100644 index 0000000..4692864 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/extractor_test.go @@ -0,0 +1,367 @@ +package combatturns + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "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" + npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" +) + +func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) { + round := 3 + resolution := "The ogre falls back." + client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{ + { + Actor: "Borin", TurnKind: "turn", Round: &round, + Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}}, + Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)}}, + }, + { + Actor: "Aria", TurnKind: "reaction", Round: nil, + Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}}, + Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{ + {StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)}, + {StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)}, + }, + }, + { + Actor: "Unknown", TurnKind: "other", Round: nil, + Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}}, + Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromString("missing"), EndUnitID: shared.UnitRefFromString("missing")}}, + }, + }}} + + result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) + if err != nil { + t.Fatalf("Extract() error = %v, want nil", err) + } + if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor, result.Value.CombatTurns[2].Actor}; !reflect.DeepEqual(got, []string{"Aria", "Borin", "Unknown"}) { + t.Fatalf("actor order = %#v, want source-position order with invalid evidence last", got) + } + wantRefs := []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 10, EndUnitID: 10}} + if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) { + t.Fatalf("canonical refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs) + } + if result.Value.CombatTurns[0].Round != nil || result.Value.CombatTurns[0].Actions[0].Resolution == nil || *result.Value.CombatTurns[0].Actions[0].Resolution != resolution { + t.Fatalf("nullable fields = %#v, want nil round and preserved resolution", result.Value.CombatTurns[0]) + } + if ref := result.Value.CombatTurns[2].SourceRefs[0]; ref != (source.SourceRef{SourceID: "session-alpha"}) { + t.Fatalf("invalid evidence = %#v, want source identity and invalid range preserved", ref) + } + + if len(client.requests) != 1 { + t.Fatalf("LLM calls = %d, want one call per chunk", len(client.requests)) + } + request := client.requests[0] + if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.SessionID != "session-123" || request.ProfileID != "profile-combat" { + t.Fatalf("LLM request identity = %#v", request) + } + transcript := request.Inputs["transcript"] + if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" || !reflect.DeepEqual(transcript.Content, extractionRequest().Chunk.Content) { + t.Fatalf("transcript input = %#v, want chunk-scoped source input", transcript) + } +} + +func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) { + negativeRound := -1 + emptyResolution := " " + client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{ + { + Actor: " ", TurnKind: "unsupported", Round: &negativeRound, + Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}}, + Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("not-a-unit")}}, + }, + }}} + result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) + if err != nil { + t.Fatalf("Extract() error = %v, want nil for candidate values", err) + } + turn := result.Value.CombatTurns[0] + if turn.Actor != " " || turn.TurnKind != "unsupported" || turn.Round == nil || *turn.Round != negativeRound || turn.Summary != " " { + t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn) + } + if turn.Actions == nil || turn.Actions[0].Targets != nil || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != emptyResolution { + t.Fatalf("invalid action fields = %#v, want preserved candidate values", turn.Actions[0]) + } + if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) { + t.Fatalf("invalid source ref = %#v, want invalid range preserved", turn.SourceRefs[0]) + } +} + +func TestExtractPassesReferencesAndNPCGroundingWithoutUsingItAsEvidence(t *testing.T) { + client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}} + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Alice: Aria")}}}, + "party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria: cleric")}}}, + "glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("ogre: a large foe")}}}, + NPCRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: "application/json", Content: npcRegistryJSON(t)}}}, + }} + req := extractionRequest() + req.References = references + if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err != nil { + t.Fatalf("Extract() error = %v, want nil", err) + } + inputs := client.requests[0].Inputs + if string(inputs["players"].Content) != "Alice: Aria" || string(inputs["party"].Content) != "Aria: cleric" || string(inputs["glossary"].Content) != "ogre: a large foe" { + t.Fatalf("reference inputs = %#v, want configured references", inputs) + } + registryInput := inputs[NPCRegistryReferenceSlot] + if registryInput.Name != NPCRegistryReferenceSlot || registryInput.MediaType != "application/json" || !strings.Contains(string(registryInput.Content), "Mira Thorn") { + t.Fatalf("NPC registry input = %#v, want canonical registry grounding", registryInput) + } + if strings.Contains(string(inputs["transcript"].Content), "Aria: cleric") { + t.Fatal("transcript input contains reference content") + } + + metadata := newExtractor(t, &fakeCombatTurnsLLMClient{}, references).ManifestMetadata() + if metadata["npc_count"] != 1 || !strings.HasPrefix(metadata["npc_registry_digest"].(string), "sha256:") { + t.Fatalf("bound registry metadata = %#v, want digest and count", metadata) + } + fingerprints := newExtractor(t, &fakeCombatTurnsLLMClient{}, references).CheckpointFingerprints() + if len(fingerprints) != 4 || fingerprints[3].Name != "npc_registry" { + t.Fatalf("bound fingerprints = %#v, want local identities plus NPC registry", fingerprints) + } + encoded, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "Mira Thorn") || strings.Contains(string(encoded), "session-alpha") { + t.Fatalf("metadata leaked content: %s", encoded) + } +} + +func TestExtractUnboundRegistryUsesExactEmptyPromptAndOmitsIdentity(t *testing.T) { + client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}} + extractor := newExtractor(t, client) + if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil { + t.Fatalf("Extract() error = %v, want nil", err) + } + input := client.requests[0].Inputs[NPCRegistryReferenceSlot] + if string(input.Content) != `{"npcs":[]}` || input.Digest != "" || input.OriginURI != "" { + t.Fatalf("unbound registry input = %#v, want exact empty prompt without identity", input) + } + metadata := extractor.ManifestMetadata() + if _, ok := metadata["npc_registry_digest"]; ok { + t.Fatalf("unbound metadata has registry digest: %#v", metadata) + } + for _, fingerprint := range extractor.CheckpointFingerprints() { + if fingerprint.Name == "npc_registry" { + t.Fatalf("unbound fingerprints include registry identity: %#v", extractor.CheckpointFingerprints()) + } + } +} + +func TestNewRejectsMalformedNPCRegistryBeforeLLMCallWithoutContent(t *testing.T) { + client := &fakeCombatTurnsLLMClient{} + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + NPCRegistryReferenceSlot: { + Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot}, + Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: "application/json", Content: []byte(`{"secret":"private transcript detail"}`)}}, + }, + }} + _, err := New(client, Options{}, references) + if err == nil || !strings.Contains(err.Error(), "prepare NPC registry") || strings.Contains(err.Error(), "private transcript detail") { + t.Fatalf("New() error = %v, want bounded content-free registry failure", err) + } + if len(client.requests) != 0 { + t.Fatalf("LLM calls = %d, want none during failed construction", len(client.requests)) + } +} + +func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) { + validReq := extractionRequest() + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + validExtractor := newExtractor(t, &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}}) + tests := []struct { + name string + extractor *Extractor + ctx context.Context + req contracts.TypedExtractionRequest + want string + }{ + {name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"}, + {name: "nil context", extractor: validExtractor, req: validReq, want: "context"}, + {name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"}, + {name: "nil source", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Chunk: validReq.Chunk}, want: "source"}, + {name: "nil chunk", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Source: validReq.Source}, want: "chunk"}, + {name: "empty chunk units", extractor: validExtractor, ctx: context.Background(), req: emptyChunkRequest(validReq), want: "units"}, + {name: "source input mismatch", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := test.extractor.Extract(test.ctx, test.req) + if err == nil || !strings.Contains(err.Error(), "dnd combat turns") || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Extract() error = %v, want %q context", err, test.want) + } + }) + } + _, err := newExtractor(t, &fakeCombatTurnsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), extractionRequest()) + if err == nil || !strings.Contains(err.Error(), "dnd combat turns") || !strings.Contains(err.Error(), "provider unavailable") { + t.Fatalf("provider error = %v, want contextual wrapped error", err) + } +} + +func TestExtractorManifestMetadataAndFingerprints(t *testing.T) { + extractor := newExtractor(t, &fakeCombatTurnsLLMClient{}) + metadata := extractor.ManifestMetadata() + for key, want := range map[string]string{ + "prompt_id": PromptID, "prompt_version": SchemaVersion, "mapping_policy": mappingPolicy, + "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID, + "response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, + } { + if metadata[key] != want { + t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want) + } + } + for _, key := range []string{"prompt_sha256", "response_schema_sha256"} { + value, ok := metadata[key].(string) + if !ok || !strings.HasPrefix(value, "sha256:") { + t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key]) + } + } + wantNames := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}} + for _, fingerprint := range extractor.CheckpointFingerprints() { + if _, ok := wantNames[fingerprint.Name]; !ok { + t.Fatalf("unexpected fingerprint = %#v", fingerprint) + } + delete(wantNames, fingerprint.Name) + } + if len(wantNames) != 0 { + t.Fatalf("missing fingerprints = %#v", wantNames) + } +} + +func TestModuleSpecAndRegistration(t *testing.T) { + wantSlots := referenceSlots() + got := ModuleSpec() + if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.combat_turns"}) || !reflect.DeepEqual(got.ReferenceSlots, wantSlots) { + t.Fatalf("ModuleSpec() = %#v, want combat extractor contract", got) + } + got.Requires[0] = "changed" + got.Provides[0] = "changed" + got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed" + again := ModuleSpec() + if again.Requires[0] == "changed" || again.Provides[0] == "changed" || again.ReferenceSlots[0].AcceptedMediaTypes[0] == "changed" { + t.Fatalf("ModuleSpec() returned shared mutable values: %#v", again) + } + registry := pipeline.NewExtractorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } + if _, ok := registry.Spec(Key); !ok { + t.Fatalf("registry missing spec for %q", Key) + } +} + +func extractionRequest() contracts.TypedExtractionRequest { + doc := combatSourceDocument() + chunk := &source.Chunk{ + ID: "session-alpha:chunk:0", + SourceID: doc.ID, + Index: 0, + Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 2}, + Content: []byte(`{"units":[10,2]}`), + MediaType: "application/json", + Units: append([]source.SourceUnit(nil), doc.Units...), + } + return contracts.TypedExtractionRequest{ + Source: doc, + Chunk: chunk, + SourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:chunk", "file:///session-alpha.json"), + SessionID: "session-123", + LLMProfile: "profile-combat", + } +} + +func combatSourceDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "session-alpha", Kind: "transcript", Format: "application/vnd.seriatim.minimal+json", Digest: "sha256:source", + Units: []source.SourceUnit{ + {ID: 10, Kind: "transcript_segment", Text: "Aria reacts and strikes the ogre.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 10, EndUnitID: 10}}, + {ID: 2, Kind: "transcript_segment", Text: "Borin retreats from the ogre.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}, + }, + } +} + +func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest { + req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index} + return req +} + +func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest { + req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"other":true}`), "sha256:other", "") + return req +} + +func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor { + t.Helper() + extractor, err := New(client, Options{}, references...) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return extractor +} + +func npcRegistryJSON(t *testing.T) []byte { + t.Helper() + value := dnd.NPCList{NPCs: []dnd.NPC{{ + ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, + Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}, + }}} + content, err := npccodec.New().Encode(value) + if err != nil { + t.Fatalf("encode NPC registry: %v", err) + } + return content +} + +type fakeCombatTurnsLLMClient struct { + response extractionResponse + err error + requests []contracts.StructuredCompletionRequest +} + +func (client *fakeCombatTurnsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { + client.requests = append(client.requests, cloneStructuredCompletionRequest(req)) + if client.err != nil { + return contracts.StructuredCompletionResponse{}, client.err + } + target, ok := out.(*extractionResponse) + if !ok { + return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target") + } + *target = client.response + content, err := json.Marshal(client.response) + if err != nil { + return contracts.StructuredCompletionResponse{}, err + } + return contracts.StructuredCompletionResponse{Content: content}, nil +} + +func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest { + req.Inputs = req.Inputs.Clone() + if len(req.Vars) == 0 { + req.Vars = nil + return req + } + vars := make(map[string]any, len(req.Vars)) + for key, value := range req.Vars { + vars[key] = value + } + req.Vars = vars + return req +} diff --git a/internal/modules/dnd/extract/combatturns/model.go b/internal/modules/dnd/extract/combatturns/model.go new file mode 100644 index 0000000..1ee2363 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/model.go @@ -0,0 +1,28 @@ +package combatturns + +import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" + +type extractionResponse struct { + CombatTurns []combatTurnResponse `json:"combat_turns"` +} + +type combatTurnResponse struct { + Actor string `json:"actor"` + TurnKind string `json:"turn_kind"` + Round *int `json:"round"` + Actions []combatActionResponse `json:"actions"` + Summary string `json:"summary"` + SourceRefs []combatSourceRefResponse `json:"source_refs"` +} + +type combatActionResponse struct { + Category string `json:"category"` + Declaration string `json:"declaration"` + Targets []string `json:"targets"` + Resolution *string `json:"resolution"` +} + +type combatSourceRefResponse struct { + StartUnitID shared.UnitRef `json:"start_unit_id"` + EndUnitID shared.UnitRef `json:"end_unit_id"` +} diff --git a/internal/modules/dnd/extract/combatturns/schema.go b/internal/modules/dnd/extract/combatturns/schema.go new file mode 100644 index 0000000..a1c7410 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/schema.go @@ -0,0 +1,21 @@ +package combatturns + +import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" + +const ( + PromptID = "dnd.combat_turns" + ResponseSchemaKey = llm.ResponseSchemaKey("dnd_combat_turns_llm") + ResponseSchemaID = "notarius.dnd.combat_turns.llm" + ResponseSchemaName = "notarius_dnd_combat_turns_llm_v1" + SchemaVersion = "v1" +) + +func loadResponseSchema() (llm.ResponseSchema, error) { + return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{ + Key: ResponseSchemaKey, + ID: ResponseSchemaID, + Version: SchemaVersion, + Name: ResponseSchemaName, + AssetPath: "assets/schemas/dnd_combat_turns_llm.v1.json", + }) +} diff --git a/internal/modules/dnd/extract/combatturns/schema_test.go b/internal/modules/dnd/extract/combatturns/schema_test.go new file mode 100644 index 0000000..1868e83 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/schema_test.go @@ -0,0 +1,99 @@ +package combatturns + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) { + schema, err := loadResponseSchema() + if err != nil { + t.Fatalf("loadResponseSchema() error = %v", err) + } + if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Version != SchemaVersion || schema.Name != ResponseSchemaName || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) { + t.Fatalf("schema metadata = %#v, want private combat schema identity", schema) + } + valid := validCombatResponse() + content, err := json.Marshal(valid) + if err != nil { + t.Fatal(err) + } + if err := validateJSONSchema(content, schema.JSONSchema); err != nil { + t.Fatalf("valid combat response rejected: %v", err) + } + + withSourceID := validCombatResponse() + withSourceID["combat_turns"].([]any)[0].(map[string]any)["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session-alpha" + content, err = json.Marshal(withSourceID) + if err != nil { + t.Fatal(err) + } + if err := validateJSONSchema(content, schema.JSONSchema); err == nil { + t.Fatal("response schema accepted source_id, want private source-reference shape") + } +} + +func TestResponseSchemaJSONIsMutationSafe(t *testing.T) { + first, err := loadResponseSchema() + if err != nil { + t.Fatal(err) + } + first.JSONSchema[0] = '[' + second, err := loadResponseSchema() + if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) { + t.Fatalf("second schema = %s, %v; want defensive valid copy", second.JSONSchema, err) + } +} + +func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) { + schema, err := loadResponseSchema() + if err != nil { + t.Fatal(err) + } + diagnostics := schema.DiagnosticsMap() + if diagnostics["key"] != ResponseSchemaKey || diagnostics["id"] != ResponseSchemaID { + t.Fatalf("diagnostics = %#v, want schema identity", diagnostics) + } + if _, ok := diagnostics["json_schema"]; ok { + t.Fatalf("diagnostics include raw schema: %#v", diagnostics) + } +} + +func validCombatResponse() map[string]any { + return map[string]any{ + "combat_turns": []any{ + map[string]any{ + "actor": "Aria", "turn_kind": "reaction", "round": nil, + "actions": []any{map[string]any{ + "category": "attack", "declaration": "Aria strikes", "targets": []any{}, "resolution": nil, + }}, + "summary": "Aria reacts.", + "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, + }, + }, + } +} + +func validateJSONSchema(instanceContent, schemaContent []byte) error { + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent)) + if err != nil { + return err + } + schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent)) + if err != nil { + return err + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("schema.json", schemaDocument); err != nil { + return err + } + schema, err := compiler.Compile("schema.json") + if err != nil { + return err + } + return schema.Validate(instance) +} diff --git a/internal/modules/dnd/extract/combatturns/scriptorium_assets.go b/internal/modules/dnd/extract/combatturns/scriptorium_assets.go new file mode 100644 index 0000000..14d9ff0 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/scriptorium_assets.go @@ -0,0 +1,45 @@ +package combatturns + +import ( + "fmt" + "sync" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" + "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" +) + +const scriptoriumPromptRoot = "assets/prompts" + +func RegisterPromptAssets(registry *llm.AssetRegistry) error { + promptFS, err := shared.ModulePromptFS("dnd.combat_turns", embeddedAssets, []promptfs.ModulePromptFile{ + {Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"}, + {Name: "task.md", Path: "assets/prompts/task.md"}, + {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, + }) + if err != nil { + return fmt.Errorf("prepare combat-turn prompt assets: %w", err) + } + if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil { + return err + } + return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas") +} + +func scriptoriumPromptMetadata() (string, error) { + scriptoriumPromptHashOnce.Do(func() { + parts := append([]llm.AssetHashPart{ + {FS: embeddedAssets, Path: "assets/prompts/dnd.combat_turns.yaml"}, + {FS: embeddedAssets, Path: "assets/prompts/task.md"}, + {FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, + }, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...) + scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) + }) + return scriptoriumPromptHash, scriptoriumPromptHashErr +} + +var ( + scriptoriumPromptHashOnce sync.Once + scriptoriumPromptHash string + scriptoriumPromptHashErr error +) diff --git a/internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go b/internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go new file mode 100644 index 0000000..4b94af2 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go @@ -0,0 +1,44 @@ +package combatturns + +import ( + "io/fs" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" +) + +func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) { + registry := llm.NewAssetRegistry() + if err := RegisterPromptAssets(registry); err != nil { + t.Fatalf("RegisterPromptAssets() error = %v", err) + } + promptFS, err := registry.PromptFS() + if err != nil { + t.Fatal(err) + } + for _, path := range []string{ + "dnd.combat_turns/dnd.combat_turns.yaml", + "dnd.combat_turns/task.md", + "dnd.combat_turns/instructions.md", + "dnd.combat_turns/sharedassets/common-dnd-system.md", + "dnd.combat_turns/sharedassets/common-dnd-transcript.md", + "dnd.combat_turns/sharedassets/common-dnd-references.md", + "dnd.combat_turns/sharedassets/common-dnd-npcs.md", + } { + if _, err := fs.ReadFile(promptFS, path); err != nil { + t.Fatalf("prompt asset %q: %v", path, err) + } + } + schemaFS, err := registry.SchemaFS() + if err != nil { + t.Fatal(err) + } + if _, err := fs.ReadFile(schemaFS, "dnd_combat_turns_llm.v1.json"); err != nil { + t.Fatalf("response schema asset: %v", err) + } + hash, err := scriptoriumPromptMetadata() + if err != nil || !strings.HasPrefix(hash, "sha256:") { + t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want digest", hash, err) + } +} diff --git a/internal/modules/dnd/validate/combatturns/shape/validator.go b/internal/modules/dnd/validate/combatturns/shape/validator.go new file mode 100644 index 0000000..c421bdb --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/shape/validator.go @@ -0,0 +1,143 @@ +package shape + +import ( + "context" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" +) + +const ( + Key = "extract/dnd/combat-turns/shape" + ReasonCode = "invalid_combat_turn_shape" + policy = "dnd.combat_turns.validator.shape.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.CombatTurnList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) + +func New(Options) *Validator { return &Validator{} } +func (v *Validator) Name() string { return Key } +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) { + if err := Validate(req.Value); err != nil { + return rejection(err.Error()), nil + } + return contracts.ValidationResult{Approved: true}, nil +} + +func Validate(value dnd.CombatTurnList) error { + issues := issuesFor(value) + if len(issues) == 0 { + return nil + } + return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn shape", issues)) +} + +func issuesFor(value dnd.CombatTurnList) []string { + if value.CombatTurns == nil { + return []string{"combat_turns must be present"} + } + issues := make([]string, 0) + for turnIndex, turn := range value.CombatTurns { + prefix := fmt.Sprintf("combat_turns[%d]", turnIndex) + if strings.TrimSpace(turn.Actor) == "" { + issues = append(issues, prefix+".actor must not be empty: "+diagnostics.Quote(turn.Actor)) + } + if !validTurnKind(turn.TurnKind) { + issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind))) + } + if turn.Round != nil && *turn.Round <= 0 { + issues = append(issues, prefix+".round must be positive or null") + } + if len(turn.Actions) == 0 { + issues = append(issues, prefix+".actions must contain at least one action") + } + if strings.TrimSpace(turn.Summary) == "" { + issues = append(issues, prefix+".summary must not be empty: "+diagnostics.Quote(turn.Summary)) + } + if len(turn.SourceRefs) == 0 { + issues = append(issues, prefix+".source_refs must contain at least one reference") + } + for actionIndex, action := range turn.Actions { + actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex) + if !validActionCategory(action.Category) { + issues = append(issues, actionPrefix+".category is unsupported: "+diagnostics.Quote(string(action.Category))) + } + if strings.TrimSpace(action.Declaration) == "" { + issues = append(issues, actionPrefix+".declaration must not be empty: "+diagnostics.Quote(action.Declaration)) + } + if action.Targets == nil { + issues = append(issues, actionPrefix+".targets must be present") + } else { + for targetIndex, target := range action.Targets { + if strings.TrimSpace(target) == "" { + issues = append(issues, fmt.Sprintf("%s.targets[%d] must not be empty: %s", actionPrefix, targetIndex, diagnostics.Quote(target))) + } + } + } + if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" { + issues = append(issues, actionPrefix+".resolution must not be empty or null: "+diagnostics.Quote(*action.Resolution)) + } + } + } + return issues +} + +func validTurnKind(value dnd.CombatTurnKind) bool { + switch value { + case dnd.CombatTurnKindTurn, dnd.CombatTurnKindReaction, dnd.CombatTurnKindLegendaryAction, dnd.CombatTurnKindLairAction, dnd.CombatTurnKindOther: + return true + default: + return false + } +} + +func validActionCategory(value dnd.CombatActionCategory) bool { + switch value { + case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther: + return true + default: + return false + } +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options), nil + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } + +func rejection(message string) contracts.ValidationResult { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message} +} diff --git a/internal/modules/dnd/validate/combatturns/shape/validator_test.go b/internal/modules/dnd/validate/combatturns/shape/validator_test.go new file mode 100644 index 0000000..3703015 --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/shape/validator_test.go @@ -0,0 +1,95 @@ +package shape + +import ( + "context" + "strings" + "testing" + "unicode/utf8" + + "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" +) + +func TestValidatorApprovesWellFormedCombatTurnList(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: validCombatTurnList()}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err) + } +} + +func TestValidateRejectsEveryOwnedShapeBoundary(t *testing.T) { + tests := []struct { + name string + mutate func(*dnd.CombatTurnList) + want string + }{ + {name: "missing combat turns", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns = nil }, want: "combat_turns must be present"}, + {name: "empty actor", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }, want: "actor must not be empty"}, + {name: "unsupported turn kind", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unknown" }, want: "turn_kind is unsupported"}, + {name: "non-positive round", mutate: func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }, want: "round must be positive"}, + {name: "missing actions", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }, want: "actions must contain"}, + {name: "empty summary", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }, want: "summary must not be empty"}, + {name: "missing source refs", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }, want: "source_refs must contain"}, + {name: "unsupported category", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unknown" }, want: "category is unsupported"}, + {name: "empty declaration", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }, want: "declaration must not be empty"}, + {name: "missing targets", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }, want: "targets must be present"}, + {name: "empty target", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }, want: "targets[0] must not be empty"}, + {name: "empty resolution", mutate: func(value *dnd.CombatTurnList) { + resolution := " " + value.CombatTurns[0].Actions[0].Resolution = &resolution + }, want: "resolution must not be empty"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := validCombatTurnList() + test.mutate(&value) + if err := Validate(value); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want %q", err, test.want) + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode { + t.Fatalf("Validator result = %#v, %v; want shape rejection", result, err) + } + }) + } +} + +func TestValidatorBoundsAggregateDiagnostics(t *testing.T) { + value := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 30)} + for index := range value.CombatTurns { + value.CombatTurns[index].Actor = strings.Repeat("火", 300) + value.CombatTurns[index].TurnKind = "invalid" + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: value}) + if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") { + t.Fatalf("bounded result = %#v, %v; want safe bounded diagnostics", result, err) + } +} + +func TestSpecRegisterOptionsAndPolicy(t *testing.T) { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v, want shape policy", got) + } + if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v, want deterministic shape validator", Spec()) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validCombatTurnList() dnd.CombatTurnList { + round := 2 + resolution := "The goblin is wounded." + return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: &round, + Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}}, + Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} +} diff --git a/internal/modules/dnd/validate/combatturns/source_refs/validator.go b/internal/modules/dnd/validate/combatturns/source_refs/validator.go new file mode 100644 index 0000000..590b5ca --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/source_refs/validator.go @@ -0,0 +1,84 @@ +package sourcerefs + +import ( + "context" + "fmt" + + "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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" +) + +const ( + Key = "extract/dnd/combat-turns/source_refs" + ReasonCode = "invalid_combat_turn_source_refs" + policy = "dnd.combat_turns.validator.source_refs.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.CombatTurnList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) + +func New(Options) *Validator { return &Validator{} } +func (v *Validator) Name() string { return Key } +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) { + if err := combatshape.Validate(req.Value); err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + issues := sourceRefIssues(req.Source, req.Value) + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{ + Approved: false, + ReasonCode: ReasonCode, + Message: diagnostics.Aggregate("invalid combat turn source references", issues), + }, nil +} + +func sourceRefIssues(doc *source.SourceDocument, value dnd.CombatTurnList) []string { + issues := make([]string, 0) + for turnIndex, turn := range value.CombatTurns { + for refIndex, ref := range turn.SourceRefs { + if err := source.ValidateRef(doc, ref); err != nil { + issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error()))) + } + } + } + return issues +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options), nil + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } diff --git a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go new file mode 100644 index 0000000..5ae2b0c --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go @@ -0,0 +1,87 @@ +package sourcerefs + +import ( + "context" + "strings" + "testing" + + "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" +) + +func TestValidatorApprovesValidSourceReferences(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: validCombatTurnList()}) + if err != nil || !result.Approved || result.ReasonCode != "" { + t.Fatalf("Validate() = %#v, %v; want approval", result, err) + } +} + +func TestValidatorRejectsInvalidSourceIdentityExistenceAndOrder(t *testing.T) { + tests := []struct { + name string + ref source.SourceRef + want string + }{ + {name: "missing document", ref: source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, want: "source document must not be nil"}, + {name: "wrong source identity", ref: source.SourceRef{SourceID: "other", StartUnitID: 1, EndUnitID: 1}, want: "does not match document"}, + {name: "missing start", ref: source.SourceRef{SourceID: "session", StartUnitID: 99, EndUnitID: 99}, want: "start_unit_id 99 was not found"}, + {name: "backward range", ref: source.SourceRef{SourceID: "session", StartUnitID: 3, EndUnitID: 1}, want: "appears after end_unit_id"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := validCombatTurnList() + value.CombatTurns[0].SourceRefs[0] = test.ref + req := contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value} + if test.name == "missing document" { + req.Source = nil + } + result, err := New(Options{}).Validate(context.Background(), req) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) { + t.Fatalf("Validate() = %#v, %v; want source-reference rejection containing %q", result, err, test.want) + } + }) + } +} + +func TestValidatorDefersMalformedShape(t *testing.T) { + value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("shape deferral = %#v, %v; want approval without source warning", result, err) + } +} + +func TestSpecRegisterOptionsAndPolicy(t *testing.T) { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got) + } + if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v, want deterministic source-reference validator", Spec()) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validCombatTurnList() dnd.CombatTurnList { + resolution := "The goblin is wounded." + return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, + Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}}, + Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} +} + +func validDocument() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{ + {ID: 1, Kind: "message", Text: "Aria attacks."}, + {ID: 2, Kind: "message", Text: "The goblin reels."}, + {ID: 3, Kind: "message", Text: "Borin retreats."}, + }} +} diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go new file mode 100644 index 0000000..04f424e --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go @@ -0,0 +1,156 @@ +package sourcerelatedness + +import ( + "context" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" +) + +const ( + Key = "extract/dnd/combat-turns/source_relatedness" + WarningReasonCode = "combat_turn_not_near_source" + policy = "dnd.combat_turns.validator.source_relatedness.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.CombatTurnList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) + +func New(Options) *Validator { return &Validator{} } +func (v *Validator) Name() string { return Key } +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) { + if combatshape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) { + return contracts.ValidationResult{Approved: true}, nil + } + + warnings := make([]contracts.Warning, 0) + for turnIndex, turn := range req.Value.CombatTurns { + citedText := citedTextKey(req.Source, turn.SourceRefs) + issues := make([]string, 0) + if !actorAppearsInCitedText(citedText, turn.Actor) { + issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor))) + } + for actionIndex, action := range turn.Actions { + if !declarationAppearsInCitedText(citedText, action.Declaration) { + issues = append(issues, fmt.Sprintf("action %d declaration %s was not found in cited source text", actionIndex, diagnostics.Quote(action.Declaration))) + } + } + if len(issues) == 0 { + continue + } + warnings = append(warnings, contracts.Warning{ + Scope: fmt.Sprintf("combat_turns[%d]", turnIndex), + ReasonCode: WarningReasonCode, + Message: diagnostics.Aggregate("combat turn not near source", issues), + }) + } + return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil +} + +func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool { + for _, turn := range value.CombatTurns { + for _, ref := range turn.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + return false + } + } + } + return true +} + +func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string { + if doc == nil { + return "" + } + included := make([]bool, len(doc.Units)) + for _, ref := range refs { + start, _ := source.UnitIndex(doc, ref.StartUnitID) + end, _ := source.UnitIndex(doc, ref.EndUnitID) + for index := start; index <= end && index < len(included); index++ { + included[index] = true + } + } + parts := make([]string, 0) + for index, unit := range doc.Units { + if included[index] { + parts = append(parts, unit.Text) + } + } + return identity.ComparisonKey(strings.Join(parts, " ")) +} + +func actorAppearsInCitedText(citedText string, actor string) bool { + key := identity.ComparisonKey(actor) + return key != "" && strings.Contains(citedText, key) +} + +func declarationAppearsInCitedText(citedText string, declaration string) bool { + citedTokens := tokenSet(citedText) + for _, token := range comparisonTokens(declaration) { + if utf8.RuneCountInString(token) >= 4 { + if _, ok := citedTokens[token]; ok { + return true + } + } + } + return false +} + +func comparisonTokens(value string) []string { + value = identity.ComparisonKey(value) + if value == "" { + return nil + } + return strings.FieldsFunc(value, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) +} + +func tokenSet(value string) map[string]struct{} { + tokens := comparisonTokens(value) + set := make(map[string]struct{}, len(tokens)) + for _, token := range tokens { + set[token] = struct{}{} + } + return set +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options), nil + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go new file mode 100644 index 0000000..bdbe5cb --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go @@ -0,0 +1,101 @@ +package sourcerelatedness + +import ( + "context" + "strings" + "testing" + "unicode/utf8" + + "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" +) + +func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t *testing.T) { + resolution := "The goblin is hit." + value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn, + Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "O'Rin attacks", Targets: []string{"unmentioned target"}, Resolution: &resolution}}, + Summary: "O'Rin attacks.", SourceRefs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 2, EndUnitID: 2}, + {SourceID: "session", StartUnitID: 1, EndUnitID: 2}, + }, + }}} + doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{ + {ID: 1, Kind: "message", Text: "o’rin\u2003thorn advances."}, + {ID: 2, Kind: "message", Text: "O’rin attacks the goblin."}, + }} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("Validate() = %#v, %v; want Unicode-related approval without target warning", result, err) + } +} + +func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) { + value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction, + Actions: []dnd.CombatAction{ + {Category: dnd.CombatActionCategoryOther, Declaration: "hit", Targets: []string{}, Resolution: nil}, + {Category: dnd.CombatActionCategoryOther, Declaration: "unseen monster", Targets: []string{}, Resolution: nil}, + }, + Summary: "An unrelated event.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 1 { + t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err) + } + warning := result.Warnings[0] + if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !strings.Contains(warning.Message, "action 0") || !strings.Contains(warning.Message, "action 1") || !utf8.ValidString(warning.Message) { + t.Fatalf("warning = %#v, want one safely quoted bounded warning", warning) + } +} + +func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { + invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("shape deferral = %#v, %v; want approval without warning", result, err) + } + invalidRange := validCombatTurnList() + invalidRange.CombatTurns[0].SourceRefs[0].StartUnitID = 99 + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidRange}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("invalid-range deferral = %#v, %v; want approval without warning", result, err) + } +} + +func TestValidatorIgnoresReferenceMaterialAndRegistersPolicy(t *testing.T) { + value := validCombatTurnList() + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Aria attacks")}}}}} + doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party waits."}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, References: references, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 1 { + t.Fatalf("reference-only relatedness = %#v, %v; want warning from transcript-only evidence", result, err) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v, want relatedness policy", got) + } + if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v, want deterministic relatedness validator", Spec()) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validCombatTurnList() dnd.CombatTurnList { + return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, + Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"absent target"}, Resolution: nil}}, + Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} +} + +func relatednessDocument() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party waits."}}} +}