Compare commits

...

8 Commits

55 changed files with 1608 additions and 984 deletions

View File

@@ -98,23 +98,41 @@ D&D prompt content. `internal/framework/promptfs` provides the domain-neutral
filesystem composition helper used to combine module-owned files with shared filesystem composition helper used to combine module-owned files with shared
domain prompt fragments. domain prompt fragments.
The D&D scene chunker and spell, NPC, and combat-turn extractors each declare an
ordered prompt asset manifest. The manifest lists the package-owned YAML and
Markdown files, then the exact shared fragments rendered by that prompt; the
same ordered list drives both filesystem mounting and the prompt fingerprint.
The three extraction prompts share system, extraction evidence, in-world
identity, transcript, and campaign-reference messages. Spell and combat prompts
then render immediate resolution and the ephemeral NPC registry before their
lane-specific material; the NPC prompt renders its task and instructions
instead. The scene prompt uses system, transcript, and campaign-reference
messages before its task and instructions. Identity, transcript, and
campaign-reference messages are ephemeral in the extraction prompts; the scene
prompt marks transcript and campaign references ephemeral, and the NPC registry
is ephemeral where spell and combat prompts use it. Unused shared assets are
neither mounted nor fingerprinted.
Schema helpers load embedded JSON Schema with identity and digest metadata, Schema helpers load embedded JSON Schema with identity and digest metadata,
return defensive copies, and expose a diagnostics map that omits schema bytes. return defensive copies, and expose a diagnostics map that omits schema bytes.
The small framework registry contains only generic test schemas; production The small framework registry contains only generic test schemas; production
schemas remain package-owned. schemas remain package-owned.
The spell and combat extractors' package-owned prompts declare their structured The spell, NPC, and combat extractors' package-owned prompts declare their
JSON inputs and private response schemas. The combat private schema owns the structured JSON inputs and private response schemas. Each private response
transport envelope—required fields, JSON types, nullability, and unknown-field schema remains separate from its durable artifact codec schema; this work does
rejection—while its deterministic validators own semantic constraints such as not use shared schema fragments or schema generation. The combat private schema
enum membership, non-empty values and collections, and positive numbers. The owns the transport envelope—required fields, JSON types, nullability, and
spell extractor's prompt declares a required `application/json` `spell_catalog` unknown-field rejection—while its deterministic validators own semantic
input and an optional `application/json` `npcs` input. The extractor generates constraints such as enum membership, non-empty values and collections, and
positive numbers. The spell extractor's prompt declares a required
`application/json` `spell_catalog` input and an optional `application/json`
`npcs` input. The extractor generates
the catalog input from its prepared the catalog input from its prepared
effective catalog as `{"spell_names":[...]}` using sorted canonical names only. effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
The shared D&D prompt assets include a generic NPC grounding fragment directly The shared D&D prompt assets include a generic NPC grounding fragment directly
after the campaign reference message for both extractors. When an NPC registry after the campaign reference message for spell and combat prompts. When an NPC
is bound, the registry is bound, the
domain registry boundary strictly decodes and identity-validates one durable domain registry boundary strictly decodes and identity-validates one durable
artifact, re-encodes canonical JSON, and generates a semantic digest over artifact, re-encodes canonical JSON, and generates a semantic digest over
those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover

View File

@@ -46,10 +46,27 @@ that agreement. Runtime delivery uses the corresponding stage request's
LLM-backed extensions own their prompt definitions and response schemas under LLM-backed extensions own their prompt definitions and response schemas under
package-local embedded assets. Shared filesystem composition belongs in package-local embedded assets. Shared filesystem composition belongs in
`internal/framework/promptfs`; reusable D&D prompt fragments, reference `internal/framework/promptfs`; reusable D&D prompt fragments, reference
declarations, prompt-input assembly, and source-unit helpers belong in declarations, prompt-input assembly, and source-unit/citation helpers belong in
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. Stage `internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
contracts expose only Notarius structured- D&D scene chunker and spell, NPC, and combat-turn extractors use ordered
completion types, not Scriptorium public types. package-local prompt manifests for both rendering and prompt fingerprinting, so
only the shared fragments each prompt actually renders participate in either
operation. The three extraction prompts share the order system, extraction
evidence, in-world identity, transcript, and campaign references, with
ephemeral cache control on identity, transcript, and references. Spell and
combat prompts append immediate resolution and an ephemeral NPC registry before
lane-specific material; the NPC prompt appends its task and instructions. The
scene prompt uses system, transcript, and campaign references before its
lane-specific task and instructions, with ephemeral cache control on transcript
and references. Stage contracts expose only Notarius structured-completion
types, not Scriptorium public types.
The shared `ChunkPromptMaterial` helper owns common transcript material
preparation for the spell, NPC, and combat-turn extractors. It clones supplied
source metadata, falls back to the materialized chunk when content is absent,
checks that content remains chunk-identical, and fills only the common default
fields. Extractors retain their request validation and wrap helper errors with
their module context.
Reference material may inform a module or prompt but must not become source Reference material may inform a module or prompt but must not become source
evidence. The resolver and materializer behavior is described in evidence. The resolver and materializer behavior is described in
@@ -182,15 +199,14 @@ source evidence.
The prompt limits each cast to its declaration and immediate resolution; it The prompt limits each cast to its declaration and immediate resolution; it
does not follow summoned creatures, persistent effects, or other downstream does not follow summoned creatures, persistent effects, or other downstream
consequences through the scene. Source references must collectively support all consequences through the scene. Shared extraction-evidence and identity rules
reported facts, using separate narrow ranges when immediate evidence is require transcript-supported factual claims and the most specific in-world
non-contiguous. The prompt grounds caster names in in-world identities, using caster identity, while campaign references only disambiguate source text.
the player and party references to disambiguate transcript speakers without Effects describe the session as played: model rules knowledge cannot supplement
treating those references as event evidence. Effects describe the session as or correct the transcript, and nonstandard adjudication is attributed to the GM
played: model rules knowledge cannot supplement or correct the transcript, and or table rather than stated as a universal rule. Structural source validation
nonstandard adjudication is attributed to the GM or table rather than stated as remains deterministic; semantic claim completeness is enforced through
a universal rule. Structural source validation remains deterministic; semantic extraction policy and evaluation.
claim completeness is enforced through extraction policy and evaluation.
Both the extractor and deterministic catalog validator expose Both the extractor and deterministic catalog validator expose
the effective base-plus-overlay semantic digest as scoped prepared-component the effective base-plus-overlay semantic digest as scoped prepared-component
@@ -223,12 +239,16 @@ The NPC extractor maps private model output to the canonical `dnd.NPCList`,
assigns source identity and deterministic NPC IDs, and preserves source assigns source identity and deterministic NPC IDs, and preserves source
references for deterministic validation. It uses the shared campaign references for deterministic validation. It uses the shared campaign
references only for disambiguation and does not consume the optional NPC references only for disambiguation and does not consume the optional NPC
registry slot. Its prompt and private response schema are package-owned. registry slot. Its prompt and private response schema are package-owned. The
prompt uses the common evidence, identity, transcript, and campaign-reference
messages, then the NPC-specific task and instructions; only the identity,
transcript, and campaign-reference messages carry ephemeral cache control.
### `internal/modules/dnd/extract/combatturns` ### `internal/modules/dnd/extract/combatturns`
The combat extractor prepares one structured request per supplied chunk using The combat extractor prepares one structured request per supplied chunk using
the shared transcript, campaign-reference, and NPC-grounding prompt inputs. It the shared extraction-evidence, identity, transcript, campaign-reference,
immediate-resolution, and NPC-grounding prompt inputs. It
maps the private response to `dnd.CombatTurnList`, assigns the current source maps the private response to `dnd.CombatTurnList`, assigns the current source
identity, removes exact duplicate source ranges, and orders turns by valid identity, removes exact duplicate source ranges, and orders turns by valid
source-document position while preserving malformed candidate fields for source-document position while preserving malformed candidate fields for
@@ -236,8 +256,11 @@ deterministic validators. Its package-owned private response schema enforces
only the structural JSON envelope; semantic artifact constraints remain with only the structural JSON envelope; semantic artifact constraints remain with
the validator chain. Its prepared metadata and checkpoint fingerprints contain the validator chain. Its prepared metadata and checkpoint fingerprints contain
only prompt/schema/mapping identities plus an optional NPC registry digest. only prompt/schema/mapping identities plus an optional NPC registry digest.
The package exposes typed registration and is included in the production D&D The prompt renders immediate resolution and the NPC registry before the
registrar with the default combat extraction chain. combat-specific task and instructions; identity, transcript, campaign
references, and the NPC registry use ephemeral cache control. The package
exposes typed registration and is included in the production D&D registrar with
the default combat extraction chain.
The combat normalizer accepts only the optional structured NPC registry. The combat normalizer accepts only the optional structured NPC registry.
Campaign references remain extractor-only LLM context and are not materialized Campaign references remain extractor-only LLM context and are not materialized
@@ -338,12 +361,20 @@ validator defers when shape is invalid, then checks every non-empty spell name
against the immutable effective SRD and overlay catalog. It accepts normalized against the immutable effective SRD and overlay catalog. It accepts normalized
canonical names and aliases without rewriting the artifact; unknown names canonical names and aliases without rewriting the artifact; unknown names
reject the complete result with bounded, stable index/name diagnostics. The reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator applies generic source-reference validation to every source-reference validator defers malformed shapes, validates every cited
cited range. The relatedness validator warns when a case-insensitive spell name range, and reports all range defects through a bounded aggregate while
is absent from all cited source text. preserving `invalid_source_refs`. The relatedness validator resolves all cited
ranges through the shared document-order traversal, then warns when a normalized
consecutive spell-name token sequence is absent from the cited source text.
Invalid shape
or cited ranges produce no relatedness warnings; the shape and source-reference
validators own those defects.
These validators are deterministic. Their selectable keys and production order These validators are deterministic. Shape, source-reference, and relatedness
are defined in each expose a local semantic `policy` checkpoint fingerprint. The catalog
validator instead exposes its effective catalog digest as its semantic
checkpoint identity and does not add a separate policy fingerprint. Their
selectable keys and production order are defined in
[Configuration](../config.md#implemented-production-validators); their durable [Configuration](../config.md#implemented-production-validators); their durable
payload rules are defined in the payload rules are defined in the
[artifact contract](../integrations/dnd-spell-artifacts.md). [artifact contract](../integrations/dnd-spell-artifacts.md).
@@ -351,10 +382,13 @@ payload rules are defined in the
## D&D NPC Validators ## D&D NPC Validators
NPC shape validation checks required strings, arrays, and source-reference NPC shape validation checks required strings, arrays, and source-reference
shape. The source-reference validator checks current-document identity, unit shape. The source-reference validator defers malformed shapes, checks
existence, and range ordering; source relatedness emits at most one bounded current-document identity, unit existence, and range ordering, and reports all
warning per record when neither the canonical name nor an alias occurs near defects through bounded aggregates. Source relatedness uses the shared
its cited text. Normalize identity validation checks deterministic IDs, document-order traversal and normalized consecutive-token matching, emitting at
most one bounded warning per record when neither the canonical name nor an
alias occurs near its cited text. Invalid shape or cited ranges produce no
relatedness warnings. Normalize identity validation checks deterministic IDs,
canonical names, aliases, and cross-record ownership or canonical collisions. canonical names, aliases, and cross-record ownership or canonical collisions.
All are deterministic and expose the policy fingerprints used by the All are deterministic and expose the policy fingerprints used by the
production chains. production chains.
@@ -363,10 +397,13 @@ production chains.
Combat shape validation owns required arrays, strings, nullable values, positive Combat shape validation owns required arrays, strings, nullable values, positive
rounds, and supported enums. Combat source-reference validation defers invalid rounds, and supported enums. Combat source-reference validation defers invalid
shape and checks source identity, unit existence, and range order. Combat shape, checks source identity, unit existence, and range order, and reports all
source-relatedness defers invalid shape or ranges, combines overlapping cited defects through bounded aggregates. Combat source-relatedness defers invalid
units in document order, and emits at most one bounded advisory warning per shape or ranges, uses the shared traversal to combine overlapping cited units
turn for unrelated actor or declaration text. The normalized-invariants in document order, and emits at most one bounded advisory warning per turn for
unrelated actors or declaration text.
Actors use normalized consecutive-token matching; declarations retain the
minimum four-rune token heuristic. The normalized-invariants
validator owns display normalization, comparison-unique targets, canonical validator owns display normalization, comparison-unique targets, canonical
source-reference order, chronology, and exact duplicate identity; it defers source-reference order, chronology, and exact duplicate identity; it defers
shape and source-reference failures. All four validators are deterministic and shape and source-reference failures. All four validators are deterministic and
@@ -383,7 +420,9 @@ that order, then exposes the matching catalog for resolution. The generic and
Seriatim registrars own their production leaf registrations. The D&D registrar Seriatim registrars own their production leaf registrations. The D&D registrar
owns D&D leaf registrations, typed spell, NPC, and combat default-validator owns D&D leaf registrations, typed spell, NPC, and combat default-validator
chains, typed append-order specializations, and D&D prompt/schema asset chains, typed append-order specializations, and D&D prompt/schema asset
collection. collection. Its registration helpers group module, validator, prompt-asset, and
chain composition while retaining artifact-specific merge and clone behavior in
the registrar.
Concrete implementation packages do not import generic implementation Concrete implementation packages do not import generic implementation
packages directly. A concrete family's `register` package is its composition packages directly. A concrete family's `register` package is its composition

View File

@@ -1,180 +1,35 @@
# D&D Module Harmonization And Prompt Reuse # D&D Module Harmonization And Prompt Reuse
This roadmap records proposed improvements to the spell, NPC, and combat-turn This roadmap tracked harmonization of the spell, NPC, and combat-turn lanes.
pipeline lanes. Current implemented behavior remains documented in the The implemented behavior is now owned by [Module Internals](../internal/modules.md)
[module internals](../internal/modules.md) and and [LLM Runtime Internals](../internal/llm.md); this document records status
[LLM runtime internals](../internal/llm.md). The work below is not yet an and the one remaining external prerequisite rather than duplicating those
implemented contract. current-behavior references.
The three lanes already share the same overall decomposition: a typed ## Implementation Status
extractor, durable codec, merger, normalizer, shape and provenance validators,
domain-specific validation, embedded prompt assets, and central production
registration. Spell catalog resolution, NPC identity and registry support, and
combat normalization invariants are intentional domain differences. The goal
is to remove mechanical drift without hiding those differences behind a broad
generic abstraction.
## Target State The offline/runtime work is complete:
### Prompt Reuse Is A Rendered-Request Invariant - shared prompt assets have explicit per-prompt manifests that drive both
mounting and prompt fingerprints;
- shared prompt inputs, deterministic chunk material preparation, and cited
source traversal are centralized;
- prompt ordering and cache-control policy are aligned across the D&D
extraction prompts, while scene chunking retains its distinct prompt shape;
- validator prerequisite, bounded-diagnostic, and checkpoint policies are
aligned without changing compatibility-sensitive identifiers;
- production registration remains explicit and typed, with grouped composition,
central default-chain ownership, and artifact-specific merge behavior;
- private LLM response schemas remain package-owned and separate from durable
codec schemas; and
- focused tests and repository-wide test, vet, build, and diff checks pass.
Provider prompt caching depends on the request prefix being byte-for-byte ## Deferred evaluation
identical. Similar prose, duplicated files, or equivalent structured values are
not sufficient. Treat message role, content bytes, ordering, cache-control
metadata, input rendering, and any provider-visible separators as part of the
cache identity.
The target prompt layout has the longest valid common prefix before any An optional live before/after provider comparison remains deferred because it
module-specific message: requires credentials and a maintained human-reviewed transcript or fixture
set. It is not part of the default test suite or merge gate. If those
- Put shared static system, extraction-evidence, and in-world-identity policy prerequisites become available, record only aggregate extraction-review
before dynamic transcript and campaign-reference messages so reuse does not results, prompt token counts, cache-hit/cache-write metrics, and non-secret
depend on identical transcript content. prompt hashes. Never commit transcript content, rendered prompts, credentials,
- Keep the shared transcript and campaign-reference messages in the same roles endpoints, or private reference material.
and order in every D&D extractor.
- Keep cache-control declarations identical on corresponding messages.
- Define one canonical ordering and serialization for shared named inputs.
Continue resolving the deprecated `roster` input into the canonical `party`
input before prompt rendering.
- Keep genuinely universal extraction rules in shared message assets. These
rules include using only transcript units as evidence, treating references as
disambiguation, returning schema-conforming JSON only, using integer
`start_unit_id` and `end_unit_id` values, omitting `source_id` for the mapper
to assign, citing all factual claims, and preferring narrow ranges over broad
ranges that bridge unrelated conversation.
- Use one shared in-world identity message for lanes whose artifacts name
actors or participants. It contains only rules that spells, NPCs, and combat
turns can follow verbatim; artifact-specific identity, alias, target, and
relationship rules remain local.
- Keep rules shared by only a subset in subset-specific shared assets. The
immediate-declaration-and-resolution boundary and NPC-registry grounding are
shared by spells and combat. The NPC extractor does not consume a prior NPC
registry.
- Place subset-specific and module-specific messages only after the longest
useful all-lane prefix. Place schema-specific task instructions last.
- Avoid inserting a nominally shared message when its rendered input or
wording differs by lane. Factor the common bytes into one message and leave
the differences in later messages instead.
Shared messages are embedded once and referenced by every applicable prompt.
This makes reuse of the canonical asset the easiest way to preserve cache
identity and avoids parallel module-specific copies that can drift.
### Shared Prompt Ownership And Input Behavior Are Protected
Focused tests protect shared asset composition and input behavior without
snapshotting the length or exact boundary of the common message prefix.
- Verify that each applicable prompt mounts and renders the canonical shared
assets rather than module-specific copies.
- Verify common input material identity, including name, media type, content,
digest, origin URI, size, empty-value representation, reference ordering,
and `roster` fallback behavior.
- Test that every prompt fingerprint includes exactly the assets actually
rendered by that prompt. The current grouped reference hash helper should be
replaced or refined so a module does not fingerprint an unused shared asset,
while no used asset is omitted.
- Document the expected shared message ordering and cache-control policy so
later changes are reviewed against the caching intent.
- Prefer behavioral and ownership assertions over message-count, prefix-length,
or full-prompt snapshots.
### Stable Extraction Preparation Is Centralized
Move the three identical chunk-input preparation implementations into a D&D
shared helper. The helper should clone the supplied material, fall back to the
chunk content, verify byte equality, and fill the canonical name, media type,
and size without retaining mutable request data.
Nil context, cancellation, source, chunk, and empty-unit checks remain local so
typed result handling and module error context stay explicit. Do not wrap the
complete LLM call, response DTO mapping, catalog preparation, registry
preparation, or manifest metadata in a generic extractor framework.
Use the shared helper as the single source of the `transcript` prompt material
so identical input requests cannot drift between lanes.
### Validator Checkpoint And Composition Policy Is Aligned
- Add explicit versioned policy checkpoint fingerprints to the spell shape,
source-reference, and source-relatedness validators. Bump a policy version
whenever acceptance, rejection, warning, or diagnostic-selection behavior
changes.
- Shape validators own malformed artifact shape; later validators defer when
shape is invalid.
- Make all source-reference validators collect bounded diagnostics through the
shared D&D diagnostics package rather than mixing first-error and aggregate
behavior.
- Use artifact-qualified reason codes consistently unless a reason code is
intentionally a stable cross-artifact contract.
- Add a compact cross-lane validator contract test covering deterministic
execution class, strict empty options, registration, policy fingerprinting,
prerequisite behavior, and diagnostic bounds.
### Source-Evidence Traversal Is Shared
Extract a D&D helper that validates source ranges and returns cited units or
text once, in source-document order, without repeating units covered by
overlapping ranges. Use it from spell, NPC, and combat relatedness validators.
Keep matching policy artifact-specific:
- spell matching may use canonical catalog names and aliases;
- NPC matching may use NPC comparison keys and aliases; and
- combat matching may apply separate actor and declaration heuristics.
Review Unicode normalization, apostrophe handling, word boundaries, short-name
false positives, multiword identities, and overlapping ranges with shared
table-driven fixtures. Relatedness remains a warning heuristic and should not
be presented as proof that every semantic claim is supported.
## Structural Cleanup
### Lane Registration
Reduce repetition in the central D&D registrar with focused registration
helpers grouped by modules, validators, prompt assets, and default chains.
Retain artifact-specific append and deep-clone behavior in the registrar,
central ownership of validator ordering, and explicit typed registration.
Avoid reflection and heterogeneous erased lane descriptors.
### Naming And Package Conventions
Adopt consistent conventions for package aliases, module keys, artifact kinds,
prompt IDs, schema IDs, reason codes, policy IDs, metadata fields, and
checkpoint fingerprint names. Compatibility-sensitive identifiers should
change only through an explicit migration; internal aliases can be harmonized
independently.
### Schemas And Assets
- Keep private LLM response schemas distinct from durable artifact codec
schemas. They represent different trust and compatibility boundaries.
- Keep source-reference schema definitions package-owned in this work; do not
add schema composition or generation machinery solely to deduplicate them.
- Prefer one embedded shared asset over synchronized copies whenever content
must be identical for prompt caching.
- Make prompt fingerprints derive from an explicit prompt asset manifest, or
from the prepared prompt definition, so message composition and provenance
cannot drift independently.
Prompt factoring must not be accepted solely because cache reuse improves.
Retain or restore module-specific wording when evaluation shows a meaningful
quality regression. Record cache observations using non-secret request and
usage metadata rather than prompt or transcript payloads.
## Completion Criteria
This roadmap is complete when:
- the three lanes expose the same stable structural conventions while keeping
documented domain differences local;
- shared provider-visible messages are produced from canonical shared assets,
and shared inputs have one deterministic implementation;
- the common prompt ordering is deliberate, documented, and as cache-friendly
as extraction quality permits;
- every validator policy that affects reusable results participates in
checkpoint identity;
- source-reference traversal and diagnostics no longer drift between lanes;
- registration remains explicit and type-safe; and
- focused D&D tests plus repository-wide tests, vetting, and the CLI build pass.

View File

@@ -11,12 +11,22 @@ import (
const scriptoriumPromptRoot = "assets/prompts" const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error { var promptAssetManifest = shared.PromptAssetManifest{
promptFS, err := shared.ModulePromptFS("dnd.scenes", embeddedAssets, []promptfs.ModulePromptFile{ ModuleDir: "dnd.scenes",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"}, {Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"},
}) },
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil { if err != nil {
return fmt.Errorf("prepare scene prompt assets: %w", err) return fmt.Errorf("prepare scene prompt assets: %w", err)
} }
@@ -28,12 +38,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
func scriptoriumPromptMetadata() (string, error) { func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() { scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{ scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
{FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.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 return scriptoriumPromptHash, scriptoriumPromptHashErr
} }

View File

@@ -18,29 +18,29 @@ func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
if prepared.PromptID != PromptID { if prepared.PromptID != PromptID {
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID) t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
} }
if got := len(prepared.Messages); got != 5 { transcriptMessages := 0
t.Fatalf("message count = %d, want 5", got) referenceMessageFound := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, string(transcript)) {
transcriptMessages++
if message.Role != "user" || message.CacheControl == nil {
t.Fatalf("transcript message did not render as cacheable user message: %#v", message)
}
}
if strings.Contains(message.Content, "Alice: Aria") &&
strings.Contains(message.Content, "Aria: cleric") &&
strings.Contains(message.Content, "Brightmantle: temple") {
referenceMessageFound = true
if message.Role != "user" || message.CacheControl == nil {
t.Fatalf("reference message did not render as cacheable user message: %#v", message)
}
}
} }
if prepared.Messages[1].Role != "user" || prepared.Messages[1].CacheControl == nil { if transcriptMessages != 1 {
t.Fatalf("transcript message did not render as cacheable user message: %#v", prepared.Messages[1]) t.Fatalf("raw transcript rendered in %d messages, want exactly one", transcriptMessages)
} }
if !strings.Contains(prepared.Messages[1].Content, string(transcript)) { if !referenceMessageFound {
t.Fatalf("transcript message did not include source input") t.Fatalf("reference message did not render all supplied reference material")
}
if prepared.Messages[2].CacheControl == nil {
t.Fatalf("reference message did not render as cacheable user message: %#v", prepared.Messages[2])
}
if !strings.Contains(prepared.Messages[2].Content, "Alice: Aria") {
t.Fatalf("reference message missing player content")
}
if !strings.Contains(prepared.Messages[2].Content, "Aria: cleric") {
t.Fatalf("reference message missing party content")
}
if !strings.Contains(prepared.Messages[2].Content, "Brightmantle: temple") {
t.Fatalf("reference message missing glossary content")
}
if strings.Contains(prepared.Messages[3].Content, string(transcript)) {
t.Fatalf("task message leaked transcript bytes")
} }
} }

View File

@@ -20,6 +20,12 @@ inputs:
messages: messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-extraction-evidence.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
cache_control:
type: ephemeral
- role: user - role: user
content_file: ./sharedassets/common-dnd-transcript.md content_file: ./sharedassets/common-dnd-transcript.md
cache_control: cache_control:
@@ -28,8 +34,12 @@ messages:
content_file: ./sharedassets/common-dnd-references.md content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-immediate-resolution.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-npcs.md content_file: ./sharedassets/common-dnd-npcs.md
cache_control:
type: ephemeral
- role: user - role: user
content_file: ./task.md content_file: ./task.md
- role: user - role: user

View File

@@ -1,12 +1,5 @@
Return exactly one JSON object and no explanatory text.
Return the combat_turns array even when no combat turn is established. Return 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 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 action category values. Set round to null when the transcript does not state an
an explicit or unambiguous positive round number. Set resolution to null when explicit or unambiguous positive round number. Set resolution to null when the
the transcript establishes the declaration but not an immediate resolution. transcript establishes the declaration but not an immediate resolution.
Every source reference must contain start_unit_id and end_unit_id from the
provided transcript. Do not add source_id; the extraction mapper assigns the
current source identity. Do not include fields not defined by the response
schema.

View File

@@ -5,26 +5,15 @@ participant takes a combat turn or performs a discrete interrupting combat
event. Reactions, legendary actions, lair actions, and other out-of-turn events event. Reactions, legendary actions, lair actions, and other out-of-turn events
belong at the point where they occur in transcript chronology. 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, Exclude initiative setup without a turn or combat event, tactical planning,
table talk, rules lookup, hypothetical actions, abandoned declarations, recap table talk, rules lookup, hypothetical actions, abandoned declarations, recap
of combat outside the current passage, and downstream consequences. of combat outside the current passage, and downstream consequences.
Use only the supplied transcript as evidence. Do not infer a round, target, Do not infer a round, target, roll, amount, condition, outcome, or action
roll, amount, condition, outcome, or action classification from D&D rules classification from D&D rules knowledge. Preserve the session as played;
knowledge. Preserve the session as played; attribute relevant nonstandard attribute relevant nonstandard rulings to the GM or table.
rulings to the GM or table.
The actor must be the in-world character or creature, not a player, transcript Unmatched actors and targets remain permitted.
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 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 turn-level source_refs collection.
non-contiguous. Numeric source-unit IDs identify transcript units; they do not
establish chronology outside the supplied transcript.

View File

@@ -1,7 +1,6 @@
package combatturns package combatturns
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"sort" "sort"
@@ -159,9 +158,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if len(req.Chunk.Units) == 0 { if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
} }
sourceInput, err := chunkSourceInput(req) sourceInput, err := shared.ChunkPromptMaterial(req)
if err != nil { if err != nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, err return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
} }
var response extractionResponse var response extractionResponse
@@ -181,26 +180,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil 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 { func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{ return pipeline.ModuleSpec{
Key: Key, Key: Key,

View File

@@ -11,12 +11,26 @@ import (
const scriptoriumPromptRoot = "assets/prompts" const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error { var promptAssetManifest = shared.PromptAssetManifest{
promptFS, err := shared.ModulePromptFS("dnd.combat_turns", embeddedAssets, []promptfs.ModulePromptFile{ ModuleDir: "dnd.combat_turns",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"}, {Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"},
}) },
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-extraction-evidence.md",
"common-dnd-identity.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
"common-dnd-immediate-resolution.md",
"common-dnd-npcs.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil { if err != nil {
return fmt.Errorf("prepare combat-turn prompt assets: %w", err) return fmt.Errorf("prepare combat-turn prompt assets: %w", err)
} }
@@ -28,12 +42,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
func scriptoriumPromptMetadata() (string, error) { func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() { scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{ scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
{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 return scriptoriumPromptHash, scriptoriumPromptHashErr
} }

View File

@@ -1,11 +1,14 @@
package combatturns package combatturns
import ( import (
"context"
"io/fs" "io/fs"
"strings" "strings"
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/scriptorium"
) )
func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) { func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
@@ -13,23 +16,6 @@ func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v", err) 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() schemaFS, err := registry.SchemaFS()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -42,3 +28,50 @@ func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want digest", hash, err) t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want digest", hash, err)
} }
} }
func TestScriptoriumPromptPreparesRequiredInputs(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v, want nil", err)
}
options, err := registry.ScriptoriumOptions()
if err != nil {
t.Fatalf("ScriptoriumOptions() error = %v, want nil", err)
}
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "combat-test-profile", Endpoint: "http://127.0.0.1:1/v1", Model: "combat-test-model",
})))
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v, want nil", err)
}
transcript := `{"units":[1]}`
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "combat-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", transcript),
"players": scriptorium.Inline("Dana: Mira"),
"party": scriptorium.Inline("Mira: ranger"),
"glossary": scriptorium.Inline("Greencloak: title"),
"npcs": scriptorium.Inline(`{"npcs":[]}`),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_combat_turns_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want combat prompt identity and schema", prepared)
}
for _, want := range []string{transcript, "Dana: Mira", "Mira: ranger", "Greencloak: title", `{"npcs":[]}`} {
found := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, want) {
found = true
break
}
}
if !found {
t.Fatalf("prepared prompt did not render required input %q", want)
}
}
}

View File

@@ -17,6 +17,12 @@ inputs:
messages: messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-extraction-evidence.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
cache_control:
type: ephemeral
- role: user - role: user
content_file: ./sharedassets/common-dnd-transcript.md content_file: ./sharedassets/common-dnd-transcript.md
cache_control: cache_control:

View File

@@ -1,18 +1,5 @@
Return exactly one JSON object and no explanatory text. For every NPC record, cite transcript units that support the canonical name,
every alias, the description, and every relationship.
For every NPC record, cite one or more transcript source-unit ranges that
collectively support the canonical name, every alias, the description, and
every relationship. Use integer start_unit_id and end_unit_id values from the
transcript. Do not provide source_id; the extractor assigns it automatically.
Use narrow ranges when evidence is not contiguous and do not bridge unrelated
conversation with a broad range.
A canonical name must be the most specific in-world identity supported by the
transcript. Never use a human player, transcript speaker, or GM name when an
associated in-world character or creature is identified. Player, party, and
glossary references may disambiguate identities, but they are not transcript
evidence and cannot establish that an NPC appeared, acted, or was discussed.
Do not return a person or entity mentioned only in those references.
Descriptions must be short session records, not biographies, statistics, Descriptions must be short session records, not biographies, statistics,
alignment, motivations, or lore inferred from general D&D knowledge. Do not alignment, motivations, or lore inferred from general D&D knowledge. Do not

View File

@@ -5,12 +5,11 @@ Include an in-world non-PC participant when the transcript establishes that it
appears, acts, speaks, or is materially discussed and gives it a proper name, appears, acts, speaks, or is materially discussed and gives it a proper name,
a stable alias or title, or an individually useful distinguishing description. a stable alias or title, or an individually useful distinguishing description.
Exclude human players, transcript speakers, the GM as an out-of-world person, Exclude human players, transcript speakers, and the GM as out-of-world people,
player characters identified by the player or party references, incidental or player characters identified by the player or party references, incidental or
hypothetical name drops, corrected transcription mistakes, characters mentioned hypothetical name drops, corrected transcription mistakes, characters mentioned
only by reference material, indistinguishable crowds or groups, and temporary only by reference material, indistinguishable crowds or groups, and temporary
summoned creatures or spell effects without a persistent individual identity. summoned creatures or spell effects without a persistent individual identity.
Return canonical in-world names rather than player names or transcript speaker Keep each description concise and limited to facts established by the
names. Keep each description concise and limited to facts established by the
transcript. Include only explicitly supported aliases and relationships. transcript. Include only explicitly supported aliases and relationships.

View File

@@ -1,7 +1,6 @@
package npcs package npcs
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
@@ -122,9 +121,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if len(req.Chunk.Units) == 0 { if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
} }
sourceInput, err := chunkSourceInput(req) sourceInput, err := shared.ChunkPromptMaterial(req)
if err != nil { if err != nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, err return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
} }
var response extractionResponse var response extractionResponse
@@ -142,26 +141,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(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 { func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{ return pipeline.ModuleSpec{
Key: Key, Key: Key,

View File

@@ -133,7 +133,11 @@ func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") { if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
t.Fatalf("canceled Extract() error = %v, want context error", err) t.Fatalf("canceled Extract() error = %v, want context error", err)
} }
_, err := newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request) _, err := extractor.Extract(context.Background(), mismatchedSourceInputRequest(request))
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "must match chunk") {
t.Fatalf("source input error = %v, want contextual source input error", err)
}
_, err = newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "provider unavailable") { if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "provider unavailable") {
t.Fatalf("provider Extract() error = %v, want contextual provider error", err) t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
} }

View File

@@ -11,12 +11,24 @@ import (
const scriptoriumPromptRoot = "assets/prompts" const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error { var promptAssetManifest = shared.PromptAssetManifest{
promptFS, err := shared.ModulePromptFS("dnd.npcs", embeddedAssets, []promptfs.ModulePromptFile{ ModuleDir: "dnd.npcs",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.npcs.yaml", Path: "assets/prompts/dnd.npcs.yaml"}, {Name: "dnd.npcs.yaml", Path: "assets/prompts/dnd.npcs.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"},
}) },
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-extraction-evidence.md",
"common-dnd-identity.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil { if err != nil {
return fmt.Errorf("prepare NPC prompt assets: %w", err) return fmt.Errorf("prepare NPC prompt assets: %w", err)
} }
@@ -28,12 +40,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
func scriptoriumPromptMetadata() (string, error) { func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() { scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{ scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
{FS: embeddedAssets, Path: "assets/prompts/dnd.npcs.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 return scriptoriumPromptHash, scriptoriumPromptHashErr
} }

View File

@@ -39,14 +39,29 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err) t.Fatalf("Prepare() error = %v, want nil", err)
} }
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_llm.v1.json" || len(prepared.Messages) != 5 { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want NPC prompt identity and wiring", prepared) t.Fatalf("prepared prompt = %#v, want NPC prompt identity and wiring", prepared)
} }
if !strings.Contains(prepared.Messages[1].Content, `{"units":[1]}`) || !strings.Contains(prepared.Messages[2].Content, "Dana: Mira") || !strings.Contains(prepared.Messages[2].Content, "Mira: ranger") { for _, want := range []string{`{"units":[1]}`, "Dana: Mira", "Mira: ranger", "Greencloak: title"} {
t.Fatalf("prepared prompt inputs do not include transcript/references") found := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, want) {
found = true
break
}
}
if !found {
t.Fatalf("prepared prompt did not render required input %q", want)
}
} }
if strings.Contains(prepared.Messages[3].Content, `{"units":[1]}`) || strings.Contains(prepared.Messages[4].Content, `{"units":[1]}`) { transcriptMessages := 0
t.Fatal("task or instruction prompt leaked raw transcript") for _, message := range prepared.Messages {
if strings.Contains(message.Content, `{"units":[1]}`) {
transcriptMessages++
}
}
if transcriptMessages != 1 {
t.Fatalf("raw transcript rendered in %d messages, want exactly one", transcriptMessages)
} }
} }

View File

@@ -23,6 +23,12 @@ inputs:
messages: messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-extraction-evidence.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
cache_control:
type: ephemeral
- role: user - role: user
content_file: ./sharedassets/common-dnd-transcript.md content_file: ./sharedassets/common-dnd-transcript.md
cache_control: cache_control:
@@ -31,8 +37,12 @@ messages:
content_file: ./sharedassets/common-dnd-references.md content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-immediate-resolution.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-npcs.md content_file: ./sharedassets/common-dnd-npcs.md
cache_control:
type: ephemeral
- role: user - role: user
content_file: ./catalog.md content_file: ./catalog.md
- role: user - role: user

View File

@@ -1,40 +1,29 @@
Source references must use integer source-unit IDs from the transcript. Provide For each spell cast, source references must collectively support the caster,
start_unit_id and end_unit_id for each source reference; the extractor assigns spell, effect, and narrative_description. If a detail is not supported by the
source_id automatically. cited transcript units, omit that detail or describe only the supported attempt
or declaration.
Report only the casting declaration or action and its immediate resolution. For spells, do not follow summoned creatures, persistent effects, or other
This may include an immediately resolved target, saving throw, damage, healing, downstream consequences through the rest of the scene.
condition, interruption, or outcome. Do not follow summoned creatures,
persistent effects, or other downstream consequences through the rest of the
scene.
For every detail you report, cite all supporting transcript units. The source Return only D&D spell-cast artifacts. For each spell cast, identify the
references for an artifact must collectively support every factual claim in in-world caster, spell name, effect, narrative description, and source
caster, spell, effect, and narrative_description. When immediate supporting references.
evidence is non-contiguous, provide multiple narrow source references. Do not
use one broad range merely to bridge unrelated table conversation. If a detail
is not supported by the cited transcript units, omit that detail or describe
only the supported attempt or declaration.
Return only D&D spell-cast artifacts. For each spell cast, identify the in-world Use the player and party references together with transcript context to map
caster, spell name, effect, narrative description, and source references. first-person player speech to the associated player character and use the
canonical character name from the references. Likewise, attribute a spell
The caster must be the canonical in-world character or creature, not the human narrated by the GM to the in-world creature that casts it. If the caster cannot
player, transcript speaker, or GM. Use the player and party references together be resolved, use only the most specific in-world identity supported by the
with transcript context to map first-person player speech to the associated transcript; do not invent a name.
player character and use the canonical character name from the references.
Likewise, attribute a spell narrated by the GM to the in-world creature that
casts it. Never return a player or GM name when the associated in-world caster
can be identified. If the caster cannot be resolved, use only the most specific
in-world identity supported by the transcript; do not invent a name.
Use the canonical spell-name catalog to select spell names. Do not return a Use the canonical spell-name catalog to select spell names. Do not return a
spell name absent from that catalog, even when it is suggested by general D&D spell name absent from that catalog, even when it is suggested by general D&D
knowledge or reference material. knowledge or reference material.
Use player, party, and glossary reference material only to clarify source text; Use player, party, and glossary reference material only to clarify source text.
references are not source evidence for a spell cast. Do not return spells, Do not return spells, casters, or effects that are mentioned only in reference
casters, or effects that are mentioned only in reference material. material.
Effects and narrative descriptions are session records, not rules summaries. Effects and narrative descriptions are session records, not rules summaries.
Report only mechanics, explanations, and outcomes established by the cited Report only mechanics, explanations, and outcomes established by the cited
@@ -42,5 +31,3 @@ transcript units. Preserve the table's observed resolution without silently
correcting it from general D&D knowledge. If the transcript gives a possibly correcting it from general D&D knowledge. If the transcript gives a possibly
nonstandard rationale, use wording such as "the GM rules" or "the table nonstandard rationale, use wording such as "the GM rules" or "the table
resolves" rather than asserting that rationale as a universal rule. resolves" rather than asserting that rationale as a universal rule.
Return exactly one JSON object and no explanatory text.

View File

@@ -1,7 +1,6 @@
package spells package spells
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"sort" "sort"
@@ -177,9 +176,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if len(req.Chunk.Units) == 0 { if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID) return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
} }
sourceInput, err := chunkSourceInput(req) sourceInput, err := shared.ChunkPromptMaterial(req)
if err != nil { if err != nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, err return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
} }
var response extractionResponse var response extractionResponse
@@ -200,26 +199,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(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 { func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{ return pipeline.ModuleSpec{
Key: Key, Key: Key,

View File

@@ -11,13 +11,27 @@ import (
const scriptoriumPromptRoot = "assets/prompts" const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error { var promptAssetManifest = shared.PromptAssetManifest{
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{ ModuleDir: "dnd.spells",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"}, {Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
{Name: "catalog.md", Path: "assets/prompts/catalog.md"}, {Name: "catalog.md", Path: "assets/prompts/catalog.md"},
{Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"},
}) },
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-extraction-evidence.md",
"common-dnd-identity.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
"common-dnd-immediate-resolution.md",
"common-dnd-npcs.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil { if err != nil {
return fmt.Errorf("prepare spell prompt assets: %w", err) return fmt.Errorf("prepare spell prompt assets: %w", err)
} }
@@ -29,13 +43,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
func scriptoriumPromptMetadata() (string, error) { func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() { scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{ scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
{FS: embeddedAssets, Path: "assets/prompts/catalog.md"},
{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 return scriptoriumPromptHash, scriptoriumPromptHashErr
} }

View File

@@ -21,32 +21,24 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" { if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath) t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
} }
if got := len(prepared.Messages); got != 7 { for _, want := range []string{
t.Fatalf("message count = %d, want 7", got) string(transcript),
} "Dana: Mira",
if !strings.Contains(prepared.Messages[1].Content, string(transcript)) { "Mira: wizard",
t.Fatalf("transcript message did not include source input") "Shield: abjuration",
} `{"spell_names":["Cure Wounds"]}`,
if prepared.Messages[1].CacheControl == nil || prepared.Messages[2].CacheControl == nil { `{"npcs":[]}`,
t.Fatalf("expected transcript and reference messages to be cacheable: %#v", prepared.Messages) } {
} found := false
if !strings.Contains(prepared.Messages[2].Content, "Dana: Mira") { for _, message := range prepared.Messages {
t.Fatalf("reference message missing player content") if strings.Contains(message.Content, want) {
} found = true
if !strings.Contains(prepared.Messages[2].Content, "Mira: wizard") { break
t.Fatalf("reference message missing party content") }
} }
if !strings.Contains(prepared.Messages[2].Content, "Shield: abjuration") { if !found {
t.Fatalf("reference message missing glossary content") t.Fatalf("prepared prompt did not render required input %q", want)
} }
if !strings.Contains(prepared.Messages[4].Content, `{"spell_names":["Cure Wounds"]}`) {
t.Fatalf("catalog message missing canonical spell-name input: %s", prepared.Messages[4].Content)
}
if !strings.Contains(prepared.Messages[3].Content, `{"npcs":[]}`) {
t.Fatalf("NPC registry message missing empty registry input: %s", prepared.Messages[3].Content)
}
if strings.Contains(prepared.Messages[5].Content, string(transcript)) {
t.Fatalf("task message leaked transcript bytes")
} }
} }
@@ -54,9 +46,12 @@ func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) {
transcript := []byte(`{"id":"session-1","segments":[]}`) transcript := []byte(`{"id":"session-1","segments":[]}`)
prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ") prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ")
if !strings.Contains(prepared.Messages[2].Content, " ") { for _, message := range prepared.Messages {
t.Fatalf("reference message did not include empty optional reference placeholders") if strings.Contains(message.Content, "Player list reference:") && strings.Contains(message.Content, "Party roster reference:") && strings.Contains(message.Content, "Glossary reference:") {
return
}
} }
t.Fatalf("reference message did not render empty optional reference placeholders")
} }
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) { func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {

View File

@@ -19,7 +19,8 @@ import (
const ( const (
Key = "dnd/combat-turns" Key = "dnd/combat-turns"
NormalizationPolicy = "dnd.combat_turns.normalize.v1" normalizationPolicy = "dnd.combat_turns.normalize.v1"
NormalizationPolicy = normalizationPolicy
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized" ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
ReasonCodeActorCanonicalized = "combat_actor_canonicalized" ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
@@ -71,7 +72,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
return nil return nil
} }
metadata := map[string]any{ metadata := map[string]any{
"normalization_policy": NormalizationPolicy, "normalization_policy": normalizationPolicy,
"identity_policy": identity.Policy, "identity_policy": identity.Policy,
} }
if n.npcRegistry.Bound() { if n.npcRegistry.Bound() {
@@ -86,7 +87,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return nil return nil
} }
fingerprints := []pipeline.CheckpointFingerprint{ fingerprints := []pipeline.CheckpointFingerprint{
{Name: "normalization_policy", Value: NormalizationPolicy}, {Name: "normalization_policy", Value: normalizationPolicy},
{Name: "identity_policy", Value: identity.Policy}, {Name: "identity_policy", Value: identity.Policy},
} }
if n.npcRegistry.Bound() { if n.npcRegistry.Bound() {

View File

@@ -189,7 +189,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
if err != nil { if err != nil {
t.Fatalf("New() error = %v", err) t.Fatalf("New() error = %v", err)
} }
if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != NormalizationPolicy || metadata["identity_policy"] != identity.Policy { if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy {
t.Fatalf("unbound metadata = %#v", metadata) t.Fatalf("unbound metadata = %#v", metadata)
} }
if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" { if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" {

View File

@@ -19,7 +19,8 @@ import (
const ( const (
Key = "dnd/npcs" Key = "dnd/npcs"
NormalizationPolicy = "dnd.npcs.normalize.v1" normalizationPolicy = "dnd.npcs.normalize.v1"
NormalizationPolicy = normalizationPolicy
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized" ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
ReasonCodeNPCIDRecomputed = "npc_id_recomputed" ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
@@ -51,7 +52,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
} }
return map[string]any{ return map[string]any{
"identity_policy": identity.Policy, "identity_policy": identity.Policy,
"normalization_policy": NormalizationPolicy, "normalization_policy": normalizationPolicy,
} }
} }
@@ -61,7 +62,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
} }
return []pipeline.CheckpointFingerprint{ return []pipeline.CheckpointFingerprint{
{Name: "identity_policy", Value: identity.Policy}, {Name: "identity_policy", Value: identity.Policy},
{Name: "normalization_policy", Value: NormalizationPolicy}, {Name: "normalization_policy", Value: normalizationPolicy},
} }
} }

View File

@@ -10,7 +10,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" identity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
) )
func TestModuleContractAndIdentity(t *testing.T) { func TestModuleContractAndIdentity(t *testing.T) {
@@ -48,13 +48,13 @@ func TestModuleContractAndIdentity(t *testing.T) {
normalizer := New(Options{}) normalizer := New(Options{})
metadata := normalizer.ManifestMetadata() metadata := normalizer.ManifestMetadata()
if metadata["identity_policy"] != domainidentity.Policy || metadata["normalization_policy"] != NormalizationPolicy { if metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy {
t.Fatalf("metadata = %#v, want identity and normalization policies", metadata) t.Fatalf("metadata = %#v, want identity and normalization policies", metadata)
} }
fingerprints := normalizer.CheckpointFingerprints() fingerprints := normalizer.CheckpointFingerprints()
wantFingerprints := []pipeline.CheckpointFingerprint{ wantFingerprints := []pipeline.CheckpointFingerprint{
{Name: "identity_policy", Value: domainidentity.Policy}, {Name: "identity_policy", Value: identity.Policy},
{Name: "normalization_policy", Value: NormalizationPolicy}, {Name: "normalization_policy", Value: normalizationPolicy},
} }
if !reflect.DeepEqual(fingerprints, wantFingerprints) { if !reflect.DeepEqual(fingerprints, wantFingerprints) {
t.Fatalf("fingerprints = %#v, want %#v", fingerprints, wantFingerprints) t.Fatalf("fingerprints = %#v, want %#v", fingerprints, wantFingerprints)
@@ -104,7 +104,7 @@ func TestNormalizePerRecordFieldsAndEvidence(t *testing.T) {
if !reflect.DeepEqual(got.SourceRefs, wantRefs) { if !reflect.DeepEqual(got.SourceRefs, wantRefs) {
t.Fatalf("source refs = %#v, want %#v", got.SourceRefs, wantRefs) t.Fatalf("source refs = %#v, want %#v", got.SourceRefs, wantRefs)
} }
if got.ID != domainidentity.DeriveID("Lady Ash") { if got.ID != identity.DeriveID("Lady Ash") {
t.Fatalf("ID = %q, want derived ID", got.ID) t.Fatalf("ID = %q, want derived ID", got.ID)
} }
if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") || if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") ||

View File

@@ -0,0 +1,112 @@
package register
import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json"
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
)
func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
return runRegistrations([]registration{
{name: "spells validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: spellextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellshape.Key),
pipeline.Binding(spellcatalog.Key),
pipeline.Binding(spellsourcerefs.Key),
pipeline.Binding(spellrelatedness.Key),
},
})
}},
{name: "spells normalize validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: spellnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellshape.Key),
pipeline.Binding(spellcatalog.Key),
pipeline.Binding(spellsourcerefs.Key),
pipeline.Binding(spellrelatedness.Key),
},
})
}},
{name: "npcs validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: npcextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcshape.Key),
pipeline.Binding(npcsourcerefs.Key),
pipeline.Binding(npcrelatedness.Key),
},
})
}},
{name: "npcs normalize validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: npcnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcshape.Key),
pipeline.Binding(npcidentity.Key),
pipeline.Binding(npcsourcerefs.Key),
pipeline.Binding(npcrelatedness.Key),
},
})
}},
{name: "combat turns validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: combatextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatshape.Key),
pipeline.Binding(combatsourcerefs.Key),
pipeline.Binding(combatrelatedness.Key),
},
})
}},
{name: "combat turns normalize validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: combatnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatshape.Key),
pipeline.Binding(combatinvariants.Key),
pipeline.Binding(combatsourcerefs.Key),
pipeline.Binding(combatrelatedness.Key),
},
})
}},
})
}

View File

@@ -0,0 +1,83 @@
package register
import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
count := 0
for _, value := range values {
count += len(value.SpellCasts)
}
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
for _, value := range values {
combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...)
}
return combined, nil
}
func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
count := 0
present := false
for _, value := range values {
if value.NPCs != nil {
present = true
}
count += len(value.NPCs)
}
if !present {
return dnd.NPCList{}, nil
}
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
for _, value := range values {
combined.NPCs = append(combined.NPCs, value.NPCs...)
}
return combined, nil
}
func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, error) {
count := 0
present := false
for _, value := range values {
if value.CombatTurns != nil {
present = true
}
count += len(value.CombatTurns)
}
if !present {
return dnd.CombatTurnList{}, nil
}
combined := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 0, count)}
for _, value := range values {
for _, turn := range value.CombatTurns {
combined.CombatTurns = append(combined.CombatTurns, cloneCombatTurn(turn))
}
}
return combined, nil
}
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
clone := value
if value.Round != nil {
round := *value.Round
clone.Round = &round
}
if value.Actions != nil {
clone.Actions = make([]dnd.CombatAction, len(value.Actions))
for index, action := range value.Actions {
clone.Actions[index] = action
if action.Targets != nil {
clone.Actions[index].Targets = append([]string(nil), action.Targets...)
}
if action.Resolution != nil {
resolution := *action.Resolution
clone.Actions[index].Resolution = &resolution
}
}
}
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
return clone
}

View File

@@ -0,0 +1,62 @@
package register
import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
)
func registerModules(registries pipeline.Registries) error {
codec := spellcodec.New()
return runRegistrations([]registration{
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
{name: "spell-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
}},
{name: "npc-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists)
}},
{name: "combat-turn-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
}},
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
{name: "spell-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind)
}},
{name: "npc-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind)
}},
{name: "combat-turn-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
}},
})
}
func registerPromptAssets(assets *llm.AssetRegistry) error {
return runRegistrations([]registration{
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
{name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }},
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
})
}

View File

@@ -4,276 +4,41 @@ package register
import ( import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
alwaysaccept "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_accept"
alwaysreject "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_reject"
validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json"
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
) )
type registration struct {
name string
register func() error
}
// Register adds all production D&D modules, validators, policy, and assets. // Register adds all production D&D modules, validators, policy, and assets.
func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
if err := validateRegistries(registries, assets); err != nil { if err := validateRegistries(registries, assets); err != nil {
return err return err
} }
codec := spellcodec.New() if err := registerModules(registries); err != nil {
registrations := []struct { return err
name string
register func() error
}{
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
{name: "spells extractor", register: func() error { return spells.Register(registries.Extractors) }},
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
{name: "spell-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
}},
{name: "npc-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists)
}},
{name: "combat-turn-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
}},
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
{name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }},
{name: "npc-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind) }},
{name: "combat-turn-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
}},
{name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }},
{name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }},
{name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }},
{name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }},
{name: "npc shape validator", register: func() error { return npcshape.Register(registries.Validators) }},
{name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }},
{name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }},
{name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }},
{name: "combat shape validator", register: func() error { return combatshape.Register(registries.Validators) }},
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
{name: "spell-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
}},
{name: "spell-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
}},
{name: "npc-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
}},
{name: "npc-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
}},
{name: "combat-turn-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
}},
{name: "combat-turn-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
}},
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
{name: "spells prompt assets", register: func() error { return spells.RegisterPromptAssets(assets) }},
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
} }
if err := registerValidators(registries); err != nil {
return err
}
if err := registerPromptAssets(assets); err != nil {
return err
}
return registerDefaultChains(registries.ValidatorChains)
}
func runRegistrations(registrations []registration) error {
for _, registration := range registrations { for _, registration := range registrations {
if err := registration.register(); err != nil { if err := registration.register(); err != nil {
return fmt.Errorf("register dnd %s: %w", registration.name, err) return fmt.Errorf("register dnd %s: %w", registration.name, err)
} }
} }
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: spells.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellshape.Key),
pipeline.Binding(spellcatalog.Key),
pipeline.Binding(spellsourcerefs.Key),
pipeline.Binding(spellrelatedness.Key),
},
}); err != nil {
return fmt.Errorf("register dnd spells validator chain: %w", err)
}
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: spellnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellshape.Key),
pipeline.Binding(spellcatalog.Key),
pipeline.Binding(spellsourcerefs.Key),
pipeline.Binding(spellrelatedness.Key),
},
}); err != nil {
return fmt.Errorf("register dnd spells normalize validator chain: %w", err)
}
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: npcextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcshape.Key),
pipeline.Binding(npcsourcerefs.Key),
pipeline.Binding(npcrelatedness.Key),
},
}); err != nil {
return fmt.Errorf("register dnd npcs validator chain: %w", err)
}
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: npcnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcshape.Key),
pipeline.Binding(npcidentity.Key),
pipeline.Binding(npcsourcerefs.Key),
pipeline.Binding(npcrelatedness.Key),
},
}); err != nil {
return fmt.Errorf("register dnd npcs normalize validator chain: %w", err)
}
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: combatextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatshape.Key),
pipeline.Binding(combatsourcerefs.Key),
pipeline.Binding(combatrelatedness.Key),
},
}); err != nil {
return fmt.Errorf("register dnd combat turns validator chain: %w", err)
}
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: combatnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatshape.Key),
pipeline.Binding(combatinvariants.Key),
pipeline.Binding(combatsourcerefs.Key),
pipeline.Binding(combatrelatedness.Key),
},
}); err != nil {
return fmt.Errorf("register dnd combat turns normalize validator chain: %w", err)
}
return nil return nil
} }
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
count := 0
for _, value := range values {
count += len(value.SpellCasts)
}
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
for _, value := range values {
combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...)
}
return combined, nil
}
func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
count := 0
present := false
for _, value := range values {
if value.NPCs != nil {
present = true
}
count += len(value.NPCs)
}
if !present {
return dnd.NPCList{}, nil
}
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
for _, value := range values {
combined.NPCs = append(combined.NPCs, value.NPCs...)
}
return combined, nil
}
func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, error) {
count := 0
present := false
for _, value := range values {
if value.CombatTurns != nil {
present = true
}
count += len(value.CombatTurns)
}
if !present {
return dnd.CombatTurnList{}, nil
}
combined := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 0, count)}
for _, value := range values {
for _, turn := range value.CombatTurns {
combined.CombatTurns = append(combined.CombatTurns, cloneCombatTurn(turn))
}
}
return combined, nil
}
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
clone := value
if value.Round != nil {
round := *value.Round
clone.Round = &round
}
if value.Actions != nil {
clone.Actions = make([]dnd.CombatAction, len(value.Actions))
for index, action := range value.Actions {
clone.Actions[index] = action
if action.Targets != nil {
clone.Actions[index].Targets = append([]string(nil), action.Targets...)
}
if action.Resolution != nil {
resolution := *action.Resolution
clone.Actions[index].Resolution = &resolution
}
}
}
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
return clone
}
func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error { func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error {
switch { switch {
case registries.Chunkers == nil: case registries.Chunkers == nil:

View File

@@ -0,0 +1,55 @@
package register
import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
alwaysaccept "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_accept"
alwaysreject "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_reject"
)
func registerValidators(registries pipeline.Registries) error {
return runRegistrations([]registration{
{name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }},
{name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }},
{name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }},
{name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }},
{name: "npc shape validator", register: func() error { return npcshape.Register(registries.Validators) }},
{name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }},
{name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }},
{name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }},
{name: "combat shape validator", register: func() error { return combatshape.Register(registries.Validators) }},
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
{name: "spell-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
}},
{name: "spell-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
}},
{name: "npc-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
}},
{name: "npc-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
}},
{name: "combat-turn-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
}},
{name: "combat-turn-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
}},
})
}

View File

@@ -2,7 +2,9 @@ package shared
import ( import (
"embed" "embed"
"fmt"
"io/fs" "io/fs"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
@@ -11,39 +13,72 @@ import (
//go:embed assets/prompts/*.md //go:embed assets/prompts/*.md
var embeddedAssets embed.FS var embeddedAssets embed.FS
var sharedPromptFiles = []string{ // PromptAssetManifest is the ordered set of assets that make up one prompt.
"common-dnd-system.md", // Module files are addressed in the owning module filesystem; shared files use
"common-dnd-transcript.md", // the names in sharedPromptPaths and are mounted beneath sharedassets.
"common-dnd-references.md", type PromptAssetManifest struct {
"common-dnd-npcs.md", ModuleDir string
ModuleFiles []promptfs.ModulePromptFile
SharedFiles []string
} }
func SharedPromptFiles() []promptfs.SharedPromptFile { var sharedPromptPaths = map[string]string{
files := make([]promptfs.SharedPromptFile, 0, len(sharedPromptFiles)) "common-dnd-system.md": "assets/prompts/common-dnd-system.md",
for _, name := range sharedPromptFiles { "common-dnd-extraction-evidence.md": "assets/prompts/common-dnd-extraction-evidence.md",
"common-dnd-identity.md": "assets/prompts/common-dnd-identity.md",
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md",
"common-dnd-references.md": "assets/prompts/common-dnd-references.md",
"common-dnd-immediate-resolution.md": "assets/prompts/common-dnd-immediate-resolution.md",
"common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md",
}
func (manifest PromptAssetManifest) PromptFS(moduleFS fs.FS) (fs.FS, error) {
sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles)
if err != nil {
return nil, err
}
moduleFiles := append([]promptfs.ModulePromptFile(nil), manifest.ModuleFiles...)
return promptfs.ModulePromptFS(manifest.ModuleDir, moduleFS, moduleFiles, sharedFiles...)
}
func (manifest PromptAssetManifest) Hash(moduleFS fs.FS) (string, error) {
sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles)
if err != nil {
return "", err
}
parts := make([]llm.AssetHashPart, 0, len(manifest.ModuleFiles)+len(sharedFiles))
for _, file := range manifest.ModuleFiles {
parts = append(parts, llm.AssetHashPart{FS: moduleFS, Path: file.Path})
}
for _, file := range sharedFiles {
parts = append(parts, llm.AssetHashPart{FS: file.FS, Path: file.Path})
}
return llm.HashAssets(parts)
}
func resolveSharedPromptFiles(names []string) ([]promptfs.SharedPromptFile, error) {
files := make([]promptfs.SharedPromptFile, 0, len(names))
seen := make(map[string]struct{}, len(names))
for _, name := range names {
if name == "" {
return nil, fmt.Errorf("shared prompt asset name must not be empty")
}
if strings.ContainsAny(name, `/\\`) {
return nil, fmt.Errorf("shared prompt asset name %q must not contain path separators", name)
}
if _, ok := seen[name]; ok {
return nil, fmt.Errorf("duplicate shared prompt asset name %q", name)
}
path, ok := sharedPromptPaths[name]
if !ok {
return nil, fmt.Errorf("unknown shared prompt asset name %q", name)
}
seen[name] = struct{}{}
files = append(files, promptfs.SharedPromptFile{ files = append(files, promptfs.SharedPromptFile{
Name: name, Name: name,
FS: embeddedAssets, FS: embeddedAssets,
Path: "assets/prompts/" + name, Path: path,
}) })
} }
return files return files, nil
}
func CommonHashParts() []llm.AssetHashPart {
return []llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
}
}
func ReferenceHashParts() []llm.AssetHashPart {
return []llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-references.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-npcs.md"},
}
}
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []promptfs.ModulePromptFile) (fs.FS, error) {
return promptfs.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...)
} }

View File

@@ -0,0 +1,14 @@
Transcript units are the only evidence for extracted events and factual claims.
Campaign and registry references may disambiguate names, aliases, speakers, or
other identities, but they do not establish events, participation, effects, or
source evidence.
Every reported factual claim must be supported by cited transcript units. Use
integer `start_unit_id` and `end_unit_id` values from the transcript. Omit
`source_id`; Notarius assigns the current source identity.
When supporting evidence is non-contiguous, use multiple narrow ranges rather
than a broad range that bridges unrelated conversation.
Return exactly one JSON object and no explanatory text. Output only the
configured JSON object and fields defined by its response schema.

View File

@@ -0,0 +1,7 @@
Use the most specific supported in-world character or creature identity for
each actor or participant. Do not identify a human player, transcript speaker,
or the GM as an out-of-world person when an in-world identity is supported.
Player, party, glossary, campaign, and NPC registry references may disambiguate
an identity, but a reference alone cannot establish that the identity
participated in the transcript.

View File

@@ -0,0 +1,7 @@
Report only a declaration or action and its immediate observed resolution.
Immediate resolution may include directly associated rolls, damage, healing,
movement, conditions, target outcomes, interruptions, or other outcomes shown
with that declaration or action.
Do not follow consequences that occur on later turns or elsewhere in the
scene.

View File

@@ -2,6 +2,8 @@ package shared
import ( import (
"io/fs" "io/fs"
"reflect"
"strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
@@ -9,81 +11,191 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
) )
func TestSharedPromptFilesReturnsNewSlice(t *testing.T) { func TestPromptAssetManifestPromptFS(t *testing.T) {
first := SharedPromptFiles() manifest := PromptAssetManifest{
second := SharedPromptFiles() ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
if len(first) != 4 || len(second) != 4 { {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
t.Fatalf("SharedPromptFiles() lengths = %d and %d, want 4", len(first), len(second)) {Name: "task.md", Path: "assets/prompts/task.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-transcript.md",
},
} }
first[0].Name = "changed.md"
if second[0].Name != "common-dnd-system.md" {
t.Fatalf("SharedPromptFiles() reused descriptor slice: %#v", second)
}
}
func TestSharedPromptFilesReferenceEmbeddedAssets(t *testing.T) { fsys, err := manifest.PromptFS(fstest.MapFS{
for _, file := range SharedPromptFiles() {
if file.FS == nil {
t.Fatalf("SharedPromptFiles() descriptor %q has nil FS", file.Name)
}
if _, err := fs.ReadFile(file.FS, file.Path); err != nil {
t.Fatalf("ReadFile(%q) error = %v, want nil", file.Path, err)
}
}
}
func TestHashPartsReferenceSharedPrompts(t *testing.T) {
assertHashParts(t, "common", CommonHashParts(), []string{
"assets/prompts/common-dnd-system.md",
"assets/prompts/common-dnd-transcript.md",
})
assertHashParts(t, "reference", ReferenceHashParts(), []string{
"assets/prompts/common-dnd-references.md",
"assets/prompts/common-dnd-npcs.md",
})
for _, part := range append(CommonHashParts(), ReferenceHashParts()...) {
if _, err := fs.ReadFile(part.FS, part.Path); err != nil {
t.Fatalf("ReadFile(%q) error = %v, want nil", part.Path, err)
}
}
}
func assertHashParts(t *testing.T, name string, parts []llm.AssetHashPart, want []string) {
t.Helper()
if len(parts) != len(want) {
t.Fatalf("%s hash parts length = %d, want %d", name, len(parts), len(want))
}
for i, part := range parts {
if part.Path != want[i] {
t.Fatalf("%s hash part %d path = %q, want %q", name, i, part.Path, want[i])
}
if part.FS == nil {
t.Fatalf("%s hash part %d has nil FS", name, i)
}
}
}
func TestModulePromptFSMountsDNDSharedPrompts(t *testing.T) {
fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
}, []promptfs.ModulePromptFile{ "assets/prompts/task.md": {Data: []byte("task")},
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
}) })
if err != nil { if err != nil {
t.Fatalf("ModulePromptFS() error = %v, want nil", err) t.Fatalf("PromptFS() error = %v, want nil", err)
} }
wantModule := map[string]string{
"assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test",
"assets/prompts/dnd.test/task.md": "task",
}
for path, wantContent := range wantModule {
content, err := fs.ReadFile(fsys, path)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
}
if string(content) != wantContent {
t.Fatalf("ReadFile(%q) = %q, want %q", path, content, wantContent)
}
}
for _, path := range []string{ for _, path := range []string{
"assets/prompts/dnd.test/dnd.test.yaml",
"assets/prompts/dnd.test/sharedassets/common-dnd-system.md", "assets/prompts/dnd.test/sharedassets/common-dnd-system.md",
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md", "assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md",
} {
content, err := fs.ReadFile(fsys, path)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
}
if len(content) == 0 {
t.Fatalf("ReadFile(%q) returned empty content, want mounted asset", path)
}
}
for _, path := range []string{
"assets/prompts/dnd.test/sharedassets/common-dnd-references.md", "assets/prompts/dnd.test/sharedassets/common-dnd-references.md",
"assets/prompts/dnd.test/sharedassets/common-dnd-npcs.md", "assets/prompts/dnd.test/sharedassets/common-dnd-npcs.md",
} { } {
if _, err := fs.ReadFile(fsys, path); err != nil { if _, err := fs.ReadFile(fsys, path); err == nil {
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) t.Fatalf("ReadFile(%q) succeeded, want unlisted shared asset to be absent", path)
} }
} }
} }
func TestPromptAssetManifestRejectsInvalidSharedNames(t *testing.T) {
tests := []struct {
name string
sharedFiles []string
wantError string
}{
{name: "unknown", sharedFiles: []string{"missing.md"}, wantError: "unknown shared prompt asset name"},
{name: "duplicate", sharedFiles: []string{"common-dnd-system.md", "common-dnd-system.md"}, wantError: "duplicate shared prompt asset name"},
{name: "empty", sharedFiles: []string{""}, wantError: "must not be empty"},
{name: "slash path", sharedFiles: []string{"nested/common-dnd-system.md"}, wantError: "must not contain path separators"},
{name: "backslash path", sharedFiles: []string{`nested\common-dnd-system.md`}, wantError: "must not contain path separators"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := (PromptAssetManifest{
ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
},
SharedFiles: test.sharedFiles,
}).PromptFS(fstest.MapFS{
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
})
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("PromptFS() error = %v, want %q", err, test.wantError)
}
})
}
}
func TestPromptAssetManifestRejectsMissingModuleFile(t *testing.T) {
_, err := (PromptAssetManifest{
ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "task.md", Path: "assets/prompts/task.md"},
},
}).PromptFS(fstest.MapFS{})
if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/task.md") {
t.Fatalf("PromptFS() error = %v, want missing module asset context", err)
}
}
func TestPromptAssetManifestRejectsMissingSharedFile(t *testing.T) {
const name = "missing-for-test.md"
const path = "assets/prompts/missing-for-test.md"
previous, existed := sharedPromptPaths[name]
sharedPromptPaths[name] = path
t.Cleanup(func() {
if existed {
sharedPromptPaths[name] = previous
} else {
delete(sharedPromptPaths, name)
}
})
_, err := (PromptAssetManifest{
ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
},
SharedFiles: []string{name},
}).PromptFS(fstest.MapFS{
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
})
if err == nil || !strings.Contains(err.Error(), "read shared prompt asset "+path) {
t.Fatalf("PromptFS() error = %v, want missing shared asset context", err)
}
}
func TestPromptAssetManifestHashMatchesManifestParts(t *testing.T) {
moduleFS := fstest.MapFS{
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
"assets/prompts/task.md": {Data: []byte("task")},
}
manifest := PromptAssetManifest{
ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"},
},
SharedFiles: []string{
"common-dnd-transcript.md",
"common-dnd-system.md",
},
}
got, err := manifest.Hash(moduleFS)
if err != nil {
t.Fatalf("Hash() error = %v, want nil", err)
}
want, err := llm.HashAssets([]llm.AssetHashPart{
{FS: moduleFS, Path: "assets/prompts/dnd.test.yaml"},
{FS: moduleFS, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
})
if err != nil {
t.Fatalf("HashAssets() error = %v, want nil", err)
}
if got != want {
t.Fatalf("Hash() = %q, want independently assembled manifest hash %q", got, want)
}
withUnused, err := llm.HashAssets([]llm.AssetHashPart{
{FS: moduleFS, Path: "assets/prompts/dnd.test.yaml"},
{FS: moduleFS, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-npcs.md"},
})
if err != nil {
t.Fatalf("HashAssets() with unused asset error = %v, want nil", err)
}
if got == withUnused {
t.Fatalf("Hash() included an unlisted shared asset")
}
}
func TestSharedPromptDescriptorsReturnFreshCopies(t *testing.T) {
first, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"})
if err != nil {
t.Fatalf("resolveSharedPromptFiles() error = %v, want nil", err)
}
second, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"})
if err != nil {
t.Fatalf("resolveSharedPromptFiles() second error = %v, want nil", err)
}
first[0].Name = "changed.md"
first[0].Path = "changed.md"
if reflect.DeepEqual(first, second) || second[0].Name != "common-dnd-system.md" || second[0].Path != "assets/prompts/common-dnd-system.md" {
t.Fatalf("resolveSharedPromptFiles() reused descriptor state: first=%#v second=%#v", first, second)
}
}

View File

@@ -0,0 +1,36 @@
package shared
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
// CitedText resolves cited source ranges in document order, including each
// source unit once, and joins the resulting text with newlines.
func CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) {
if doc == nil {
return "", fmt.Errorf("source document must not be nil")
}
included := make([]bool, len(doc.Units))
for _, ref := range refs {
if err := source.ValidateRef(doc, ref); err != nil {
return "", fmt.Errorf("resolve cited source range: %w", err)
}
start, _ := source.UnitIndex(doc, ref.StartUnitID)
end, _ := source.UnitIndex(doc, ref.EndUnitID)
for index := start; index <= end; index++ {
included[index] = true
}
}
parts := make([]string, 0, len(doc.Units))
for index, unit := range doc.Units {
if included[index] {
parts = append(parts, unit.Text)
}
}
return strings.Join(parts, "\n"), nil
}

View File

@@ -0,0 +1,87 @@
package shared
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestCitedText(t *testing.T) {
doc := citationDocument()
tests := []struct {
name string
refs []source.SourceRef
want string
wantErr bool
}{
{name: "empty references", refs: nil, want: ""},
{name: "invalid source id", refs: []source.SourceRef{{SourceID: "other", StartUnitID: 10, EndUnitID: 10}}, wantErr: true},
{name: "unknown start unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 10}}, wantErr: true},
{name: "unknown end unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 99}}, wantErr: true},
{name: "reversed document range", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 30, EndUnitID: 10}}, wantErr: true},
{
name: "disjoint ranges supplied out of order",
refs: []source.SourceRef{
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
},
want: "alpha\ngamma",
},
{
name: "adjacent ranges",
refs: []source.SourceRef{
{SourceID: "session", StartUnitID: 10, EndUnitID: 20},
{SourceID: "session", StartUnitID: 30, EndUnitID: 40},
},
want: "alpha\nbeta\ngamma\ndelta",
},
{
name: "overlapping and duplicate ranges",
refs: []source.SourceRef{
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
{SourceID: "session", StartUnitID: 20, EndUnitID: 40},
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
},
want: "alpha\nbeta\ngamma\ndelta",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
refs := append([]source.SourceRef(nil), test.refs...)
got, err := CitedText(doc, refs)
if (err != nil) != test.wantErr {
t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr)
}
if err == nil && got != test.want {
t.Fatalf("CitedText() = %q, want %q", got, test.want)
}
if !reflect.DeepEqual(doc.Units, beforeUnits) || !reflect.DeepEqual(refs, test.refs) {
t.Fatalf("CitedText() mutated document or references")
}
})
}
}
func TestCitedTextRejectsNilDocument(t *testing.T) {
if _, err := CitedText(nil, nil); err == nil {
t.Fatal("CitedText() error = nil, want nil-document error")
}
}
func citationDocument() *source.SourceDocument {
return &source.SourceDocument{
ID: "session",
Kind: "transcript",
Format: "application/json",
Digest: "sha256:session",
Units: []source.SourceUnit{
{ID: 10, Kind: "message", Text: "alpha"},
{ID: 20, Kind: "message", Text: "beta"},
{ID: 30, Kind: "message", Text: "gamma"},
{ID: 40, Kind: "message", Text: "delta"},
},
}
}

View File

@@ -0,0 +1,30 @@
package shared
import (
"bytes"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// ChunkPromptMaterial prepares the chunk-scoped source material used by D&D
// extractors when constructing their prompt inputs.
func ChunkPromptMaterial(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{}, fmt.Errorf("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
}

View File

@@ -0,0 +1,101 @@
package shared
import (
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestChunkPromptMaterial(t *testing.T) {
chunk := &source.Chunk{
ID: "session-alpha:chunk:0",
Content: []byte(`{"units":[1,2]}`),
MediaType: "application/json",
}
tests := []struct {
name string
sourceInput contracts.LLMInputMaterial
want contracts.LLMInputMaterial
wantErr string
mutateOutput bool
}{
{
name: "fallback to chunk content",
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "", ""),
},
{
name: "clone isolation",
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
mutateOutput: true,
},
{
name: "mismatched content",
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, []byte(`{"units":[9]}`), "sha256:other", "file:///other.json"),
wantErr: "source input must match chunk",
},
{
name: "default fields",
sourceInput: contracts.LLMInputMaterial{
Content: append([]byte(nil), chunk.Content...),
Digest: "sha256:source",
OriginURI: "file:///source.json",
},
want: contracts.LLMInputMaterial{
Name: "source",
MediaType: chunk.MediaType,
Content: append([]byte(nil), chunk.Content...),
Digest: "sha256:source",
OriginURI: "file:///source.json",
SizeBytes: int64(len(chunk.Content)),
},
},
{
name: "preserve explicit metadata",
sourceInput: contracts.LLMInputMaterial{
Name: "transcript",
MediaType: "text/plain",
Content: append([]byte(nil), chunk.Content...),
Digest: "sha256:explicit",
OriginURI: "file:///explicit.txt",
SizeBytes: 42,
},
want: contracts.LLMInputMaterial{
Name: "transcript",
MediaType: "text/plain",
Content: append([]byte(nil), chunk.Content...),
Digest: "sha256:explicit",
OriginURI: "file:///explicit.txt",
SizeBytes: 42,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := contracts.TypedExtractionRequest{Chunk: chunk, SourceInput: test.sourceInput}
got, err := ChunkPromptMaterial(req)
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ChunkPromptMaterial() error = %v, want %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("ChunkPromptMaterial() error = %v, want nil", err)
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("ChunkPromptMaterial() = %#v, want %#v", got, test.want)
}
if test.mutateOutput {
got.Content[0] = 'x'
if string(test.sourceInput.Content) != string(chunk.Content) {
t.Fatalf("ChunkPromptMaterial() output shares content with source input")
}
}
})
}
}

View File

@@ -0,0 +1,43 @@
package shared
import (
"strings"
"unicode"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
// NormalizedTokens returns identity-normalized alphanumeric tokens from a
// D&D value.
func NormalizedTokens(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)
})
}
// ContainsTokenSequence reports whether value contains the complete normalized
// query token sequence as consecutive tokens.
func ContainsTokenSequence(value string, query string) bool {
valueTokens := NormalizedTokens(value)
queryTokens := NormalizedTokens(query)
if len(queryTokens) == 0 || len(queryTokens) > len(valueTokens) {
return false
}
for start := 0; start <= len(valueTokens)-len(queryTokens); start++ {
matches := true
for offset, token := range queryTokens {
if valueTokens[start+offset] != token {
matches = false
break
}
}
if matches {
return true
}
}
return false
}

View File

@@ -0,0 +1,25 @@
package shared
import "testing"
func TestContainsTokenSequence(t *testing.T) {
tests := []struct {
name string
value string
query string
want bool
}{
{name: "unicode apostrophe and case", value: "OrIn\u2003ThOrN advances", query: "o'rin thorn", want: true},
{name: "multiword sequence", value: "Mira Thorn watches", query: "mira thorn", want: true},
{name: "nonconsecutive words", value: "Mira watches Thorn", query: "mira thorn", want: false},
{name: "short name is not substring", value: "A cart rolls past", query: "art", want: false},
{name: "empty query", value: "anything", query: " ", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := ContainsTokenSequence(test.value, test.query); got != test.want {
t.Fatalf("ContainsTokenSequence(%q, %q) = %t, want %t", test.value, test.query, got, test.want)
}
})
}
}

View File

@@ -1,57 +1,107 @@
package shared package shared
import ( import (
"reflect"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
) )
func TestPromptInputsBuildExpectedInputs(t *testing.T) { func TestPromptInputsIdentity(t *testing.T) {
source := contracts.NewLLMInputMaterial("source", "application/json", []byte("source text"), "sha256:source", "file:///source.json") source := contracts.NewLLMInputMaterial("source", "application/json", []byte("source text"), "sha256:source", "file:///source.json")
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ baseReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"players": slotWithContent("players", "Alice: Aria"), "players": slotWithContent("players", "Alice: Aria"),
"party": slotWithContent("party", "Aria: cleric"), "party": slotWithContent("party", "Aria: cleric"),
"glossary": slotWithContent("glossary", "Brightmantle: temple"), "glossary": slotWithContent("glossary", "Brightmantle: temple"),
}} }}
orderedReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
inputs := PromptInputs(source, references) "players": {Items: []contracts.ReferenceItem{
for _, name := range []string{"transcript", "players", "party", "glossary"} { referenceItem("players", "file:///b.txt", "sha256:bbb", "second"),
if _, ok := inputs[name]; !ok { referenceItem("players", "file:///a.txt", "sha256:aaa", "first"),
t.Fatalf("PromptInputs() missing %q: %#v", name, inputs) }},
} }}
} reversedReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
if _, ok := inputs["roster"]; ok { "players": {Items: []contracts.ReferenceItem{
t.Fatalf("PromptInputs() included roster input: %#v", inputs) referenceItem("players", "file:///a.txt", "sha256:aaa", "first"),
} referenceItem("players", "file:///b.txt", "sha256:bbb", "second"),
if got := inputs["transcript"].Name; got != "transcript" { }},
t.Fatalf("transcript name = %q, want transcript", got) }}
} rosterReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
if got := string(inputs["transcript"].Content); got != "source text" {
t.Fatalf("transcript content = %q, want source text", got)
}
if got := string(inputs["players"].Content); got != "Alice: Aria" {
t.Fatalf("players content = %q, want player reference", got)
}
if got := string(inputs["party"].Content); got != "Aria: cleric" {
t.Fatalf("party content = %q, want party reference", got)
}
if got := string(inputs["glossary"].Content); got != "Brightmantle: temple" {
t.Fatalf("glossary content = %q, want glossary reference", got)
}
}
func TestPromptInputsUseRosterWhenPartyIsEmpty(t *testing.T) {
inputs := PromptInputs(contracts.LLMInputMaterial{}, contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"party": {}, "party": {},
"roster": slotWithContent("roster", "Legacy roster text"), "roster": slotWithContent("roster", "Legacy roster text"),
}}) }}
explicitPartyReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"party": slotWithContent("party", "Current party text"),
"roster": slotWithContent("roster", "Legacy roster text"),
}}
if got := string(inputs["party"].Content); got != "Legacy roster text" { tests := []struct {
t.Fatalf("party content = %q, want roster fallback content", got) name string
references contracts.ReferenceSet
other *contracts.ReferenceSet
want map[string]string
}{
{
name: "identical materials produce deeply equal inputs",
references: baseReferences,
other: &baseReferences,
want: map[string]string{
"transcript": "source text",
"players": "Alice: Aria",
"party": "Aria: cleric",
"glossary": "Brightmantle: temple",
},
},
{
name: "reference insertion order does not change bytes",
references: orderedReferences,
other: &reversedReferences,
want: map[string]string{"players": "Reference 1\nOrigin-URI: file:///a.txt\nDigest: sha256:aaa\n\nfirst\n\nReference 2\nOrigin-URI: file:///b.txt\nDigest: sha256:bbb\n\nsecond"},
},
{
name: "roster becomes canonical party input",
references: rosterReferences,
want: map[string]string{"party": "Legacy roster text"},
},
{
name: "explicit party wins over roster",
references: explicitPartyReferences,
want: map[string]string{"party": "Current party text"},
},
{
name: "missing optional slots use placeholders",
references: contracts.ReferenceSet{},
want: map[string]string{
"players": " ",
"party": " ",
"glossary": " ",
},
},
} }
if _, ok := inputs["roster"]; ok {
t.Fatalf("PromptInputs() included roster input: %#v", inputs) for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
inputs := PromptInputs(source, test.references)
if test.other != nil {
other := PromptInputs(source, *test.other)
if !reflect.DeepEqual(inputs, other) {
t.Fatalf("PromptInputs() differs for equivalent references:\nfirst=%#v\nsecond=%#v", inputs, other)
}
}
for name, want := range test.want {
got, ok := inputs[name]
if !ok {
t.Fatalf("PromptInputs() missing %q: %#v", name, inputs)
}
if got := string(got.Content); got != want {
t.Fatalf("%s content = %q, want %q", name, got, want)
}
}
if _, ok := inputs["roster"]; ok {
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
}
})
} }
} }
@@ -149,3 +199,12 @@ func slotWithContent(name string, content string) contracts.ResolvedReferenceSlo
}}, }},
} }
} }
func referenceItem(slotName, uri, digest, content string) contracts.ReferenceItem {
return contracts.ReferenceItem{
SlotName: slotName,
Content: []byte(content),
Digest: digest,
Origin: contracts.ReferenceOrigin{URI: uri},
}
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"strings" "strings"
"testing" "testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -53,6 +54,22 @@ func TestValidatorDefersMalformedShape(t *testing.T) {
} }
} }
func TestValidatorBoundsAggregateDiagnostics(t *testing.T) {
value := validCombatTurnList()
value.CombatTurns[0].SourceRefs = make([]source.SourceRef, 24)
for index := range value.CombatTurns[0].SourceRefs {
value.CombatTurns[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index}
}
value.CombatTurns[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t"
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) {
t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err)
}
if strings.Count(result.Message, "combat_turns[0].source_refs[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") {
t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message)
}
}
func TestSpecRegisterOptionsAndPolicy(t *testing.T) { func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { 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) t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got)

View File

@@ -3,15 +3,12 @@ package sourcerelatedness
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"unicode"
"unicode/utf8" "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/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
) )
@@ -38,13 +35,21 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) { 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) { if combatshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts := make([]string, len(req.Value.CombatTurns))
for turnIndex, turn := range req.Value.CombatTurns {
citedText, err := shared.CitedText(req.Source, turn.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts[turnIndex] = citedText
}
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for turnIndex, turn := range req.Value.CombatTurns { for turnIndex, turn := range req.Value.CombatTurns {
citedText := citedTextKey(req.Source, turn.SourceRefs) citedText := citedTexts[turnIndex]
issues := make([]string, 0) issues := make([]string, 0)
if !actorAppearsInCitedText(citedText, turn.Actor) { if !actorAppearsInCitedText(citedText, turn.Actor) {
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor))) issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)))
@@ -65,47 +70,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
} }
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil 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 { func actorAppearsInCitedText(citedText string, actor string) bool {
key := identity.ComparisonKey(actor) return shared.ContainsTokenSequence(citedText, actor)
return key != "" && strings.Contains(citedText, key)
} }
func declarationAppearsInCitedText(citedText string, declaration string) bool { func declarationAppearsInCitedText(citedText string, declaration string) bool {
citedTokens := tokenSet(citedText) citedTokens := tokenSet(citedText)
for _, token := range comparisonTokens(declaration) { for _, token := range shared.NormalizedTokens(declaration) {
if utf8.RuneCountInString(token) >= 4 { if utf8.RuneCountInString(token) >= 4 {
if _, ok := citedTokens[token]; ok { if _, ok := citedTokens[token]; ok {
return true return true
@@ -115,16 +86,8 @@ func declarationAppearsInCitedText(citedText string, declaration string) bool {
return false 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{} { func tokenSet(value string) map[string]struct{} {
tokens := comparisonTokens(value) tokens := shared.NormalizedTokens(value)
set := make(map[string]struct{}, len(tokens)) set := make(map[string]struct{}, len(tokens))
for _, token := range tokens { for _, token := range tokens {
set[token] = struct{}{} set[token] = struct{}{}

View File

@@ -51,6 +51,20 @@ func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) {
} }
} }
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
resolution := "The cart is struck."
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}},
Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0].Message, "actor") {
t.Fatalf("Validate() = %#v, %v; want short-actor boundary warning", result, err)
}
}
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}} invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape})

View File

@@ -3,13 +3,11 @@ package sourcerelatedness
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
) )
@@ -39,9 +37,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := npcshape.Validate(req.Value); err != nil { if err := npcshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts := make([]string, len(req.Value.NPCs))
for npcIndex, npc := range req.Value.NPCs {
citedText, err := shared.CitedText(req.Source, npc.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts[npcIndex] = citedText
}
var warnings []contracts.Warning var warnings []contracts.Warning
for npcIndex, npc := range req.Value.NPCs { for npcIndex, npc := range req.Value.NPCs {
if npcAppearsInCitedText(req.Source, npc) { if npcAppearsInCitedText(citedTexts[npcIndex], npc) {
continue continue
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
@@ -53,43 +59,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
} }
func npcAppearsInCitedText(doc *source.SourceDocument, npc dnd.NPC) bool { func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
cited := citedTextKey(doc, npc.SourceRefs) if shared.ContainsTokenSequence(citedText, npc.Name) {
if cited == "" {
return false
}
if strings.Contains(cited, identity.ComparisonKey(npc.Name)) {
return true return true
} }
for _, alias := range npc.Aliases { for _, alias := range npc.Aliases {
if strings.Contains(cited, identity.ComparisonKey(alias)) { if shared.ContainsTokenSequence(citedText, alias) {
return true return true
} }
} }
return false return false
} }
func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string {
if doc == nil {
return ""
}
var builder strings.Builder
for _, ref := range refs {
if err := source.ValidateRef(doc, ref); err != nil {
continue
}
start, _ := source.UnitIndex(doc, ref.StartUnitID)
end, _ := source.UnitIndex(doc, ref.EndUnitID)
for index := start; index <= end; index++ {
if builder.Len() > 0 {
builder.WriteByte(' ')
}
builder.WriteString(doc.Units[index].Text)
}
}
return identity.ComparisonKey(builder.String())
}
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
} }

View File

@@ -14,7 +14,11 @@ import (
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) { func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{ value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, {ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
}},
{ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, {ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
}} }}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{ doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{
@@ -41,6 +45,17 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
} }
} }
func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{{
ID: "one", Name: "Art", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err)
}
}
func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) { func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}} invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape})
@@ -49,8 +64,8 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
} }
invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange}) result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange})
if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("invalid-range relatedness = %#v, %v; want one warning", result, err) t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err)
} }
} }

View File

@@ -10,19 +10,26 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
const Key = "extract/dnd/spells/shape" const (
const ReasonCode = "invalid_spell_shape" Key = "extract/dnd/spells/shape"
ReasonCode = "invalid_spell_shape"
policy = "dnd.spells.validator.shape.v1"
)
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key } func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass { func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic 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.SpellList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil { if err := Validate(req.Value); err != nil {
return rejection(err.Error()), nil return rejection(err.Error()), nil

View File

@@ -48,7 +48,13 @@ func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
} }
} }
func TestSpecAndRegister(t *testing.T) { func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
}
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v, want deterministic shape validator", spec)
}
registry := pipeline.NewValidatorRegistry() registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) t.Fatalf("Register() error = %v, want nil", err)

View File

@@ -8,33 +8,45 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
) )
const Key = "extract/dnd/spells/source_refs" const (
const ReasonCode = "invalid_source_refs" Key = "extract/dnd/spells/source_refs"
ReasonCode = "invalid_source_refs"
policy = "dnd.spells.validator.source_refs.v1"
)
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key } func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass { func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic 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.SpellList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := spellshape.Validate(req.Value); err != nil { if err := spellshape.Validate(req.Value); err != nil {
return rejection(err.Error()), nil return contracts.ValidationResult{Approved: true}, nil
} }
issues := make([]string, 0)
for spellIndex, spell := range req.Value.SpellCasts { for spellIndex, spell := range req.Value.SpellCasts {
for refIndex, ref := range spell.SourceRefs { for refIndex, ref := range spell.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil { if err := source.ValidateRef(req.Source, ref); err != nil {
return rejection(fmt.Sprintf("spell_casts[%d].source_refs[%d]: %v", spellIndex, refIndex, err)), nil issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
} }
} }
} }
if len(issues) > 0 {
return rejection(diagnostics.Aggregate("invalid spell source references", issues)), nil
}
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {

View File

@@ -2,7 +2,9 @@ package sourcerefs
import ( import (
"context" "context"
"strings"
"testing" "testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -46,7 +48,37 @@ func TestValidatorRejectsMissingSourceDocument(t *testing.T) {
} }
} }
func TestSpecAndRegister(t *testing.T) { func TestValidatorDefersMalformedShape(t *testing.T) {
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Cure Wounds"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value})
if err != nil || !result.Approved || result.ReasonCode != "" || result.Message != "" {
t.Fatalf("Validate() = %#v, %v; want shape deferral", result, err)
}
}
func TestValidatorAggregatesAndBoundsInvalidSourceReferences(t *testing.T) {
value := requestWithValue(validDocument(), source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}).Value
value.SpellCasts[0].SourceRefs = make([]source.SourceRef, 24)
for index := range value.SpellCasts[0].SourceRefs {
value.SpellCasts[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index}
}
value.SpellCasts[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t"
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) {
t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err)
}
if strings.Count(result.Message, "spell_casts[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") {
t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message)
}
}
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
}
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v, want deterministic source-reference validator", spec)
}
registry := pipeline.NewValidatorRegistry() registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) t.Fatalf("Register() error = %v, want nil", err)

View File

@@ -5,70 +5,59 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
) )
const Key = "extract/dnd/spells/source_relatedness" const (
const WarningReasonCode = "spell_not_near_source" Key = "extract/dnd/spells/source_relatedness"
WarningReasonCode = "spell_not_near_source"
policy = "dnd.spells.validator.source_relatedness.v1"
)
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key } func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass { func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic 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.SpellList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := spellshape.Validate(req.Value); err != nil { if err := spellshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts := make([]string, len(req.Value.SpellCasts))
for spellIndex, spell := range req.Value.SpellCasts {
citedText, err := shared.CitedText(req.Source, spell.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts[spellIndex] = citedText
}
var warnings []contracts.Warning var warnings []contracts.Warning
for spellIndex, spell := range req.Value.SpellCasts { for spellIndex, spell := range req.Value.SpellCasts {
if !spellAppearsInCitedText(req.Source, spell) { if !spellAppearsInCitedText(citedTexts[spellIndex], spell) {
warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("spell %q was not found in cited source text", strings.TrimSpace(spell.Spell))}) warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("spell %q was not found in cited source text", strings.TrimSpace(spell.Spell))})
} }
} }
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
} }
func spellAppearsInCitedText(doc *source.SourceDocument, spell dnd.SpellCast) bool { func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool {
name := strings.ToLower(strings.TrimSpace(spell.Spell)) name := strings.TrimSpace(spell.Spell)
if name == "" { if name == "" {
return true return true
} }
for _, ref := range spell.SourceRefs { return shared.ContainsTokenSequence(citedText, name)
if text, ok := citedText(doc, ref); ok && strings.Contains(strings.ToLower(text), name) {
return true
}
}
return false
}
func citedText(doc *source.SourceDocument, ref source.SourceRef) (string, bool) {
if doc == nil {
return "", false
}
start, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok {
return "", false
}
end, ok := source.UnitIndex(doc, ref.EndUnitID)
if !ok || start > end {
return "", false
}
var b strings.Builder
for i := start; i <= end; i++ {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(doc.Units[i].Text)
}
return b.String(), true
} }
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}

View File

@@ -39,6 +39,47 @@ func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) {
} }
} }
func TestValidatorMatchesCaseInsensitiveUnicodeMultiwordSpellAcrossCitations(t *testing.T) {
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Spell: "Tasha's Hideous Laughter", Effect: "effect", NarrativeDescription: "description",
SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, 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: "Tashas"},
{ID: 2, Kind: "message", Text: "hideous laughter fills the room."},
},
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v; want normalized multiword spell approval", result, err)
}
}
func TestValidatorDoesNotMatchShortSpellNameSubstring(t *testing.T) {
result, err := New(Options{}).Validate(context.Background(), requestWithSpell(&source.SourceDocument{
ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session",
Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party said nothing."}},
}, "Aid", 1))
if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err)
}
}
func TestValidatorIgnoresInvalidCitations(t *testing.T) {
request := requestWithSpell(validDocument(), "Cure Wounds", 2)
request.Value.SpellCasts[0].SourceRefs[0].StartUnitID = 99
result, err := New(Options{}).Validate(context.Background(), request)
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v; want approval without relatedness warning", result, err)
}
}
func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) { func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
if err != nil { if err != nil {
@@ -49,7 +90,13 @@ func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
} }
} }
func TestSpecAndRegister(t *testing.T) { func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
}
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v, want deterministic relatedness validator", spec)
}
registry := pipeline.NewValidatorRegistry() registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) t.Fatalf("Register() error = %v, want nil", err)