Add standalone D&D combat turn extraction

This commit is contained in:
2026-07-21 05:08:02 +00:00
parent 6dd695611c
commit 2fbb3813aa
22 changed files with 1924 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "orin\u2003thorn advances."},
{ID: 2, Kind: "message", Text: "Orin 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."}}}
}