Add NPC registry grounding for spell extraction

This commit is contained in:
2026-07-21 03:12:03 +00:00
parent fb043325e1
commit 20cfbfd311
26 changed files with 901 additions and 58 deletions

View File

@@ -94,6 +94,16 @@ go run ./cmd/notarius run dnd-session \
--reference spells.extract.glossary=./campaign-glossary.txt
```
For the operator-driven NPC-to-spell workflow, bind the normalized NPC lane
payload from the completed NPC run to the spell extractor:
```sh
go run ./cmd/notarius run dnd-spells \
--config examples/dnd-npc-spell-sequential.config.yml \
--input examples/seriatim-minimal-transcript.json \
--reference spells.extract.npcs=./npc-run/lanes/npcs.json
```
The same grammar can target chunk, merge, and normalize slots when the configured
modules declare them:

View File

@@ -22,8 +22,9 @@ The explicit-path option is defined in the [CLI reference](cli.md).
- [Minimal D&D spell configuration](../examples/dnd-spells.config.yml)
- [Production-oriented D&D spell configuration](../examples/dnd-spells-production.config.yml)
- [D&D NPC configuration](../examples/dnd-npcs.config.yml)
- [Sequential D&D NPC and spell configuration](../examples/dnd-npc-spell-sequential.config.yml)
Both are complete version 3 files. The fragments below illustrate individual
All are complete version 3 files. The fragments below illustrate individual
fields and are not alternate complete configurations.
## Top-Level Fields
@@ -284,9 +285,11 @@ production validators do not call the LLM and must not set `llm_profile`.
| chunk | `generic` | Splits source units into ordered chunks. |
| chunk | `dnd/scenes` | Uses an LLM to split transcript source units into D&D scenes. |
| extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. |
| extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. |
| merge | `appendorder` | Combines typed artifacts in chunk order. |
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
| normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical identity and aliases. |
| output | `json` | Produces JSON output files for normalized `application/json` lanes. |
## Implemented Production Validators
@@ -301,6 +304,10 @@ production validators do not call the LLM and must not set `llm_profile`.
| `extract/dnd/spells/catalog` | deterministic | Rejects spell-list artifacts containing names outside the effective SRD and overlay catalog. |
| `extract/dnd/spells/source_refs` | deterministic | Rejects missing or invalid D&D spell source references. |
| `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. |
| `extract/dnd/npcs/shape` | deterministic | Rejects malformed D&D NPC-list artifacts. |
| `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. |
| `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name or alias is not found near its cited source text. |
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs, aliases, and cross-record identity collisions. |
The production default chain for `dnd/spells` is used for both its extract and
normalize stages:
@@ -315,8 +322,27 @@ validators:
- extract/dnd/spells/source_relatedness
```
No other production module currently has a default validator chain. Empty
chains approve output by default.
The production default chain for `dnd/npcs` uses the extraction chain for the
extract stage and the identity chain for normalize-stage output:
```yaml
extract:
validators:
- generic/valid_json
- generic/valid_json_schema
- extract/dnd/npcs/shape
- extract/dnd/npcs/source_refs
- extract/dnd/npcs/source_relatedness
normalize:
validators:
- generic/valid_json
- generic/valid_json_schema
- normalize/dnd/npcs/identity
- extract/dnd/npcs/source_refs
- extract/dnd/npcs/source_relatedness
```
Empty chains approve output by default.
The `generic` chunker accepts:
@@ -345,6 +371,17 @@ not allow multiple files. Its format is defined in the
The extractor uses campaign references only as supporting disambiguation
material; spell casts still must be present in the source transcript.
It also declares an optional `npcs` slot for a normalized NPC artifact. The
slot accepts exactly one `application/json` file no larger than 1 MiB. During
extractor preparation Notarius strictly decodes and identity-validates the
artifact, then gives the model canonical JSON for caster-name grounding.
Registry source references may belong to the NPC-producing session and are
provenance only; they are not spell evidence. The bound registry contributes a
semantic digest and NPC count to extractor metadata and checkpoint identity,
while its names, aliases, content, and path do not appear there. When absent,
the prompt receives the exact empty value `{"npcs":[]}` and no registry
provenance or fingerprint is recorded.
The `dnd/spells` normalizer declares the same optional `spell_catalog` slot.
When an overlay is used, bind it independently under
`artifacts.<lane>.normalize.references.spell_catalog`; normalize-stage
@@ -352,6 +389,12 @@ references are local to that stage and are not inherited from extraction. The
normalizer uses the embedded SRD catalog when no normalize-stage overlay is
bound.
The `dnd/npcs` extractor declares the same optional campaign slots as the spell
extractor, but it does not declare the `npcs` registry slot. Its normalizer
accepts no references. To pass an NPC result to a later spell run, bind the
normalized payload explicitly at runtime; the maintained sequential example
documents that operator workflow.
## State Surfaces
The `output`, `cache`, and `debug` top-level fields select independent physical

View File

@@ -1,8 +1,9 @@
# D&D NPC Artifact
This document defines the durable D&D NPC-list artifact and its JSON codec.
The artifact type and codec are implemented, but no selectable production
pipeline currently produces this artifact.
This document defines the durable D&D NPC-list artifact, its JSON codec, and
the selectable production NPC pipeline. The normalized JSON payload can be
passed explicitly to the spell extractor as an optional caster-name registry;
it remains a reference, not spell evidence.
## Identity
@@ -55,3 +56,51 @@ reference shapes, and the NPC ID pattern.
Codec metadata contains only `npc_count`. Schema bytes and returned metadata
are independent values so callers cannot mutate codec-owned state.
## Production Pipeline
The production identities are:
- extractor: `dnd/npcs`;
- artifact kind: `dnd/npc-list`;
- normalizer: `dnd/npcs`; and
- durable schema: `notarius.dnd.npcs`, version `v1`, media type
`application/json`.
The extractor maps private model records to the current source identity and
assigns deterministic IDs. Extraction validation checks shape, source
references, and source relatedness. The normalizer then consolidates records
by canonical identity or canonical-name/alias matches, preserves the first
record's display and output position, unions relationships and exact evidence,
rewrites unambiguous relationship targets to canonical names, and validates
the retained registry's identity. No LLM is used for consolidation.
The default extraction chain is `generic/valid_json`,
`generic/valid_json_schema`, `extract/dnd/npcs/shape`,
`extract/dnd/npcs/source_refs`, and
`extract/dnd/npcs/source_relatedness`. The normalize chain adds
`normalize/dnd/npcs/identity` before the source-reference and relatedness
checks. Relatedness emits bounded warnings when an NPC canonical name or
alias is not present near its cited transcript text; opaque campaign
references may explain such a warning but do not become evidence.
## Manifest And Sequential Consumption
The NPC extractor records prompt and response-schema identities. The durable
codec records only `npc_count`; raw names, aliases, descriptions, source
references, and payload bytes stay in the lane file rather than manifest
metadata. The normalized lane is independently reusable as a file reference:
```sh
go run ./cmd/notarius run dnd-spells \
--config examples/dnd-npc-spell-sequential.config.yml \
--input examples/seriatim-minimal-transcript.json \
--reference spells.extract.npcs=./npc-output/<run-id>/lanes/npcs.json
```
The spell extractor strictly decodes and identity-validates this file, accepts
source references belonging to another session as registry provenance, and
uses only canonical names and aliases for caster grounding. Those NPC source
references are never accepted as spell evidence. The spell run's manifest
keeps raw file provenance under `references` and records only the prepared
registry's semantic digest and count in extractor metadata.

View File

@@ -94,6 +94,22 @@ Reference slot keys and accepted file types are defined in
supporting disambiguation material, not source evidence, and are not
addressable through `source_refs`.
## Optional NPC Grounding
The `dnd/spells` extractor accepts an optional `npcs` reference containing one
normalized NPC artifact as `application/json`, up to 1 MiB. Preparation uses
the approved NPC codec and identity policy to validate the file, re-encodes
canonical durable JSON, and supplies that JSON as a spell-owned prompt input.
It helps the model prefer canonical caster names and recognize aliases; it
does not establish that a spell was cast.
NPC source references may identify the run that produced the registry or any
other session. They remain registry provenance and are never copied into a
spell cast's `source_refs`; every spell evidence range must still identify the
current transcript. When the slot is absent, the prompt receives exactly
`{"npcs":[]}` and the run has no NPC reference provenance or NPC checkpoint
fingerprint.
## Normalization Behavior
When the `dnd/spells` normalizer is selected, each recognized spell name is
@@ -153,7 +169,9 @@ manifest metadata:
"response_schema_sha256": "sha256:...",
"catalog_base_id": "dnd-5e-2014-srd-spells",
"catalog_digest": "sha256:...",
"catalog_overlay_ids": ["campaign.example"]
"catalog_overlay_ids": ["campaign.example"],
"npc_registry_digest": "sha256:...",
"npc_count": 3
},
"normalizer": {
"catalog_base_id": "dnd-5e-2014-srd-spells",
@@ -172,8 +190,15 @@ identity fields when that module is selected. Overlay origin, media type, byte
size, and raw digest are recorded separately in the manifest's reference
provenance; see the [JSON output contract](json-output.md#manifestjson).
The `npc_registry_digest` and `npc_count` fields in the example are present only
when the optional NPC registry is bound. They contain no NPC names, aliases,
source references, paths, or raw bytes.
The extractor's prompt hash, private response-schema hash, and effective catalog
digest also contribute independently scoped semantic checkpoint fingerprints.
Changing any of those prepared contracts intentionally produces a cold
checkpoint miss. Fingerprints contain only digests, never prompt, schema,
catalog, or reference content.
catalog, or reference content. When an NPC registry is bound, its semantic
digest contributes an additional local `npc_registry` fingerprint; the
manifest metadata contains only that digest and `npc_count`. Raw NPC file
provenance remains independently recorded in the manifest's `references` list.

View File

@@ -151,7 +151,8 @@ accepts only artifacts whose codec media type is `application/json`. The file
contains the codec-owned JSON bytes pretty-printed.
The schema of each lane payload is owned by that artifact contract. For the
current D&D spell lane, see [D&D Spell Artifact](dnd-spell-artifacts.md).
current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md) and
[D&D NPC Artifact](dnd-npc-artifacts.md).
## `rejected.json`

View File

@@ -39,9 +39,9 @@ without exposing Scriptorium types through stage contracts.
7. injecting that one shared client into complete pipeline preparation before
the source file is read or the runner is invoked.
The D&D scene chunker and spell extractor retain this injected client and use
it for every structured completion. Operation requests do not carry an LLM
client.
The D&D scene chunker and spell and NPC extractors retain this injected client
and use it for every structured completion. Operation requests do not carry an
LLM client.
The CLI separately gathers explicit profile IDs from resolved LLM-capable stage
and validator bindings. It prepares a small internal check prompt for each ID so
@@ -104,11 +104,15 @@ The small framework registry contains only generic test schemas; production
schemas remain package-owned.
The spell extractor's package-owned prompt declares a required
`application/json` `spell_catalog` input. The extractor generates that input
from its prepared effective catalog as `{"spell_names":[...]}` using sorted
canonical names only. Its input digest covers those generated bytes; manifests
record catalog identity and digest rather than names, aliases, overlay bytes,
or source metadata.
`application/json` `spell_catalog` input and an optional `application/json`
`npcs` input. The extractor generates the catalog input from its prepared
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
When an NPC registry is bound, it strictly decodes and identity-validates one
durable artifact, re-encodes canonical JSON, and generates a semantic digest
over those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests
cover the generated bytes; manifests record catalog identity and optional NPC
registry digest/count rather than names, aliases, overlay bytes, registry
paths, or source metadata.
## Debug And Redaction Boundaries

View File

@@ -25,8 +25,9 @@ of that set; input and output builders receive no references. The spell
extractor is typed over the canonical D&D model. D&D validators, merge, and
normalize use typed variants; JSON representation validators use serialized
requests; and unconditional validators expose separate chunk and typed
variants. The D&D production registrar registers only the canonical typed spell
implementations.
variants. The D&D production registrar registers the canonical typed spell and
NPC implementations, including their kind-specific merge and normalize
behavior.
Prepared extractors, extract validators, and codecs may be reused concurrently
by the run-wide extract pool. Production implementations are immutable after
@@ -81,6 +82,15 @@ semantic digest; overlay content remains contextual reference material rather
than source evidence. Its external JSON contract is defined in the
[spell-catalog overlay contract](../integrations/dnd-spell-catalog-overlays.md).
### `internal/modules/dnd/npcs/identity` and `internal/modules/dnd/codec/npcs`
The NPC identity package owns Unicode comparison keys, deterministic
`npc:sha256:` IDs, display normalization, and whole-registry collision issues.
The NPC codec owns the strict durable `dnd/npc-list` JSON boundary and exposes
candidate versus approved encode/decode operations. NPC source references are
durable provenance and may later be consumed by another pipeline as registry
context without being treated as evidence for that pipeline.
## Input Adapter
### `internal/modules/seriatim/input/transcript`
@@ -186,9 +196,34 @@ stages, using the codec only for checkpoint, debug, and output boundaries.
Shared D&D helpers keep prompt input
names and source-unit reference conversion consistent with the scene chunker.
The extractor also declares the optional `npcs` registry slot. Preparation
requires one approved `application/json` item no larger than 1 MiB, validates
identity without relating registry source references to the current transcript,
and supplies canonical JSON to a spell-owned prompt message. A bound registry
adds only `npc_registry_digest` and `npc_count` to manifest metadata and an
`npc_registry` checkpoint fingerprint. The unbound prompt input is exactly
`{"npcs":[]}` and has no registry provenance or fingerprint.
The durable payload and manifest metadata shapes are defined in the
[D&D spell artifact contract](../integrations/dnd-spell-artifacts.md).
### `internal/modules/dnd/extract/npcs`
The NPC extractor maps private model output to the canonical `dnd.NPCList`,
assigns source identity and deterministic NPC IDs, and preserves source
references for deterministic validation. It uses the shared campaign
references only for disambiguation and does not consume the spell-owned NPC
registry slot. Its prompt and private response schema are package-owned.
### `internal/modules/dnd/normalize/npcs`
The NPC normalizer performs deterministic identity-aware consolidation in
merged input order. It unions only canonical identity or canonical/alias
matches, retains the first display record, unions exact relationships and
source references, rewrites unambiguous relationship targets, and leaves
ambiguous collisions for identity validation. It exposes the identity policy
as its local checkpoint fingerprint and emits bounded normalization warnings.
## Merger And Normalizer
### `internal/modules/generic/merge/appendorder`
@@ -272,6 +307,17 @@ are defined in
payload rules are defined in the
[artifact contract](../integrations/dnd-spell-artifacts.md).
## D&D NPC Validators
NPC shape validation checks required strings, arrays, and source-reference
shape. The source-reference validator checks current-document identity, unit
existence, and range ordering; source relatedness emits at most one bounded
warning per record when neither the canonical name nor an alias occurs near
its cited text. Normalize identity validation checks deterministic IDs,
canonical names, aliases, and cross-record ownership or canonical collisions.
All are deterministic and expose the policy fingerprints used by the
production chains.
## Production Registration
Production composition occurs through family registrars. The CLI allocates one
@@ -280,8 +326,8 @@ complete framework registry set and one LLM asset registry. It invokes
`internal/modules/seriatim/register`, and `internal/modules/dnd/register` in
that order, then exposes the matching catalog for resolution. The generic and
Seriatim registrars own their production leaf registrations. The D&D registrar
owns D&D leaf registrations, the typed spell default-validator chains, and D&D
prompt/schema asset collection.
owns D&D leaf registrations, typed spell and NPC default-validator chains,
typed append-order specializations, and D&D prompt/schema asset collection.
Concrete implementation packages do not import generic implementation
packages directly. A concrete family's `register` package is its composition

View File

@@ -65,9 +65,9 @@ run-local construction closures. Preparation injects shared dependencies and
constructs input, chunk, validators, ordered lanes, and output before source
parsing. Production modules use strict construction-time option decoding, and
LLM-backed modules retain the injected shared client. The D&D family registers
the canonical `dnd/spell-list` codec, typed spell extractor, normalizer, and
validators, plus kind-specific generic merge strategies; generic JSON validators
use the serialized-validation contract. The runner executes lanes through
the canonical `dnd/spell-list` and `dnd/npc-list` codecs, typed spell and NPC
extractors and normalizers, validators, plus kind-specific generic merge
strategies; generic JSON validators use the serialized-validation contract. The runner executes lanes through
private exact-type-checked closures, coordinates extract results independently
of completion timing, and serializes artifacts only through their codec at
checkpoint, debug, and output boundaries.
@@ -84,13 +84,17 @@ Configuration. The implemented module packages are:
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list and spell-cast artifact types. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, and relationship artifact types. |
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
| `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. |
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
| `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. |
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
| `internal/modules/generic/normalize/noop` | Preserves accepted merged output. |
| `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. |
| `internal/modules/dnd/normalize/npcs` | Consolidates NPC records deterministically by identity and aliases, rewrites unambiguous relationship targets, and reports bounded warnings. |
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. |
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
@@ -98,6 +102,14 @@ reference declarations, prompt input assembly, and source-unit reference
helpers. Domain-neutral prompt filesystem composition lives in
`internal/framework/promptfs`.
The spell extractor owns its optional `npcs` registry boundary. Preparation
strictly decodes and identity-validates one normalized JSON artifact, emits
canonical registry JSON to the spell prompt, and records only its semantic
digest and count in prepared metadata. The raw reference remains independently
tracked by pipeline provenance. An absent registry is represented only by the
empty prompt value `{"npcs":[]}`; the shared D&D reference fragment is not
changed.
Generic validators under `internal/modules/generic/validate` provide
unconditional test decisions, JSON syntax validation, and JSON Schema
validation. D&D spell validators under `internal/modules/dnd/validate/spells`

View File

@@ -40,6 +40,36 @@ names, schemas, and media types inside a run directory.
Remove an output run directory only after its consumer data is no longer
needed. This is data deletion, not cache cleanup.
## Sequential NPC And Spell Runs
The maintained [sequential configuration](../examples/dnd-npc-spell-sequential.config.yml)
contains two independent pipelines over the same Seriatim input shape. Run the
NPC pipeline first and retain its normalized payload:
```sh
go run ./cmd/notarius run dnd-npcs \
--config examples/dnd-npc-spell-sequential.config.yml \
--input examples/seriatim-minimal-transcript.json \
--output-dir ./npc-output
```
Then bind that completed run's `lanes/npcs.json` file to the spell extractor:
```sh
go run ./cmd/notarius run dnd-spells \
--config examples/dnd-npc-spell-sequential.config.yml \
--input examples/seriatim-minimal-transcript.json \
--reference spells.extract.npcs=./npc-output/<run-id>/lanes/npcs.json
```
The NPC file is a reference for canonical caster names and aliases, not spell
evidence. The spell manifest records the bound file's raw reference provenance
and the prepared registry's count and semantic digest separately. The NPC
payload, names, aliases, source references, and file bytes can be sensitive
campaign data; protect both output roots and any checkpoint or debug roots that
retain derived application data. A registry from another session is allowed,
but its source references are never copied into spell output evidence.
## Chunk-Plan Cache
Chunk plans are stored at:

View File

@@ -1,6 +1,6 @@
# D&D NPC Extraction And Registry
Status: Accepted.
Status: Complete.
## Purpose

View File

@@ -18,10 +18,6 @@ not as committed release dates.
### Add Sequential D&D Artifacts
- Add the proposed [D&D NPC extraction and registry](dnd-npc-extraction.md) as
the next sequential artifact. It defines canonical identity, aliases,
descriptions, relationships, evidence, deterministic consolidation, and
direct reuse as spell-pipeline reference material.
- Add combat-turn extraction with explicit event and source-reference
semantics. Use earlier NPC output as a reference to improve participant
identity and consistency.

View File

@@ -1,6 +1,6 @@
# D&D NPC Extraction And Registry Implementation Plan
Status: Ready for implementation.
Status: Complete.
Implement this plan in order. The feature policy and target state are defined
in [D&D NPC Extraction And Registry](dnd-npc-extraction.md); this document owns

View File

@@ -0,0 +1,28 @@
version: 3
output:
directory: ./notarius-output
cache:
chunk_plans:
mode: bypass
checkpoints:
enabled: false
directory: ""
debug:
directory: ./notarius-debug
pipelines:
dnd-npcs:
input: seriatim
chunk: generic
artifacts:
npcs:
extract:
module: dnd/npcs
retries: 2
normalize: dnd/npcs
dnd-spells:
input: seriatim
chunk: generic
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells

View File

@@ -20,27 +20,29 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
for _, example := range maintainedExampleFiles(t) {
t.Run(example.name, func(t *testing.T) {
cfg := loadMaintainedExample(t, example.path)
effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("resolve maintained example: %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: example.path,
WorkingDir: filepath.Dir(example.path),
})
if err != nil {
t.Fatalf("materialize maintained example references: %v", err)
}
if example.name == "production" {
if len(materialized.ArtifactLanes) != 1 ||
len(materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
len(materialized.ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 {
t.Fatalf("production spell catalog reference was not materialized: %#v", materialized.ArtifactLanes)
for _, pipelineID := range example.pipelineIDs {
effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, pipelineID))
if err != nil {
t.Fatalf("resolve maintained example %q: %v", pipelineID, err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: example.path,
WorkingDir: filepath.Dir(example.path),
})
if err != nil {
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
}
if example.name == "production" {
if len(materialized.ArtifactLanes) != 1 ||
len(materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
len(materialized.ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 {
t.Fatalf("production spell catalog reference was not materialized: %#v", materialized.ArtifactLanes)
}
}
}
var stdout, stderr strings.Builder
code := RunWithOptions([]string{"pipelines", "list", "--config", example.path}, &stdout, &stderr, productionOptionsFromComponents(components))
if code != 0 || stdout.String() != "dnd-session\n" || stderr.Len() != 0 {
if code != 0 || stdout.String() != strings.Join(example.pipelineIDs, "\n")+"\n" || stderr.Len() != 0 {
t.Fatalf("pipelines list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})

View File

@@ -0,0 +1,64 @@
package cli
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestOversizedNPCRegistryFailsBeforeRuntimeAndCheckpointConstruction(t *testing.T) {
components := productionTestComponents(t)
npcPath := filepath.Join(t.TempDir(), "npcs.json")
if err := os.WriteFile(npcPath, []byte(strings.Repeat("x", 1048577)), 0o600); err != nil {
t.Fatal(err)
}
checkpointRoot := filepath.Join(t.TempDir(), "checkpoints")
content := string(readRepositoryFile(t, "examples", "dnd-npc-spell-sequential.config.yml"))
content = replaceRequiredOnce(t, content, " extract: dnd/spells", " extract:\n module: dnd/spells\n references:\n npcs: "+npcPath)
content = replaceRequiredOnce(t, content, " enabled: false\n directory: \"\"", " enabled: true\n directory: "+checkpointRoot)
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
llmConstructed := false
chunkStoreConstructed := false
options := Options{
Catalog: catalogFromRegistries(components.registries),
Registries: components.registries,
LLMClientFactory: func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
llmConstructed = true
return nil, nil, errors.New("LLM client must not be constructed")
},
ChunkPlanStoreFactory: func(string) (pipeline.ChunkPlanStore, error) {
chunkStoreConstructed = true
return nil, errors.New("chunk-plan store must not be constructed")
},
}
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-spells", "--config", configPath,
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
"--chunk_cache", "bypass", "--output-dir", t.TempDir(),
}, &stdout, &stderr, options)
for _, fragment := range []string{`pipeline "dnd-spells"`, `reference slot "npcs"`, "1048577 bytes", "limit 1048576"} {
if code == 0 || !strings.Contains(stderr.String(), fragment) {
t.Fatalf("RunWithOptions() code = %d stderr = %q, want context fragment %q", code, stderr.String(), fragment)
}
}
if llmConstructed || chunkStoreConstructed {
t.Fatalf("runtime construction = LLM %t, chunk store %t; want materialization failure first", llmConstructed, chunkStoreConstructed)
}
if _, err := os.Stat(checkpointRoot); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("checkpoint root stat error = %v, want no checkpoint allocation", err)
}
}

View File

@@ -454,16 +454,18 @@ func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
}
type maintainedExample struct {
name string
path string
name string
path string
pipelineIDs []string
}
func maintainedExampleFiles(t *testing.T) []maintainedExample {
t.Helper()
return []maintainedExample{
{name: "minimal", path: repositoryPath("examples", "dnd-spells.config.yml")},
{name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml")},
{name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml")},
{name: "minimal", path: repositoryPath("examples", "dnd-spells.config.yml"), pipelineIDs: []string{"dnd-session"}},
{name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml"), pipelineIDs: []string{"dnd-session"}},
{name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml"), pipelineIDs: []string{"dnd-session"}},
{name: "sequential", path: repositoryPath("examples", "dnd-npc-spell-sequential.config.yml"), pipelineIDs: []string{"dnd-npcs", "dnd-spells"}},
}
}

View File

@@ -17,6 +17,9 @@ inputs:
- name: glossary
required: false
content_type: text/plain
- name: npcs
required: false
content_type: application/json
messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
@@ -30,6 +33,8 @@ messages:
type: ephemeral
- role: user
content_file: ./catalog.md
- role: user
content_file: ./npc_registry.md
- role: user
content_file: ./task.md
- role: user

View File

@@ -0,0 +1,11 @@
The optional canonical NPC registry for this extraction is provided below as
durable JSON. Use it only to prefer exact canonical NPC names and recognize
their aliases when the transcript identifies a caster.
Registry entries are grounding material, not evidence that a spell was cast.
Do not extract a spell, caster, effect, or source reference from the registry.
NPC source references describe registry provenance and may belong to another
session; they are never spell evidence. Preserve the existing player and party
policy for identifying PCs from the transcript.
{{ input "npcs" }}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
@@ -34,12 +35,19 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
return append(slots, contracts.ReferenceSlot{
slots = append(slots, contracts.ReferenceSlot{
Name: spellcatalog.SpellCatalogReferenceSlot,
Description: "Optional canonical spell-name catalog used for extraction grounding.",
AcceptedMediaTypes: []string{"application/json"},
MaxBytes: 1048576,
}, contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical caster-name grounding.",
AcceptedMediaTypes: []string{"application/json"},
MaxBytes: NPCRegistryMaxBytes,
})
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })
return slots
}
var _ contracts.Extractor[dnd.SpellList] = (*Extractor)(nil)
@@ -51,6 +59,7 @@ type Extractor struct {
llm contracts.StructuredLLMClient
effectiveCatalog spellcatalog.EffectiveCatalog
catalogPromptInput contracts.LLMInputMaterial
npcRegistry npcRegistryPromptInput
promptSHA string
responseSchemaSHA string
}
@@ -74,6 +83,10 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
if err != nil {
return nil, extractorErrorf("prepare spell catalog prompt input: %w", err)
}
npcRegistry, err := resolveNPCRegistry(referenceSet)
if err != nil {
return nil, extractorErrorf("prepare NPC registry prompt input: %w", err)
}
promptSHA, err := scriptoriumPromptMetadata()
if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err)
@@ -86,6 +99,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
llm: llmClient,
effectiveCatalog: effectiveCatalog,
catalogPromptInput: catalogPromptInput,
npcRegistry: npcRegistry,
promptSHA: promptSHA,
responseSchemaSHA: responseSchema.SHA256,
}, nil
@@ -113,6 +127,10 @@ func (e *Extractor) ManifestMetadata() map[string]any {
"response_schema_version": SchemaVersion,
"response_schema_sha256": e.responseSchemaSHA,
}
if e.npcRegistry.bound {
metadata["npc_registry_digest"] = e.npcRegistry.digest
metadata["npc_count"] = e.npcRegistry.count
}
return metadata
}
@@ -120,11 +138,15 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if e == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
fingerprints := []pipeline.CheckpointFingerprint{
{Name: "effective_catalog", Value: e.effectiveCatalog.Digest()},
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
}
if e.npcRegistry.bound {
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: e.npcRegistry.digest})
}
return fingerprints
}
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SpellList], error) {
@@ -157,6 +179,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References)
inputs[spellcatalog.SpellCatalogReferenceSlot] = e.catalogPromptInput.Clone()
inputs[NPCRegistryReferenceSlot] = e.npcRegistry.input.Clone()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,

View File

@@ -0,0 +1,89 @@
package spells
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"mime"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
const (
NPCRegistryReferenceSlot = "npcs"
NPCRegistryMaxBytes = 1048576
)
type npcRegistryPromptInput struct {
input contracts.LLMInputMaterial
digest string
count int
bound bool
}
func resolveNPCRegistry(references contracts.ReferenceSet) (npcRegistryPromptInput, error) {
slot, ok := references.Slots[NPCRegistryReferenceSlot]
if !ok {
return npcRegistryPromptInput{
input: contracts.NewLLMInputMaterial(
NPCRegistryReferenceSlot,
"application/json",
[]byte(`{"npcs":[]}`),
"",
"",
),
}, nil
}
if len(slot.Items) != 1 {
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q must contain exactly one item", NPCRegistryReferenceSlot)
}
item := slot.Items[0]
mediaType, _, err := mime.ParseMediaType(item.MediaType)
if err != nil {
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q item media type %q is invalid: %w", NPCRegistryReferenceSlot, item.MediaType, err)
}
if !strings.EqualFold(mediaType, "application/json") {
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q item media type %q must be application/json", NPCRegistryReferenceSlot, item.MediaType)
}
if len(item.Content) > NPCRegistryMaxBytes {
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q item is %d bytes, limit %d", NPCRegistryReferenceSlot, len(item.Content), NPCRegistryMaxBytes)
}
codec := npccodec.New()
value, err := codec.Decode(item.Content)
if err != nil {
return npcRegistryPromptInput{}, fmt.Errorf("decode NPC registry: %w", err)
}
if issues := identity.ValidateList(value); len(issues) > 0 {
return npcRegistryPromptInput{}, fmt.Errorf("validate NPC registry identity: %s", formatNPCIdentityIssues(issues))
}
content, err := codec.Encode(value)
if err != nil {
return npcRegistryPromptInput{}, fmt.Errorf("encode canonical NPC registry: %w", err)
}
digest := semanticNPCRegistryDigest(content)
return npcRegistryPromptInput{
input: contracts.NewLLMInputMaterial(NPCRegistryReferenceSlot, "application/json", content, digest, ""),
digest: digest,
count: len(value.NPCs),
bound: true,
}, nil
}
func semanticNPCRegistryDigest(content []byte) string {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
}
func formatNPCIdentityIssues(issues []identity.Issue) string {
parts := make([]string, len(issues))
for index, issue := range issues {
parts[index] = fmt.Sprintf("%s at record %d", issue.Code, issue.RecordIndex)
}
return strings.Join(parts, ", ")
}

View File

@@ -0,0 +1,236 @@
package spells
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestResolveNPCRegistryUsesExactEmptyPromptWhenUnbound(t *testing.T) {
resolved, err := resolveNPCRegistry(contracts.ReferenceSet{})
if err != nil {
t.Fatalf("resolveNPCRegistry() error = %v, want nil", err)
}
if resolved.bound || resolved.digest != "" || resolved.count != 0 {
t.Fatalf("resolved unbound registry = %#v, want no semantic metadata", resolved)
}
if resolved.input.Name != NPCRegistryReferenceSlot || resolved.input.MediaType != "application/json" || resolved.input.Digest != "" || resolved.input.OriginURI != "" {
t.Fatalf("unbound prompt input metadata = %#v, want name/media type only", resolved.input)
}
if got := string(resolved.input.Content); got != `{"npcs":[]}` {
t.Fatalf("unbound prompt input = %q, want exact empty registry", got)
}
if resolved.input.SizeBytes != int64(len(`{"npcs":[]}`)) {
t.Fatalf("unbound prompt input size = %d, want %d", resolved.input.SizeBytes, len(`{"npcs":[]}`))
}
if metadata := newExtractor(t, &fakeSpellsLLMClient{}).ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata)
}
fingerprints := newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints()
for _, fingerprint := range fingerprints {
if fingerprint.Name == "npc_registry" {
t.Fatalf("unbound checkpoint fingerprints = %#v, want no NPC registry fingerprint", fingerprints)
}
}
}
func TestResolveNPCRegistryCanonicalizesContentAndUsesSemanticDigest(t *testing.T) {
value := validNPCRegistryList()
canonical := encodeNPCRegistry(t, value)
raw := append([]byte(" \n"), canonical...)
raw = append(raw, []byte("\n ")...)
resolved, err := resolveNPCRegistry(npcRegistryReference(raw, "file:///another-session/npcs.json"))
if err != nil {
t.Fatalf("resolveNPCRegistry() error = %v, want nil", err)
}
if !resolved.bound || resolved.count != len(value.NPCs) {
t.Fatalf("resolved registry = %#v, want bound registry with %d NPC", resolved, len(value.NPCs))
}
if !bytes.Equal(resolved.input.Content, canonical) {
t.Fatalf("canonical prompt input = %s, want %s", resolved.input.Content, canonical)
}
if resolved.input.Digest != semanticNPCRegistryDigest(canonical) || resolved.digest != resolved.input.Digest {
t.Fatalf("semantic digest = %q/%q, want %q", resolved.input.Digest, resolved.digest, semanticNPCRegistryDigest(canonical))
}
if resolved.input.OriginURI != "" {
t.Fatalf("prompt input origin = %q, want no provenance path", resolved.input.OriginURI)
}
resolved.input.Content[0] = 'X'
again, err := resolveNPCRegistry(npcRegistryReference(raw, "file:///another-session/npcs.json"))
if err != nil {
t.Fatalf("second resolveNPCRegistry() error = %v, want nil", err)
}
if !bytes.Equal(again.input.Content, canonical) {
t.Fatalf("canonical content changed after caller mutation = %s, want %s", again.input.Content, canonical)
}
}
func TestResolveNPCRegistryRejectsInvalidBoundaryValues(t *testing.T) {
valid := validNPCRegistryList()
second := validNPCRegistryList().NPCs[0]
second.ID = identity.DeriveID("Captain Vale")
second.Name = "Captain Vale"
second.Aliases = []string{"The Greencloak"}
valueWithAliasCollision := dnd.NPCList{NPCs: []dnd.NPC{valid.NPCs[0], second}}
invalidID := valid
invalidID.NPCs[0].ID = "not-an-npc-id"
tests := []struct {
name string
reference contracts.ReferenceSet
wantError string
}{
{name: "zero items", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {Items: []contracts.ReferenceItem{}}}}, wantError: "exactly one"},
{name: "multiple", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {Items: []contracts.ReferenceItem{{Content: []byte(`{"npcs":[]}`)}, {Content: []byte(`{"npcs":[]}`)}}}}}, wantError: "exactly one"},
{name: "wrong media type", reference: npcRegistryReferenceWithMedia([]byte(`{"npcs":[]}`), "text/plain"), wantError: "must be application/json"},
{name: "malformed JSON", reference: npcRegistryReference([]byte(`{"npcs":[`), "file:///private.json"), wantError: "decode NPC registry"},
{name: "unknown field", reference: npcRegistryReference([]byte(`{"npcs":[],"unexpected":true}`), "file:///private.json"), wantError: "unknown field"},
{name: "invalid ID", reference: npcRegistryReference(marshalNPCRegistry(t, invalidID), "file:///private.json"), wantError: "decode NPC registry"},
{name: "alias collision", reference: npcRegistryReference(encodeNPCRegistry(t, valueWithAliasCollision), "file:///private.json"), wantError: string(identity.IssueAliasOwnershipCollision)},
{name: "byte limit", reference: npcRegistryReference(bytes.Repeat([]byte("x"), NPCRegistryMaxBytes+1), "file:///private.json"), wantError: "limit"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := resolveNPCRegistry(test.reference)
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("resolveNPCRegistry() error = %v, want %q", err, test.wantError)
}
if strings.Contains(err.Error(), "Mira Thorn") || strings.Contains(err.Error(), "The Greencloak") || strings.Contains(err.Error(), "private.json") {
t.Fatalf("error leaked registry content or provenance: %v", err)
}
})
}
}
func TestNPCRegistryFingerprintIsSemanticAndDefensive(t *testing.T) {
value := validNPCRegistryList()
canonical := encodeNPCRegistry(t, value)
pretty, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("MarshalIndent() error = %v", err)
}
first := newExtractor(t, &fakeSpellsLLMClient{}, npcRegistryReference(canonical, "file:///one.json"))
second := newExtractor(t, &fakeSpellsLLMClient{}, npcRegistryReference(pretty, "file:///two.json"))
firstFingerprints := checkpointFingerprintMap(first.CheckpointFingerprints())
secondFingerprints := checkpointFingerprintMap(second.CheckpointFingerprints())
if firstFingerprints["npc_registry"] == "" || firstFingerprints["npc_registry"] != secondFingerprints["npc_registry"] {
t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints)
}
returned := first.CheckpointFingerprints()
returned[0].Name = "caller-mutated"
if first.CheckpointFingerprints()[0].Name == "caller-mutated" {
t.Fatal("CheckpointFingerprints() returned caller-mutable slice state")
}
changed := validNPCRegistryList()
changed.NPCs[0].Description = "A different description."
changedFingerprint := checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}, npcRegistryReference(encodeNPCRegistry(t, changed), "file:///three.json")).CheckpointFingerprints())
if changedFingerprint["npc_registry"] == firstFingerprints["npc_registry"] {
t.Fatalf("semantic NPC fingerprint did not change: %#v", changedFingerprint)
}
metadata := first.ManifestMetadata()
encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
for _, forbidden := range []string{"Mira Thorn", "The Greencloak", "another-session", "one.json"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("metadata or fingerprints leaked %q: %s", forbidden, encoded)
}
}
if metadata["npc_registry_digest"] != firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 {
t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata)
}
}
func TestExtractPassesCanonicalNPCRegistryToLLMWithoutProvenance(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
canonical := encodeNPCRegistry(t, validNPCRegistryList())
extractor := newExtractor(t, client, npcRegistryReference(append([]byte("\n"), canonical...), "file:///npc-session.json"))
if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if input.Name != NPCRegistryReferenceSlot || input.MediaType != "application/json" || input.Digest != semanticNPCRegistryDigest(canonical) || input.OriginURI != "" {
t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input)
}
if !bytes.Equal(input.Content, canonical) {
t.Fatalf("NPC prompt input = %s, want canonical JSON %s", input.Content, canonical)
}
}
func validNPCRegistryList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []dnd.NPCRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
}}}
}
func encodeNPCRegistry(t *testing.T, value dnd.NPCList) []byte {
t.Helper()
content, err := npccodec.New().Encode(value)
if err != nil {
t.Fatalf("encode NPC registry: %v", err)
}
return content
}
func marshalNPCRegistry(t *testing.T, value dnd.NPCList) []byte {
t.Helper()
content, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal NPC registry: %v", err)
}
return content
}
func npcRegistryReference(content []byte, origin string) contracts.ReferenceSet {
references := npcRegistryReferenceWithMedia(content, "application/json; charset=utf-8")
item := references.Slots[NPCRegistryReferenceSlot].Items[0]
item.Origin.URI = origin
slot := references.Slots[NPCRegistryReferenceSlot]
slot.Items[0] = item
references.Slots[NPCRegistryReferenceSlot] = slot
return references
}
func npcRegistryReferenceWithMedia(content []byte, mediaType string) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, AcceptedMediaTypes: []string{"application/json"}, MaxBytes: NPCRegistryMaxBytes},
Items: []contracts.ReferenceItem{{
SlotName: NPCRegistryReferenceSlot,
MediaType: mediaType,
Content: append([]byte(nil), content...),
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///npc-registry.json"},
}},
},
}}
}
func checkpointFingerprintMap(values []pipeline.CheckpointFingerprint) map[string]string {
result := make(map[string]string, len(values))
for _, value := range values {
result[value.Name] = value.Value
}
return result
}

View File

@@ -39,6 +39,12 @@ func TestModuleSpec(t *testing.T) {
Description: "Optional campaign glossary reference material used only for disambiguation.",
AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"},
},
{
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical caster-name grounding.",
AcceptedMediaTypes: []string{"application/json"},
MaxBytes: NPCRegistryMaxBytes,
},
{
Name: "party",
Description: "Optional party roster reference material used only for disambiguation.",

View File

@@ -15,6 +15,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
{Name: "catalog.md", Path: "assets/prompts/catalog.md"},
{Name: "npc_registry.md", Path: "assets/prompts/npc_registry.md"},
{Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
})
@@ -32,6 +33,7 @@ func scriptoriumPromptMetadata() (string, error) {
parts := append([]llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
{FS: embeddedAssets, Path: "assets/prompts/catalog.md"},
{FS: embeddedAssets, Path: "assets/prompts/npc_registry.md"},
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)

View File

@@ -21,8 +21,8 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
}
if got := len(prepared.Messages); got != 6 {
t.Fatalf("message count = %d, want 6", got)
if got := len(prepared.Messages); got != 7 {
t.Fatalf("message count = %d, want 7", got)
}
if !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
t.Fatalf("transcript message did not include source input")
@@ -42,7 +42,10 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
if !strings.Contains(prepared.Messages[3].Content, `{"spell_names":["Cure Wounds"]}`) {
t.Fatalf("catalog message missing canonical spell-name input: %s", prepared.Messages[3].Content)
}
if strings.Contains(prepared.Messages[4].Content, string(transcript)) {
if !strings.Contains(prepared.Messages[4].Content, `{"npcs":[]}`) {
t.Fatalf("NPC registry message missing empty registry input: %s", prepared.Messages[4].Content)
}
if strings.Contains(prepared.Messages[5].Content, string(transcript)) {
t.Fatalf("task message leaked transcript bytes")
}
}
@@ -129,6 +132,7 @@ func prepareSpellsPrompt(t *testing.T, transcript []byte, players string, party
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)),
"spell_catalog": scriptorium.Inline(`{"spell_names":["Cure Wounds"]}`),
"npcs": scriptorium.Inline(`{"npcs":[]}`),
"players": scriptorium.Inline(players),
"party": scriptorium.Inline(party),
"glossary": scriptorium.Inline(glossary),

View File

@@ -0,0 +1,130 @@
package integration_test
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
)
func TestSequentialNPCOutputCanGroundIndependentSpellRun(t *testing.T) {
registries := productionNPCRegistries(t)
catalog := moduleCatalog(registries)
configValue := loadSequentialPipelineConfig(t)
npcEffective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npcs", Catalog: catalog})
if err != nil {
t.Fatalf("resolve NPC pipeline: %v", err)
}
npcClient := &fakeNPCProductionLLMClient{response: npcProductionResponse{NPCs: []npcProductionRecord{{
Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []npcProductionRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 2}},
}}}}
npcOutput, err := runPreparedPipeline(t, registries, npcEffective.ResolvedPipeline, npcClient, pipeline.RunInput{RawInput: readNPCFixture(t)})
if err != nil {
t.Fatalf("run NPC pipeline: %v", err)
}
if len(npcOutput.NormalizeOutputs) != 1 || npcOutput.NormalizeOutputs[0].LaneID != "npcs" {
t.Fatalf("NPC normalized outputs = %#v, want one npcs lane", npcOutput.NormalizeOutputs)
}
npcPayload := npcOutput.NormalizeOutputs[0].Artifact.Content
if _, err := npccodec.New().Decode(npcPayload); err != nil {
t.Fatalf("decode normalized NPC payload: %v", err)
}
npcRunDir := t.TempDir()
npcPath := filepath.Join(npcRunDir, "lanes", "npcs.json")
if err := os.MkdirAll(filepath.Dir(npcPath), 0o700); err != nil {
t.Fatalf("create NPC output directory: %v", err)
}
if err := os.WriteFile(npcPath, npcPayload, 0o600); err != nil {
t.Fatalf("write NPC output payload: %v", err)
}
spellEffective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-spells", Catalog: catalog})
if err != nil {
t.Fatalf("resolve spell pipeline: %v", err)
}
spellEffective.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.Bindings = []pipeline.ReferenceBinding{{
Stage: pipeline.StageExtract,
LaneID: "spells",
SlotName: spells.NPCRegistryReferenceSlot,
Source: npcPath,
BindingSource: contracts.ReferenceBindingSourceCLI,
}}
materialized, warnings, err := pipeline.MaterializeReferences(spellEffective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{WorkingDir: npcRunDir})
if err != nil {
t.Fatalf("materialize NPC registry reference: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("reference materialization warnings = %#v, want none", warnings)
}
spellClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
Caster: "Mira Thorn",
Spell: "Cure Wounds",
Effect: "Restores the injured ally.",
NarrativeDescription: "Mira Thorn restores the ally after the fight.",
SourceRefs: responseSourceRefs("spell-session", 1, 1),
}}}}
spellOutput, err := runPreparedPipeline(t, registries, materialized, spellClient, pipeline.RunInput{RawInput: readDNDSpellsFixture(t)})
if err != nil {
t.Fatalf("run spell pipeline: %v", err)
}
if len(spellOutput.NormalizeOutputs) != 1 || spellOutput.NormalizeOutputs[0].LaneID != "spells" {
t.Fatalf("spell normalized outputs = %#v, want one spells lane", spellOutput.NormalizeOutputs)
}
spellValue := decodeRunnerSpellResponse(t, spellOutput.NormalizeOutputs[0].Artifact.Content)
if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "Mira Thorn" {
t.Fatalf("spell output = %#v, want one registry-grounded caster", spellValue)
}
if len(spellValue.SpellCasts[0].SourceRefs) != 1 || spellValue.SpellCasts[0].SourceRefs[0].SourceID != "spell-session" {
t.Fatalf("spell source refs = %#v, want current spell session only", spellValue.SpellCasts[0].SourceRefs)
}
if len(spellClient.requests) != 1 {
t.Fatalf("spell LLM requests = %d, want one", len(spellClient.requests))
}
registryInput := spellClient.requests[0].Inputs[spells.NPCRegistryReferenceSlot]
if string(registryInput.Content) != string(npcPayload) || registryInput.MediaType != npccodec.MediaType || registryInput.OriginURI != "" {
t.Fatalf("spell NPC prompt input = %#v, want canonical payload without origin", registryInput)
}
if len(spellOutput.Manifest.References) != 1 {
t.Fatalf("spell manifest references = %#v, want one NPC provenance entry", spellOutput.Manifest.References)
}
provenance := spellOutput.Manifest.References[0]
if provenance.Stage != "extract" || provenance.LaneID != "spells" || provenance.SlotName != spells.NPCRegistryReferenceSlot || provenance.BindingSource != contracts.ReferenceBindingSourceCLI || !strings.Contains(provenance.OriginURI, "npcs.json") {
t.Fatalf("spell NPC provenance = %#v, want extract CLI reference provenance", provenance)
}
metadata, ok := spellOutput.Manifest.ArtifactLanes[0].Metadata["extractor"].(map[string]any)
if !ok || metadata["npc_count"] != 1 || metadata["npc_registry_digest"] != registryInput.Digest {
t.Fatalf("spell extractor metadata = %#v, want NPC count and semantic digest", spellOutput.Manifest.ArtifactLanes[0].Metadata)
}
}
func loadSequentialPipelineConfig(t *testing.T) config.Config {
t.Helper()
data, err := os.ReadFile("testdata/dnd_npc_spell_sequential_pipeline.yml")
if err != nil {
t.Fatalf("read sequential pipeline config: %v", err)
}
fileConfig, err := config.ParseFileConfigYAML(data)
if err != nil {
t.Fatalf("parse sequential pipeline config: %v", err)
}
configValue := config.Default()
if err := configValue.ApplyFileConfig(fileConfig); err != nil {
t.Fatalf("apply sequential pipeline config: %v", err)
}
return configValue
}

View File

@@ -0,0 +1,25 @@
version: 3
output:
directory: ./notarius-output
cache:
chunk_plans:
mode: bypass
checkpoints: {}
debug:
directory: ./notarius-debug
pipelines:
dnd-npcs:
input: seriatim
chunk: generic
artifacts:
npcs:
extract:
module: dnd/npcs
normalize: dnd/npcs
dnd-spells:
input: seriatim
chunk: generic
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells