Add standalone D&D combat turn extraction
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
6
internal/modules/dnd/extract/combatturns/assets.go
Normal file
6
internal/modules/dnd/extract/combatturns/assets.go
Normal 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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
internal/modules/dnd/extract/combatturns/canonicalize.go
Normal file
156
internal/modules/dnd/extract/combatturns/canonicalize.go
Normal 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
|
||||
}
|
||||
239
internal/modules/dnd/extract/combatturns/extractor.go
Normal file
239
internal/modules/dnd/extract/combatturns/extractor.go
Normal 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...)
|
||||
}
|
||||
367
internal/modules/dnd/extract/combatturns/extractor_test.go
Normal file
367
internal/modules/dnd/extract/combatturns/extractor_test.go
Normal 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
|
||||
}
|
||||
28
internal/modules/dnd/extract/combatturns/model.go
Normal file
28
internal/modules/dnd/extract/combatturns/model.go
Normal 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"`
|
||||
}
|
||||
21
internal/modules/dnd/extract/combatturns/schema.go
Normal file
21
internal/modules/dnd/extract/combatturns/schema.go
Normal 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",
|
||||
})
|
||||
}
|
||||
99
internal/modules/dnd/extract/combatturns/schema_test.go
Normal file
99
internal/modules/dnd/extract/combatturns/schema_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
143
internal/modules/dnd/validate/combatturns/shape/validator.go
Normal file
143
internal/modules/dnd/validate/combatturns/shape/validator.go
Normal 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}
|
||||
}
|
||||
@@ -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}},
|
||||
}}}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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."},
|
||||
}}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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."}}}
|
||||
}
|
||||
Reference in New Issue
Block a user