From 20cfbfd31136485e0db835260d03ec3a557b02c6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 21 Jul 2026 03:12:03 +0000 Subject: [PATCH] Add NPC registry grounding for spell extraction --- docs/cli.md | 10 + docs/config.md | 49 +++- docs/integrations/dnd-npc-artifacts.md | 55 +++- docs/integrations/dnd-spell-artifacts.md | 29 ++- docs/integrations/json-output.md | 3 +- docs/internal/llm.md | 20 +- docs/internal/modules.md | 54 +++- docs/internal/overview.md | 20 +- docs/operations.md | 30 +++ docs/roadmap/dnd-npc-extraction.md | 2 +- docs/roadmap/future.md | 4 - docs/roadmap/implementation.md | 2 +- examples/dnd-npc-spell-sequential.config.yml | 28 +++ internal/cli/example_contract_test.go | 36 +-- internal/cli/npc_registry_contract_test.go | 64 +++++ internal/cli/production_contract_test.go | 12 +- .../spells/assets/prompts/dnd.spells.yaml | 5 + .../spells/assets/prompts/npc_registry.md | 11 + .../modules/dnd/extract/spells/extractor.go | 27 +- .../dnd/extract/spells/npc_registry.go | 89 +++++++ .../dnd/extract/spells/npc_registry_test.go | 236 ++++++++++++++++++ .../dnd/extract/spells/registry_test.go | 6 + .../dnd/extract/spells/scriptorium_assets.go | 2 + .../extract/spells/scriptorium_assets_test.go | 10 +- .../dnd_npc_spell_sequential_test.go | 130 ++++++++++ .../dnd_npc_spell_sequential_pipeline.yml | 25 ++ 26 files changed, 901 insertions(+), 58 deletions(-) create mode 100644 examples/dnd-npc-spell-sequential.config.yml create mode 100644 internal/cli/npc_registry_contract_test.go create mode 100644 internal/modules/dnd/extract/spells/assets/prompts/npc_registry.md create mode 100644 internal/modules/dnd/extract/spells/npc_registry.go create mode 100644 internal/modules/dnd/extract/spells/npc_registry_test.go create mode 100644 internal/modules/integration/dnd_npc_spell_sequential_test.go create mode 100644 internal/modules/integration/testdata/dnd_npc_spell_sequential_pipeline.yml diff --git a/docs/cli.md b/docs/cli.md index 11b714c..645df0f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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: diff --git a/docs/config.md b/docs/config.md index 27378ff..51a0e4c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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..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 diff --git a/docs/integrations/dnd-npc-artifacts.md b/docs/integrations/dnd-npc-artifacts.md index 89442c2..57b2fcb 100644 --- a/docs/integrations/dnd-npc-artifacts.md +++ b/docs/integrations/dnd-npc-artifacts.md @@ -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//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. diff --git a/docs/integrations/dnd-spell-artifacts.md b/docs/integrations/dnd-spell-artifacts.md index b27b9f6..586295e 100644 --- a/docs/integrations/dnd-spell-artifacts.md +++ b/docs/integrations/dnd-spell-artifacts.md @@ -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. diff --git a/docs/integrations/json-output.md b/docs/integrations/json-output.md index a04c7e4..721669c 100644 --- a/docs/integrations/json-output.md +++ b/docs/integrations/json-output.md @@ -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` diff --git a/docs/internal/llm.md b/docs/internal/llm.md index 8d82bed..7293168 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -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 diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 700d8f6..dba15f0 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -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 diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 46d1f21..50968be 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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` diff --git a/docs/operations.md b/docs/operations.md index b7604ec..2a9caef 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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//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: diff --git a/docs/roadmap/dnd-npc-extraction.md b/docs/roadmap/dnd-npc-extraction.md index 4837e54..189825a 100644 --- a/docs/roadmap/dnd-npc-extraction.md +++ b/docs/roadmap/dnd-npc-extraction.md @@ -1,6 +1,6 @@ # D&D NPC Extraction And Registry -Status: Accepted. +Status: Complete. ## Purpose diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md index bf9ce9c..64a8be4 100644 --- a/docs/roadmap/future.md +++ b/docs/roadmap/future.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index da9a79a..4ce09e0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/examples/dnd-npc-spell-sequential.config.yml b/examples/dnd-npc-spell-sequential.config.yml new file mode 100644 index 0000000..876a9ed --- /dev/null +++ b/examples/dnd-npc-spell-sequential.config.yml @@ -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 diff --git a/internal/cli/example_contract_test.go b/internal/cli/example_contract_test.go index e8e1e5b..bcf4be0 100644 --- a/internal/cli/example_contract_test.go +++ b/internal/cli/example_contract_test.go @@ -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()) } }) diff --git a/internal/cli/npc_registry_contract_test.go b/internal/cli/npc_registry_contract_test.go new file mode 100644 index 0000000..9692939 --- /dev/null +++ b/internal/cli/npc_registry_contract_test.go @@ -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) + } +} diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 523c908..5652286 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -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"}}, } } diff --git a/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml b/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml index 962fbf6..ccc097f 100644 --- a/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml +++ b/internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml @@ -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 diff --git a/internal/modules/dnd/extract/spells/assets/prompts/npc_registry.md b/internal/modules/dnd/extract/spells/assets/prompts/npc_registry.md new file mode 100644 index 0000000..bf074a1 --- /dev/null +++ b/internal/modules/dnd/extract/spells/assets/prompts/npc_registry.md @@ -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" }} diff --git a/internal/modules/dnd/extract/spells/extractor.go b/internal/modules/dnd/extract/spells/extractor.go index 53a5d8f..014d1fc 100644 --- a/internal/modules/dnd/extract/spells/extractor.go +++ b/internal/modules/dnd/extract/spells/extractor.go @@ -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, diff --git a/internal/modules/dnd/extract/spells/npc_registry.go b/internal/modules/dnd/extract/spells/npc_registry.go new file mode 100644 index 0000000..00215ed --- /dev/null +++ b/internal/modules/dnd/extract/spells/npc_registry.go @@ -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, ", ") +} diff --git a/internal/modules/dnd/extract/spells/npc_registry_test.go b/internal/modules/dnd/extract/spells/npc_registry_test.go new file mode 100644 index 0000000..0bfc731 --- /dev/null +++ b/internal/modules/dnd/extract/spells/npc_registry_test.go @@ -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 +} diff --git a/internal/modules/dnd/extract/spells/registry_test.go b/internal/modules/dnd/extract/spells/registry_test.go index 134b859..37f12c7 100644 --- a/internal/modules/dnd/extract/spells/registry_test.go +++ b/internal/modules/dnd/extract/spells/registry_test.go @@ -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.", diff --git a/internal/modules/dnd/extract/spells/scriptorium_assets.go b/internal/modules/dnd/extract/spells/scriptorium_assets.go index b12095b..3930bd5 100644 --- a/internal/modules/dnd/extract/spells/scriptorium_assets.go +++ b/internal/modules/dnd/extract/spells/scriptorium_assets.go @@ -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()...)...) diff --git a/internal/modules/dnd/extract/spells/scriptorium_assets_test.go b/internal/modules/dnd/extract/spells/scriptorium_assets_test.go index 3859ac0..e48017f 100644 --- a/internal/modules/dnd/extract/spells/scriptorium_assets_test.go +++ b/internal/modules/dnd/extract/spells/scriptorium_assets_test.go @@ -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), diff --git a/internal/modules/integration/dnd_npc_spell_sequential_test.go b/internal/modules/integration/dnd_npc_spell_sequential_test.go new file mode 100644 index 0000000..877bf17 --- /dev/null +++ b/internal/modules/integration/dnd_npc_spell_sequential_test.go @@ -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 +} diff --git a/internal/modules/integration/testdata/dnd_npc_spell_sequential_pipeline.yml b/internal/modules/integration/testdata/dnd_npc_spell_sequential_pipeline.yml new file mode 100644 index 0000000..2851a39 --- /dev/null +++ b/internal/modules/integration/testdata/dnd_npc_spell_sequential_pipeline.yml @@ -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