Add D&D NPC interaction artifact codec
This commit is contained in:
197
docs/roadmap/dnd-npc-interactions.md
Normal file
197
docs/roadmap/dnd-npc-interactions.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# D&D NPC Interactions
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The normalized NPC registry intentionally answers only who was identified in a
|
||||||
|
session. It does not answer whether an NPC was merely mentioned, participated
|
||||||
|
in dialogue, or fought alongside or against the party. Add a separate,
|
||||||
|
ordered NPC-interaction artifact for that occurrence-level information rather
|
||||||
|
than expanding the identity registry.
|
||||||
|
|
||||||
|
This feature preserves the minimal-extractor policy: the model identifies one
|
||||||
|
bounded kind of interaction and its supporting transcript evidence. It does
|
||||||
|
not summarize the interaction, infer relationships, or maintain NPC state.
|
||||||
|
|
||||||
|
## Desired End State
|
||||||
|
|
||||||
|
A D&D pipeline can run NPC extraction first and supply its accepted normalized
|
||||||
|
registry to a later `dnd/npc-interactions` lane. The later lane emits an ordered
|
||||||
|
list of evidenced interaction occurrences involving registry NPCs.
|
||||||
|
|
||||||
|
The production identities should be:
|
||||||
|
|
||||||
|
- extractor and normalizer key: `dnd/npc-interactions`;
|
||||||
|
- artifact kind: `dnd/npc-interaction-list`;
|
||||||
|
- durable schema ID: `notarius.dnd.npc_interactions`;
|
||||||
|
- durable schema name: `notarius_dnd_npc_interactions_v1`;
|
||||||
|
- durable schema version: `v1`; and
|
||||||
|
- media type: `application/json`.
|
||||||
|
|
||||||
|
The lane must use the existing D&D module organization, shared prompt assets,
|
||||||
|
typed artifact pipeline, codec boundary, registration pattern, and default
|
||||||
|
validator composition.
|
||||||
|
|
||||||
|
## Artifact Contract
|
||||||
|
|
||||||
|
The durable payload is an object containing an `interactions` array. The array
|
||||||
|
may be empty. Each interaction contains exactly:
|
||||||
|
|
||||||
|
- `name`: the canonical NPC name from the supplied registry;
|
||||||
|
- `kind`: one value from the bounded interaction vocabulary; and
|
||||||
|
- `source_refs`: one or more current-transcript ranges supporting both the NPC
|
||||||
|
identity and the classified interaction.
|
||||||
|
|
||||||
|
Every object rejects unknown fields. The model-facing response should contain
|
||||||
|
only the corresponding name, kind, and source-unit range candidates. Notarius
|
||||||
|
attaches the current source identity deterministically; the model must not
|
||||||
|
reproduce it.
|
||||||
|
|
||||||
|
Do not add a separate interaction ID in the durable contract. Stable ordering,
|
||||||
|
the canonical NPC name, the bounded kind, and exact evidence ranges are enough
|
||||||
|
to identify and audit an occurrence for the present use cases. Revisit durable
|
||||||
|
cross-artifact identity only with a concrete consumer requirement.
|
||||||
|
|
||||||
|
## Interaction Vocabulary
|
||||||
|
|
||||||
|
Use this closed vocabulary:
|
||||||
|
|
||||||
|
| Kind | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. |
|
||||||
|
| `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. |
|
||||||
|
| `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. |
|
||||||
|
| `combat_ally` | The NPC actively participates in combat on the party's side. |
|
||||||
|
| `combat_opponent` | The NPC actively participates in combat against the party. |
|
||||||
|
| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. |
|
||||||
|
|
||||||
|
`other` is a residual category for positively evidenced activity, not an
|
||||||
|
escape hatch for uncertain classification. Omit a candidate when the
|
||||||
|
transcript does not support one category.
|
||||||
|
|
||||||
|
When activities overlap within one occurrence, apply this precedence:
|
||||||
|
|
||||||
|
1. active combat participation outranks dialogue, presence, and mention;
|
||||||
|
2. dialogue outranks non-combat presence and mention;
|
||||||
|
3. non-combat presence outranks mention; and
|
||||||
|
4. `other` applies only when none of the defined categories describes the
|
||||||
|
evidenced activity.
|
||||||
|
|
||||||
|
Combat alignment is not resolved by precedence. An NPC cannot be both a combat
|
||||||
|
ally and combat opponent in one occurrence; split the record when its alignment
|
||||||
|
meaningfully changes.
|
||||||
|
|
||||||
|
## Occurrence Boundaries And Ordering
|
||||||
|
|
||||||
|
An occurrence represents one NPC, one interaction kind, and one locally
|
||||||
|
coherent transcript passage. Combine repeated evidence only while it supports
|
||||||
|
the same uninterrupted activity. Create separate occurrences when:
|
||||||
|
|
||||||
|
- the interaction kind changes;
|
||||||
|
- combat alignment changes;
|
||||||
|
- a scene or meaningful absence separates repeated activity; or
|
||||||
|
- the NPC is first mentioned and later becomes present.
|
||||||
|
|
||||||
|
Extraction remains chunk-scoped, so an occurrence must not span accepted chunk
|
||||||
|
boundaries. Merge and normalization must not semantically combine occurrences
|
||||||
|
from different chunks.
|
||||||
|
|
||||||
|
Order the durable list by the earliest valid source position. Use canonical
|
||||||
|
name, interaction kind, and canonical source ranges as deterministic
|
||||||
|
tie-breakers. Remove only exact duplicate records; do not use model judgment to
|
||||||
|
collapse nearby occurrences.
|
||||||
|
|
||||||
|
## NPC Grounding And Evidence
|
||||||
|
|
||||||
|
The extractor declares the existing `npcs` reference slot and requires a bound,
|
||||||
|
accepted NPC registry. The normal same-run configuration binds the normalized
|
||||||
|
output of an earlier NPC lane through an ordered generated reference. Existing
|
||||||
|
framework support for a compatible external registry may remain available, but
|
||||||
|
must not weaken the artifact contract.
|
||||||
|
|
||||||
|
Present the registry's names-only projection to the model. An emitted name must
|
||||||
|
match one canonical registry name exactly after the registry's established
|
||||||
|
lookup rules are applied, and the durable artifact retains that canonical
|
||||||
|
display name. Do not copy NPC IDs or registry source references into an
|
||||||
|
interaction.
|
||||||
|
|
||||||
|
The registry establishes available identity, not occurrence. Every interaction
|
||||||
|
must cite current transcript ranges that independently support both the NPC and
|
||||||
|
the selected kind. Campaign references and generated artifacts may disambiguate
|
||||||
|
a name, but they never become interaction evidence.
|
||||||
|
|
||||||
|
If no registry NPC has an evidenced interaction, the correct artifact is an
|
||||||
|
empty list. A missing, rejected, or incompatible required registry handoff is a
|
||||||
|
pipeline dependency failure rather than a request to extract ungrounded names.
|
||||||
|
|
||||||
|
## Prompt And Model Boundary
|
||||||
|
|
||||||
|
Follow the established D&D prompt ordering and cache-boundary policy. Stable
|
||||||
|
shared instructions, lane instructions, campaign references, and the NPC
|
||||||
|
names-only projection precede the variable transcript. Factor wording shared
|
||||||
|
with the spell and combat lanes into the existing shared asset pattern rather
|
||||||
|
than creating nearly identical package-local messages.
|
||||||
|
|
||||||
|
The prompt must:
|
||||||
|
|
||||||
|
- define the closed vocabulary and precedence rules;
|
||||||
|
- ask for occurrences involving only supplied registry NPCs;
|
||||||
|
- distinguish mention from presence and dialogue;
|
||||||
|
- require current-transcript evidence for identity and kind;
|
||||||
|
- forbid summaries, relationship inference, sentiment, aliases, and invented
|
||||||
|
names; and
|
||||||
|
- permit an empty result.
|
||||||
|
|
||||||
|
Keep the private JSON Schema structural. Deterministic code owns canonical-name
|
||||||
|
resolution, enum enforcement at the durable boundary, source-range validation,
|
||||||
|
ordering, exact deduplication, and evidence invariants.
|
||||||
|
|
||||||
|
## Validation And Quality
|
||||||
|
|
||||||
|
Provide production validators and default chains at extraction and
|
||||||
|
normalization boundaries consistent with the existing D&D artifacts. The
|
||||||
|
append-only merge does not require a separate default validator chain.
|
||||||
|
Deterministic validation must reject:
|
||||||
|
|
||||||
|
- missing or extra fields;
|
||||||
|
- empty or unrecognized NPC names;
|
||||||
|
- names absent from the supplied registry;
|
||||||
|
- unknown interaction kinds;
|
||||||
|
- empty, malformed, out-of-source, or reversed evidence ranges; and
|
||||||
|
- records whose evidence comes from a reference rather than the current
|
||||||
|
transcript.
|
||||||
|
|
||||||
|
Any relatedness validator should remain warning-only unless evaluation
|
||||||
|
demonstrates a reliable deterministic rejection rule. Diagnostics must be
|
||||||
|
bounded and must not leak reference contents.
|
||||||
|
|
||||||
|
Evaluate the lane on a small human-reviewed transcript set that includes every
|
||||||
|
category, transitions between categories, multiple occurrences for one NPC,
|
||||||
|
mentions followed by appearances, alignment changes, repeated evidence across
|
||||||
|
chunks, and empty output. Review category agreement, evidence sufficiency,
|
||||||
|
ordering, duplicate behavior, and reliability on the smaller models the
|
||||||
|
application is intended to support. Treat model-output evaluation as a human
|
||||||
|
development aid, not a brittle deterministic test oracle.
|
||||||
|
|
||||||
|
## Documentation Outcomes
|
||||||
|
|
||||||
|
When implemented, document the durable artifact in `docs/integrations/`, add
|
||||||
|
the selectable module and validator contracts to configuration documentation,
|
||||||
|
update the current module and pipeline internals, and provide a maintained
|
||||||
|
ordered-pipeline example showing NPC extraction followed by interaction
|
||||||
|
extraction. Future behavior must remain in this roadmap until it exists.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This scope does not:
|
||||||
|
|
||||||
|
- add occurrence fields to the normalized NPC registry;
|
||||||
|
- summarize dialogue, combat, or NPC behavior;
|
||||||
|
- infer disposition, relationships, factions, motives, or persistent state;
|
||||||
|
- identify player characters or anonymous groups as NPCs;
|
||||||
|
- add scene participants or duplicate scene-description responsibilities;
|
||||||
|
- reconcile NPC aliases or perform LLM-assisted semantic deduplication;
|
||||||
|
- derive interaction records from registry evidence; or
|
||||||
|
- introduce a DAG, concurrent cross-lane reconciliation, or a new reference
|
||||||
|
mechanism.
|
||||||
@@ -18,28 +18,10 @@ not as committed release dates.
|
|||||||
|
|
||||||
### Extract NPC Interactions
|
### Extract NPC Interactions
|
||||||
|
|
||||||
- Add an ordered NPC-interaction artifact that records how an identifiable NPC
|
Add a minimal, ordered `dnd/npc-interactions` artifact after NPC identity
|
||||||
participates in the session without adding occurrence-level state to the
|
extraction. The proposed contract, occurrence semantics, category policy,
|
||||||
normalized NPC registry. Use `npc-interactions` as the working lane and
|
grounding rules, evidence requirements, and non-goals are defined in
|
||||||
product name; the exact artifact kind may be finalized with its contract.
|
[D&D NPC Interactions](dnd-npc-interactions.md).
|
||||||
- Keep each record minimal: canonical NPC `name`, one bounded interaction
|
|
||||||
`kind`, and transcript `source_refs` supporting both the identity and
|
|
||||||
classification.
|
|
||||||
- Start with the mutually exclusive vocabulary `mentioned`,
|
|
||||||
`noncombat_presence`, `dialogue`, `combat_ally`, `combat_opponent`, and
|
|
||||||
`other`. Define narrow inclusion rules and category precedence before
|
|
||||||
implementation so overlapping activity does not produce arbitrary labels.
|
|
||||||
- Model interactions as ordered occurrences rather than one scalar NPC
|
|
||||||
category. The same NPC may therefore have separate records when the
|
|
||||||
transcript establishes distinct interactions, such as dialogue followed by
|
|
||||||
hostile combat.
|
|
||||||
- Run NPC identity extraction first and provide its accepted names-only
|
|
||||||
projection to the interaction extractor for grounding. Registry names may
|
|
||||||
disambiguate identity but never establish that an interaction occurred, and
|
|
||||||
registry source references must not be copied into interaction evidence.
|
|
||||||
- Evaluate category agreement, evidence sufficiency, duplicate behavior, and
|
|
||||||
smaller-model reliability on human-reviewed transcripts before expanding the
|
|
||||||
enum or adding additional fields.
|
|
||||||
|
|
||||||
### Export Accepted Chunk Maps
|
### Export Accepted Chunk Maps
|
||||||
|
|
||||||
|
|||||||
570
docs/roadmap/implementation.md
Normal file
570
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
# D&D NPC Interactions Implementation Plan
|
||||||
|
|
||||||
|
Status: Ready for implementation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement the accepted [D&D NPC Interactions](dnd-npc-interactions.md)
|
||||||
|
roadmap as a production D&D artifact lane. The finished lane must extract an
|
||||||
|
ordered list of minimal, evidence-grounded NPC interaction occurrences from
|
||||||
|
each accepted transcript chunk, ground every NPC against a required accepted
|
||||||
|
NPC registry, and preserve the existing fixed pipeline and generated-reference
|
||||||
|
architecture.
|
||||||
|
|
||||||
|
This plan is the implementation authority for sequencing and file-level work.
|
||||||
|
The feature roadmap remains authoritative for product intent, category
|
||||||
|
semantics, occurrence boundaries, evidence policy, and non-goals. Follow
|
||||||
|
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md)
|
||||||
|
throughout: do not add descriptive, analytical, relationship, or state fields.
|
||||||
|
|
||||||
|
## Fixed Decisions
|
||||||
|
|
||||||
|
The implementation must use these identities:
|
||||||
|
|
||||||
|
| Concern | Identity |
|
||||||
|
| --- | --- |
|
||||||
|
| Extractor key | `dnd/npc-interactions` |
|
||||||
|
| Normalizer key | `dnd/npc-interactions` |
|
||||||
|
| Artifact kind | `dnd/npc-interaction-list` |
|
||||||
|
| Durable schema ID | `notarius.dnd.npc_interactions` |
|
||||||
|
| Durable schema name | `notarius_dnd_npc_interactions_v1` |
|
||||||
|
| Durable schema version | `v1` |
|
||||||
|
| Durable media type | `application/json` |
|
||||||
|
| Private prompt ID | `dnd.npc_interactions` |
|
||||||
|
| Private response schema ID | `notarius.dnd.npc_interactions.llm` |
|
||||||
|
| Reference slot | `npcs` |
|
||||||
|
|
||||||
|
Use a top-level `interactions` array. Each durable record has exactly `name`,
|
||||||
|
`kind`, and `source_refs`. Do not add an interaction ID. The closed kind
|
||||||
|
vocabulary is `mentioned`, `noncombat_presence`, `dialogue`, `combat_ally`,
|
||||||
|
`combat_opponent`, and `other`.
|
||||||
|
|
||||||
|
The `npcs` slot is required for both extraction and normalization. It accepts
|
||||||
|
only the existing `dnd/npc-list` JSON artifact within the existing NPC registry
|
||||||
|
size limit. A bound registry with an empty `npcs` array is valid and requires an
|
||||||
|
empty interaction artifact. An absent binding, malformed registry, failed
|
||||||
|
generated handoff, or unknown interaction name is not permission to perform
|
||||||
|
ungrounded extraction.
|
||||||
|
|
||||||
|
Use the existing `internal/modules/dnd/npcs/registry.Resolver` for construction-
|
||||||
|
time external references and operation-time generated references. The
|
||||||
|
extractor presents only `Registry.PromptInput()` to the model. Name lookup uses
|
||||||
|
the registry's established comparison-key policy before normalization; the
|
||||||
|
normalizer replaces a recognized variant with the exact canonical registry
|
||||||
|
display name. Registry source references and campaign references never become
|
||||||
|
interaction evidence.
|
||||||
|
|
||||||
|
An occurrence is one NPC, one kind, and one coherent passage within one
|
||||||
|
accepted chunk. The model must split a category or combat-alignment transition.
|
||||||
|
No occurrence spans chunks. Merge preserves chunk order. Normalization:
|
||||||
|
|
||||||
|
1. replaces recognized names with canonical registry display names;
|
||||||
|
2. sorts and exact-deduplicates each record's source references;
|
||||||
|
3. orders records by earliest valid source position, then canonical name,
|
||||||
|
interaction kind, and the canonical source-reference sequence;
|
||||||
|
4. removes records only when canonical name, kind, and the complete canonical
|
||||||
|
source-reference sequence are all identical; and
|
||||||
|
5. never semantically combines nearby, overlapping, or cross-chunk records.
|
||||||
|
|
||||||
|
The private model response remains structural and contains name, kind, and
|
||||||
|
integer start/end unit candidates. Mapping attaches the current source ID.
|
||||||
|
Invalid strings, kinds, and ranges must survive mapping far enough for the
|
||||||
|
deterministic validator that owns the rule to reject them.
|
||||||
|
|
||||||
|
For the final record comparator, use source-document unit position for the
|
||||||
|
earliest valid reference. Compare canonical names first by the existing NPC
|
||||||
|
identity comparison key and then by exact display string; compare kinds by
|
||||||
|
their string values; and compare canonical reference sequences
|
||||||
|
lexicographically by `source_id`, start-unit document position, and end-unit
|
||||||
|
document position. Records with valid evidence precede records without it;
|
||||||
|
normalized output validation rejects the latter before durable encoding.
|
||||||
|
|
||||||
|
No framework change, new identity subsystem, DAG behavior, LLM-assisted
|
||||||
|
deduplication, or scene dependency is part of this plan.
|
||||||
|
|
||||||
|
## Testing And Documentation Rules
|
||||||
|
|
||||||
|
Apply [Testing Policy](../policy/testing.md) at every stage:
|
||||||
|
|
||||||
|
- Protect artifact round trips, required fields, enum values, source
|
||||||
|
provenance, registry grounding, deterministic ordering, exact
|
||||||
|
deduplication, reference immutability, and generated handoff behavior.
|
||||||
|
- Use the real codec, registry, resolver, normalizer, and pipeline machinery
|
||||||
|
when they are fast and deterministic. Stub only the structured LLM boundary.
|
||||||
|
- Keep provider calls offline and deterministic.
|
||||||
|
- Test each policy at its narrowest stable owner. Do not repeat every codec,
|
||||||
|
validator, and normalizer case in an end-to-end test.
|
||||||
|
- Do not add a prompt-cache “change detector” based on exact message count,
|
||||||
|
shared-prefix length, token count, or prompt hash. Reuse the shared prompt
|
||||||
|
assets, test rendered behavior and manifest composition, and document the
|
||||||
|
ordering policy instead.
|
||||||
|
- Do not use broad golden snapshots for prompts or diagnostics. A maintained
|
||||||
|
durable JSON fixture is appropriate because the complete serialized artifact
|
||||||
|
is an intentional integration contract.
|
||||||
|
|
||||||
|
Until the final stage, keep unimplemented behavior only in roadmap
|
||||||
|
documentation. Update current-behavior documentation and examples in the same
|
||||||
|
stage that makes the production lane selectable.
|
||||||
|
|
||||||
|
## Stage 1 — Add The Typed Artifact And Durable Codec
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Establish the domain type and strict serialized boundary without changing the
|
||||||
|
production module catalog.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
1. Extend `internal/modules/dnd/types.go` with:
|
||||||
|
- `NPCInteractionListKind`;
|
||||||
|
- a string-backed `NPCInteractionKind`;
|
||||||
|
- exported constants for all six accepted values;
|
||||||
|
- `NPCInteractionList` with `Interactions []NPCInteraction`; and
|
||||||
|
- `NPCInteraction` with `Name string`, `Kind NPCInteractionKind`, and
|
||||||
|
`SourceRefs []source.SourceRef`.
|
||||||
|
2. Add `internal/modules/dnd/codec/npcinteractions` following the existing NPC
|
||||||
|
and combat-turn codec boundary:
|
||||||
|
- expose the fixed schema constants and `MediaType`;
|
||||||
|
- implement the typed artifact codec for `dnd.NPCInteractionList`;
|
||||||
|
- keep `EncodeCandidate` and `DecodeCandidate` strict about one JSON value
|
||||||
|
and unknown fields while preserving typed semantic candidates;
|
||||||
|
- make approved `Encode` and `Decode` enforce non-empty names, the closed
|
||||||
|
kind enum, at least one structurally valid source reference, and the
|
||||||
|
durable schema shape; and
|
||||||
|
- report only `interaction_count` in codec metadata.
|
||||||
|
3. Add
|
||||||
|
`internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json`.
|
||||||
|
It must require the top-level array and all three record fields, reject
|
||||||
|
unknown fields at every object level, encode the six-value enum, require at
|
||||||
|
least one source reference, and use the established durable source-reference
|
||||||
|
shape.
|
||||||
|
4. Add a small maintained fixture under
|
||||||
|
`internal/modules/dnd/codec/npcinteractions/testdata/` containing at least
|
||||||
|
two ordered records with distinct kinds and source ranges.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
At the codec package boundary, cover:
|
||||||
|
|
||||||
|
- fixture decode/encode round trip and canonical compact JSON;
|
||||||
|
- exact kind, schema identity, version, media type, and Go type registration;
|
||||||
|
- empty-list support and nil-versus-present-empty behavior where the existing
|
||||||
|
codecs distinguish it;
|
||||||
|
- strict rejection of malformed JSON, trailing values, and unknown fields;
|
||||||
|
- approved-boundary rejection of each meaningful required-field, enum, and
|
||||||
|
source-reference violation;
|
||||||
|
- candidate-boundary preservation of semantic values for later validators; and
|
||||||
|
- defensive copies for schema bytes and metadata.
|
||||||
|
|
||||||
|
Do not duplicate source-document existence checks in the codec; those belong to
|
||||||
|
the source-reference validator.
|
||||||
|
|
||||||
|
### Completion Criteria
|
||||||
|
|
||||||
|
- The new types and codec compile and pass focused tests.
|
||||||
|
- The codec can be registered into an isolated artifact codec registry with
|
||||||
|
exact type `dnd.NPCInteractionList`.
|
||||||
|
- No production registrar or configuration catalog exposes the new kind yet.
|
||||||
|
|
||||||
|
## Stage 2 — Implement The Chunk-Scoped Extractor And Prompt
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Add an independently testable LLM-backed extractor that requires NPC grounding,
|
||||||
|
maps only minimal private output, and follows the established D&D prompt-cache
|
||||||
|
layout.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
1. Add `internal/modules/dnd/extract/npcinteractions` using the current D&D
|
||||||
|
extractor organization:
|
||||||
|
- `assets.go`;
|
||||||
|
- `canonicalize.go`;
|
||||||
|
- `extractor.go`;
|
||||||
|
- `model.go`;
|
||||||
|
- `schema.go`;
|
||||||
|
- `scriptorium_assets.go`;
|
||||||
|
- corresponding focused tests; and
|
||||||
|
- package-local embedded prompt and schema assets.
|
||||||
|
2. Give the extractor strict empty options, required capabilities `chunks` and
|
||||||
|
`source.transcript`, provided capability `dnd.npc_interactions`, and artifact
|
||||||
|
kind `dnd.NPCInteractionListKind`.
|
||||||
|
3. Build its reference slots from `shared.ReferenceSlots(...)`, then append the
|
||||||
|
`npcs` slot with:
|
||||||
|
- `Required: true`;
|
||||||
|
- accepted media type `application/json`;
|
||||||
|
- accepted artifact kind `dnd.NPCListKind`;
|
||||||
|
- the existing registry maximum byte count; and
|
||||||
|
- a description stating that the accepted registry is required identity
|
||||||
|
grounding, not evidence.
|
||||||
|
Return defensive, consistently sorted slot declarations from both
|
||||||
|
`ModuleSpec()` and the constructed extractor.
|
||||||
|
4. Construct an `npcregistry.Resolver` from the preparation-time reference set.
|
||||||
|
Resolve operation references inside `Extract`, require `Registry.Bound()`,
|
||||||
|
and fail before the LLM call if the required registry is absent or invalid.
|
||||||
|
Preserve the resolver's content-safe error behavior.
|
||||||
|
5. Use `shared.ChunkPromptMaterial` and `shared.PromptInputs`. Replace the
|
||||||
|
ordinary `npcs` input with the resolved names-only `Registry.PromptInput()`.
|
||||||
|
Do not place full registry bytes, NPC IDs, registry evidence, or provenance
|
||||||
|
in prompt inputs.
|
||||||
|
6. Add these assets:
|
||||||
|
- `assets/prompts/dnd.npc_interactions.yaml`;
|
||||||
|
- `assets/prompts/task.md`;
|
||||||
|
- `assets/prompts/instructions.md`; and
|
||||||
|
- `assets/schemas/dnd_npc_interactions_llm.v1.json`.
|
||||||
|
7. Use the existing shared prompt assets in this exact semantic order:
|
||||||
|
common system; common extraction evidence; common identity; common campaign
|
||||||
|
references; common NPC grounding; lane task; lane instructions; variable
|
||||||
|
transcript. Preserve the established cache-control placement used by the
|
||||||
|
spell and combat-turn manifests.
|
||||||
|
8. Revise the shared `common-dnd-npcs.md` wording once so it truthfully applies
|
||||||
|
to all three consumers: a normalized registry is always presented to the
|
||||||
|
prompt and may be empty. Keep its identity, context-only rule, and
|
||||||
|
prohibition on treating registry provenance as event evidence. Do not fork a
|
||||||
|
nearly identical interaction-specific grounding asset.
|
||||||
|
9. The lane prompt must state the category definitions, precedence, occurrence
|
||||||
|
splitting rules, registry-only name restriction, empty-output behavior, and
|
||||||
|
exclusions from the feature roadmap. Keep the response schema structural:
|
||||||
|
it requires the envelope and fields but leaves enum membership, non-empty
|
||||||
|
values, and source semantics to deterministic validators.
|
||||||
|
10. Map private records to `dnd.NPCInteractionList`, attach the current source
|
||||||
|
ID, sort and exact-deduplicate ranges within each candidate, and stable-sort
|
||||||
|
candidates with valid evidence by earliest source-document position.
|
||||||
|
Preserve invalid candidate fields rather than repairing or dropping them.
|
||||||
|
11. Expose prompt, response-schema, and mapping-policy identities through
|
||||||
|
manifest metadata and checkpoint fingerprints, following the current D&D
|
||||||
|
extractors. Include the NPC names-only projection digest in local checkpoint
|
||||||
|
identity. For a preparation-time external registry, expose only bounded
|
||||||
|
digest/count metadata; do not place names or source content in metadata.
|
||||||
|
Generated-reference provenance remains framework-owned.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
At stable package boundaries, cover:
|
||||||
|
|
||||||
|
- nil/cancelled/invalid extraction requests and provider failures;
|
||||||
|
- required-slot declaration and rejection of an unbound or malformed registry
|
||||||
|
before any LLM call;
|
||||||
|
- operation-time generated registry resolution by an extractor prepared
|
||||||
|
without generated bytes;
|
||||||
|
- exact names-only registry prompt input and separation from transcript
|
||||||
|
evidence;
|
||||||
|
- empty registry plus empty response;
|
||||||
|
- mapping of every category, current-source attachment, evidence
|
||||||
|
canonicalization, and source-position ordering;
|
||||||
|
- preservation of invalid names, kinds, and ranges for validators;
|
||||||
|
- strict private JSON shape and unknown-field rejection;
|
||||||
|
- prompt rendering with required inputs, category policy, shared assets, and
|
||||||
|
transcript-last ordering;
|
||||||
|
- prompt/schema registration and content-safe metadata/fingerprints; and
|
||||||
|
- defensive module specifications and strict rejection of unknown options.
|
||||||
|
|
||||||
|
Run the existing spell and combat-turn prompt/asset tests after changing the
|
||||||
|
shared NPC prompt fragment. Do not assert an exact shared-prefix length.
|
||||||
|
|
||||||
|
### Completion Criteria
|
||||||
|
|
||||||
|
- The extractor is constructible and testable through the typed extractor
|
||||||
|
contract.
|
||||||
|
- A valid request makes one scheduled structured-completion call and returns a
|
||||||
|
typed candidate with current-source provenance.
|
||||||
|
- Missing required NPC grounding cannot reach the model.
|
||||||
|
- The extractor remains unregistered in the production D&D family until later
|
||||||
|
stages provide the rest of the lane.
|
||||||
|
|
||||||
|
## Stage 3 — Add Deterministic Artifact Validators
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Give each semantic invariant one clear validation owner and make invalid model
|
||||||
|
output a rejection rather than a normalization repair or framework error.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
Add these typed validator packages, each with strict empty options, bounded
|
||||||
|
diagnostics, typed registration, and the fixed artifact kind:
|
||||||
|
|
||||||
|
1. `internal/modules/dnd/validate/npcinteractions/shape`
|
||||||
|
(`extract/dnd/npc-interactions/shape`):
|
||||||
|
- require a non-empty trimmed name without mutating it;
|
||||||
|
- require one of the six kinds; and
|
||||||
|
- require at least one source-reference candidate with structurally required
|
||||||
|
values.
|
||||||
|
2. `internal/modules/dnd/validate/npcinteractions/registry`
|
||||||
|
(`extract/dnd/npc-interactions/registry`):
|
||||||
|
- use `npcregistry.Resolver` at construction and operation time;
|
||||||
|
- require a bound registry;
|
||||||
|
- approve a name when `Registry.Lookup` recognizes it under the established
|
||||||
|
comparison policy;
|
||||||
|
- reject unknown names in stable record order; and
|
||||||
|
- never use registry evidence as transcript evidence.
|
||||||
|
3. `internal/modules/dnd/validate/npcinteractions/source_refs`
|
||||||
|
(`extract/dnd/npc-interactions/source_refs`):
|
||||||
|
- require the current source identity;
|
||||||
|
- require referenced units to exist; and
|
||||||
|
- require valid inclusive start/end order through the current document.
|
||||||
|
Follow the established D&D citation helpers and rejection aggregation
|
||||||
|
limits rather than introducing a generic framework dependency on the
|
||||||
|
interaction type.
|
||||||
|
4. `internal/modules/dnd/validate/npcinteractions/source_relatedness`
|
||||||
|
(`extract/dnd/npc-interactions/source_relatedness`):
|
||||||
|
- inspect only current transcript text covered by the cited ranges;
|
||||||
|
- emit at most one bounded warning per record when the NPC name cannot be
|
||||||
|
related to that evidence;
|
||||||
|
- do not claim to validate category agreement; and
|
||||||
|
- ignore campaign and registry content for relatedness.
|
||||||
|
5. `internal/modules/dnd/validate/npcinteractions/invariants`
|
||||||
|
(`normalize/dnd/npc-interactions/invariants`):
|
||||||
|
- require the exact canonical display name returned by registry lookup;
|
||||||
|
- require canonical source-reference order with no duplicate ranges;
|
||||||
|
- require the complete list order defined in Fixed Decisions; and
|
||||||
|
- reject exact duplicate normalized records.
|
||||||
|
|
||||||
|
The registry and invariants validators must support operation-time generated
|
||||||
|
references. Construction-time metadata and fingerprints follow the resolver
|
||||||
|
pattern used by registry-aware modules and must not include NPC names or
|
||||||
|
content.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Give each policy one primary test owner:
|
||||||
|
|
||||||
|
- shape tests own empty names, enum membership, and missing evidence;
|
||||||
|
- registry tests own required binding, comparison-key recognition, unknown
|
||||||
|
names, operation-time generated overrides, empty registries, and content-safe
|
||||||
|
failures;
|
||||||
|
- source-reference tests own wrong source IDs, missing units, reversed ranges,
|
||||||
|
and valid multi-range records;
|
||||||
|
- relatedness tests own current-transcript-only warnings and bounded
|
||||||
|
diagnostics; and
|
||||||
|
- invariant tests own exact canonical names, final ordering, canonical evidence
|
||||||
|
sequences, and duplicate rejection.
|
||||||
|
|
||||||
|
Also verify that validators do not mutate artifacts, source documents, or
|
||||||
|
registries and that their specs register only for
|
||||||
|
`dnd.NPCInteractionListKind`.
|
||||||
|
|
||||||
|
### Completion Criteria
|
||||||
|
|
||||||
|
- Valid typed candidates are approved and malformed or ungrounded candidates
|
||||||
|
are rejected at the intended boundary.
|
||||||
|
- No validator silently canonicalizes, drops, or merges records.
|
||||||
|
- References can explain identity but cannot satisfy source evidence checks.
|
||||||
|
|
||||||
|
## Stage 4 — Add Deterministic Merge And Normalization
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Preserve chunk-scoped occurrences through merge, then produce the canonical
|
||||||
|
ordered artifact without semantic inference.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
1. Add `internal/modules/dnd/normalize/npcinteractions` with strict empty
|
||||||
|
options and the fixed module key and artifact kind.
|
||||||
|
2. Declare the same required `npcs` reference slot as the extractor. Construct
|
||||||
|
and resolve `npcregistry.Resolver` at the same preparation/operation
|
||||||
|
boundaries, and require a bound registry.
|
||||||
|
3. Implement the normalization algorithm in Fixed Decisions:
|
||||||
|
- clone all nested values;
|
||||||
|
- normalize display whitespace only as needed for lookup;
|
||||||
|
- replace every recognized name with the registry's exact canonical display
|
||||||
|
name;
|
||||||
|
- canonicalize each reference list without merging overlapping ranges;
|
||||||
|
- compute order from source-document positions rather than assuming numeric
|
||||||
|
unit IDs are contiguous;
|
||||||
|
- apply every specified tie-breaker; and
|
||||||
|
- collapse only exact records after canonicalization.
|
||||||
|
4. Preserve nil versus present-empty list behavior consistently with the other
|
||||||
|
D&D normalizers.
|
||||||
|
5. Emit bounded warnings for visible canonical-name changes, reference
|
||||||
|
canonicalization, record reordering, and exact duplicate removal. Use stable
|
||||||
|
reason codes and the shared D&D diagnostics helpers. Never include reference
|
||||||
|
content in a warning.
|
||||||
|
6. Add an append-only merger function for `dnd.NPCInteractionList` beside the
|
||||||
|
other D&D typed append functions in
|
||||||
|
`internal/modules/dnd/register/merge.go`. It must preserve accepted
|
||||||
|
chunk/lane order, preserve present-empty semantics, and deep-clone source
|
||||||
|
reference slices so outputs do not alias inputs. Do not register it until
|
||||||
|
Stage 5.
|
||||||
|
7. Add normalization-policy and NPC projection identities to checkpoint
|
||||||
|
fingerprints. Manifest metadata may include the policy identity and bounded
|
||||||
|
external-registry digest/count, but not names or source content.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- canonical registry name replacement, including Unicode/case/spacing lookup;
|
||||||
|
- required and operation-time generated registry behavior;
|
||||||
|
- source-reference ordering and exact range deduplication;
|
||||||
|
- ordering by real document position with all deterministic tie-breakers;
|
||||||
|
- separation of category transitions, alignment transitions, nearby records,
|
||||||
|
overlapping-but-nonidentical evidence, and nonidentical records from
|
||||||
|
different chunks;
|
||||||
|
- collapse of exact duplicates only;
|
||||||
|
- stable, bounded warnings;
|
||||||
|
- nil/present-empty behavior;
|
||||||
|
- input ownership and nested-slice cloning; and
|
||||||
|
- append-order merger behavior independently of normalization.
|
||||||
|
|
||||||
|
Use table-driven pure normalization cases for the dense ordering and duplicate
|
||||||
|
rules, and package-level normalizer tests for resolver and warning behavior.
|
||||||
|
|
||||||
|
### Completion Criteria
|
||||||
|
|
||||||
|
- Normalization is deterministic and idempotent.
|
||||||
|
- Running normalization twice does not change the value or emit new
|
||||||
|
transformation warnings on the second pass.
|
||||||
|
- Semantically distinct occurrences remain distinct.
|
||||||
|
- The module and merger compile but are not yet selectable through production
|
||||||
|
composition.
|
||||||
|
|
||||||
|
## Stage 5 — Compose The Complete Production Lane
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Register the complete typed lane atomically so configuration cannot select a
|
||||||
|
partial implementation.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
Update the D&D registrar:
|
||||||
|
|
||||||
|
1. In `internal/modules/dnd/register/modules.go`, register:
|
||||||
|
- the NPC-interaction codec;
|
||||||
|
- extractor and prompt assets;
|
||||||
|
- the append-order merger variant for
|
||||||
|
`dnd.NPCInteractionListKind`;
|
||||||
|
- the `dnd/npc-interactions` normalizer; and
|
||||||
|
- the generic no-op normalizer variant for the new exact Go type.
|
||||||
|
2. In `internal/modules/dnd/register/validators.go`, register all five domain
|
||||||
|
validators plus generic always-accept and always-reject typed variants for
|
||||||
|
the new artifact type.
|
||||||
|
3. In `internal/modules/dnd/register/chains.go`, register:
|
||||||
|
- extraction chain:
|
||||||
|
`generic/valid_json`,
|
||||||
|
`extract/dnd/npc-interactions/shape`,
|
||||||
|
`extract/dnd/npc-interactions/registry`,
|
||||||
|
`extract/dnd/npc-interactions/source_refs`,
|
||||||
|
`generic/valid_json_schema`,
|
||||||
|
`extract/dnd/npc-interactions/source_relatedness`;
|
||||||
|
- normalization chain:
|
||||||
|
`generic/valid_json`,
|
||||||
|
`extract/dnd/npc-interactions/shape`,
|
||||||
|
`extract/dnd/npc-interactions/registry`,
|
||||||
|
`normalize/dnd/npc-interactions/invariants`,
|
||||||
|
`extract/dnd/npc-interactions/source_refs`,
|
||||||
|
`generic/valid_json_schema`,
|
||||||
|
`extract/dnd/npc-interactions/source_relatedness`.
|
||||||
|
4. Do not add a default merge validator chain. The append merger performs no
|
||||||
|
semantic mutation, and extraction plus normalized boundaries own the
|
||||||
|
consequential policies.
|
||||||
|
5. Extend registrar contract tests to verify exact typed coverage, keys,
|
||||||
|
artifact kinds, prompt/schema assets, reference-slot agreement, and default
|
||||||
|
validator order.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Extend the existing D&D family registration test rather than creating
|
||||||
|
parallel catalog snapshots.
|
||||||
|
- Verify that the extractor and normalizer both expose required compatible
|
||||||
|
`npcs` slots and that generated `dnd/npc-list` bindings resolve only from an
|
||||||
|
earlier step.
|
||||||
|
- Verify production resolution rejects a missing required binding, a later or
|
||||||
|
same-step producer, a wrong artifact kind, and incompatible media/schema
|
||||||
|
metadata through existing resolver behavior.
|
||||||
|
- Avoid retesting the framework's general ordered-step failure matrix; add only
|
||||||
|
interaction-specific composition cases not already covered generically.
|
||||||
|
|
||||||
|
### Completion Criteria
|
||||||
|
|
||||||
|
- Production registration exposes one complete type-consistent lane.
|
||||||
|
- A valid two-step NPC-to-interactions pipeline resolves and prepares.
|
||||||
|
- Invalid or missing NPC dependencies fail before source parsing or LLM work
|
||||||
|
whenever statically discoverable.
|
||||||
|
|
||||||
|
## Stage 6 — Prove The Workflow And Publish Current Contracts
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Exercise the assembled generated-reference workflow and move all implemented
|
||||||
|
behavior into its canonical current documentation.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
1. Add one representative integration test under
|
||||||
|
`internal/modules/integration` with focused testdata:
|
||||||
|
- step 1 extracts and normalizes NPCs;
|
||||||
|
- step 2 consumes the generated NPC artifact in an interaction lane;
|
||||||
|
- the fake structured LLM returns multiple interaction kinds and at least
|
||||||
|
one name variant;
|
||||||
|
- the assertion proves the names-only handoff, current-transcript evidence,
|
||||||
|
canonical name, stable chronology, and final durable artifact.
|
||||||
|
2. Include one failure assertion showing that a rejected or absent normalized
|
||||||
|
NPC producer prevents interaction extraction. Reuse the existing runner
|
||||||
|
dependency behavior; do not duplicate every framework failure case.
|
||||||
|
3. Add `examples/dnd-npc-interactions.config.yml` as a complete copyable
|
||||||
|
two-step profile. The first step produces `npcs`; the second step binds that
|
||||||
|
exact step/lane artifact to its `npcs` reference and selects
|
||||||
|
`dnd/npc-interactions` for extract and normalize.
|
||||||
|
4. Add the example to the existing CLI production configuration contract test
|
||||||
|
so it is parsed and resolved offline.
|
||||||
|
5. Add the durable external contract at
|
||||||
|
`docs/integrations/dnd-npc-interaction-artifacts.md`. It owns schema
|
||||||
|
identity, JSON shape, categories, evidence semantics, ordering,
|
||||||
|
normalization, validator chains, generated-reference behavior, and manifest
|
||||||
|
metadata.
|
||||||
|
6. Update current canonical documentation:
|
||||||
|
- `docs/config.md` for module/validator catalogs, default chains, required
|
||||||
|
reference slot, and the maintained example;
|
||||||
|
- `docs/internal/modules.md` for implementation ownership, prompt inputs,
|
||||||
|
registry resolution, validation, merge, and normalization;
|
||||||
|
- `docs/internal/overview.md` for the implemented component inventory;
|
||||||
|
- `docs/internal/llm.md` only as needed to include the new lane in the
|
||||||
|
existing D&D prompt-order/cache policy without duplicating the manifest;
|
||||||
|
- `docs/integrations/dnd-npc-artifacts.md` to identify NPC interactions as a
|
||||||
|
consumer while retaining the rule that registry evidence is not event
|
||||||
|
evidence; and
|
||||||
|
- any directly affected configuration or integration links.
|
||||||
|
7. Remove the NPC-interactions entry from `docs/roadmap/future.md` once the
|
||||||
|
production behavior and current documentation are complete. Change
|
||||||
|
`docs/roadmap/dnd-npc-interactions.md` to `Status: Implemented` pending its
|
||||||
|
later retirement; do not leave it as the canonical current contract.
|
||||||
|
8. Create a small human-review worksheet or fixture set only if the repository
|
||||||
|
already has an appropriate non-test evaluation home. Otherwise record the
|
||||||
|
manual evaluation results in the implementation handoff rather than
|
||||||
|
inventing a new framework. Exercise all six categories, transitions,
|
||||||
|
repeated occurrences, mentions followed by presence, combat alignment
|
||||||
|
changes, cross-chunk repetition, and empty output on at least one intended
|
||||||
|
smaller model. Do not make model agreement a deterministic CI gate.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Run focused tests while implementing, then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./cmd/notarius
|
||||||
|
```
|
||||||
|
|
||||||
|
Also run `git diff --check`, inspect the complete documentation diff for
|
||||||
|
current-versus-future claims, and verify every new relative documentation link.
|
||||||
|
|
||||||
|
### Completion Criteria
|
||||||
|
|
||||||
|
- All repository checks pass offline.
|
||||||
|
- The maintained example resolves with an explicit earlier NPC producer.
|
||||||
|
- The integration test proves the accepted generated artifact, not registry
|
||||||
|
source references, grounds the later model request.
|
||||||
|
- Durable output contains only `name`, `kind`, and current-transcript
|
||||||
|
`source_refs` per occurrence.
|
||||||
|
- Current documentation owns implemented contracts, while the feature roadmap
|
||||||
|
is marked implemented and `future.md` no longer advertises the work as
|
||||||
|
pending.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None. The feature roadmap and fixed decisions above define the product and
|
||||||
|
architectural choices required for implementation.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "notarius.dnd.npc_interactions",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["interactions"],
|
||||||
|
"properties": {
|
||||||
|
"interactions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "kind", "source_refs"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["mentioned", "noncombat_presence", "dialogue", "combat_ally", "combat_opponent", "other"]
|
||||||
|
},
|
||||||
|
"source_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["source_id", "start_unit_id", "end_unit_id"],
|
||||||
|
"properties": {
|
||||||
|
"source_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"start_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"end_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/modules/dnd/codec/npcinteractions/codec.go
Normal file
140
internal/modules/dnd/codec/npcinteractions/codec.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Package npcinteractions encodes durable D&D NPC interaction artifacts.
|
||||||
|
package npcinteractions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchemaID = "notarius.dnd.npc_interactions"
|
||||||
|
SchemaName = "notarius_dnd_npc_interactions_v1"
|
||||||
|
SchemaVersion = "v1"
|
||||||
|
MediaType = "application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/schemas/dnd_npc_interactions.v1.json
|
||||||
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
|
var _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil)
|
||||||
|
|
||||||
|
type Codec struct{}
|
||||||
|
|
||||||
|
func New() *Codec { return &Codec{} }
|
||||||
|
|
||||||
|
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCInteractionListKind }
|
||||||
|
|
||||||
|
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||||
|
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_interactions.v1.json")
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ArtifactSchema{}
|
||||||
|
}
|
||||||
|
return contracts.ArtifactSchema{
|
||||||
|
ID: SchemaID,
|
||||||
|
Name: SchemaName,
|
||||||
|
Version: SchemaVersion,
|
||||||
|
JSONSchema: append([]byte(nil), raw...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) MediaType() string { return MediaType }
|
||||||
|
|
||||||
|
func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any {
|
||||||
|
return map[string]any{"interaction_count": len(value.Interactions)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) {
|
||||||
|
if err := validate(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
return c.EncodeCandidate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeCandidate provides the durable representation before semantic
|
||||||
|
// validators have approved a value.
|
||||||
|
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
|
||||||
|
content, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
|
||||||
|
value, err := c.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
return dnd.NPCInteractionList{}, err
|
||||||
|
}
|
||||||
|
if err := validate(value); err != nil {
|
||||||
|
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||||
|
// validators have approved it.
|
||||||
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var value dnd.NPCInteractionList
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: multiple JSON values")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(value dnd.NPCInteractionList) error {
|
||||||
|
if value.Interactions == nil {
|
||||||
|
return fmt.Errorf("interactions must be present")
|
||||||
|
}
|
||||||
|
for index, interaction := range value.Interactions {
|
||||||
|
prefix := fmt.Sprintf("interactions[%d]", index)
|
||||||
|
if strings.TrimSpace(interaction.Name) == "" {
|
||||||
|
return fmt.Errorf("%s.name must not be empty", prefix)
|
||||||
|
}
|
||||||
|
if !validInteractionKind(interaction.Kind) {
|
||||||
|
return fmt.Errorf("%s.kind must be supported", prefix)
|
||||||
|
}
|
||||||
|
if len(interaction.SourceRefs) == 0 {
|
||||||
|
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
|
||||||
|
}
|
||||||
|
for refIndex, ref := range interaction.SourceRefs {
|
||||||
|
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
||||||
|
if strings.TrimSpace(ref.SourceID) == "" {
|
||||||
|
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||||
|
}
|
||||||
|
if ref.StartUnitID <= 0 {
|
||||||
|
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||||
|
}
|
||||||
|
if ref.EndUnitID <= 0 {
|
||||||
|
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInteractionKind(value dnd.NPCInteractionKind) bool {
|
||||||
|
switch value {
|
||||||
|
case dnd.NPCInteractionKindMentioned,
|
||||||
|
dnd.NPCInteractionKindNoncombatPresence,
|
||||||
|
dnd.NPCInteractionKindDialogue,
|
||||||
|
dnd.NPCInteractionKindCombatAlly,
|
||||||
|
dnd.NPCInteractionKindCombatOpponent,
|
||||||
|
dnd.NPCInteractionKindOther:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
183
internal/modules/dnd/codec/npcinteractions/codec_test.go
Normal file
183
internal/modules/dnd/codec/npcinteractions/codec_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package npcinteractions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validList() dnd.NPCInteractionList {
|
||||||
|
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||||
|
{
|
||||||
|
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Hooded Guard", Kind: dnd.NPCInteractionKindCombatOpponent,
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/dnd_npc_interactions.v1.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
codec := New()
|
||||||
|
value, err := codec.Decode(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v", err)
|
||||||
|
}
|
||||||
|
if want := validList(); !reflect.DeepEqual(value, want) {
|
||||||
|
t.Fatalf("Decode() = %#v, want %#v", value, want)
|
||||||
|
}
|
||||||
|
encoded, err := codec.Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
var compact bytes.Buffer
|
||||||
|
if err := json.Compact(&compact, raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(encoded, compact.Bytes()) {
|
||||||
|
t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
schema := codec.Schema()
|
||||||
|
if codec.Kind() != dnd.NPCInteractionListKind || codec.MediaType() != MediaType {
|
||||||
|
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
|
||||||
|
}
|
||||||
|
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
||||||
|
t.Fatalf("schema = %#v", schema)
|
||||||
|
}
|
||||||
|
registry := pipeline.NewArtifactCodecRegistry()
|
||||||
|
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec, ok := registry.Spec(dnd.NPCInteractionListKind)
|
||||||
|
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||||
|
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||||
|
}
|
||||||
|
if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCList{}); err == nil {
|
||||||
|
t.Fatal("Encode() error = nil, want exact type rejection")
|
||||||
|
} else {
|
||||||
|
var typeErr *pipeline.ArtifactCodecTypeError
|
||||||
|
if !errors.As(err, &typeErr) {
|
||||||
|
t.Fatalf("Encode() error = %T, want ArtifactCodecTypeError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}
|
||||||
|
content, err := codec.Encode(empty)
|
||||||
|
if err != nil || string(content) != `{"interactions":[]}` {
|
||||||
|
t.Fatalf("Encode() = %s, %v", content, err)
|
||||||
|
}
|
||||||
|
for _, candidate := range []dnd.NPCInteractionList{
|
||||||
|
{},
|
||||||
|
empty,
|
||||||
|
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}},
|
||||||
|
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
|
||||||
|
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
|
||||||
|
} {
|
||||||
|
content, err := codec.EncodeCandidate(candidate)
|
||||||
|
if err != nil || !json.Valid(content) {
|
||||||
|
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
|
||||||
|
}
|
||||||
|
decoded, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||||
|
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
||||||
|
validJSON := `{"interactions":[{"name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
|
for _, test := range []struct{ name, raw, want string }{
|
||||||
|
{"malformed", `{`, "decode dnd npc interaction list"},
|
||||||
|
{"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"},
|
||||||
|
{"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"},
|
||||||
|
{"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
||||||
|
{"trailing", `{"interactions":[]} {}`, "multiple JSON values"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value dnd.NPCInteractionList
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"},
|
||||||
|
{"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"},
|
||||||
|
{"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"},
|
||||||
|
{"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"},
|
||||||
|
{"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
||||||
|
{"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
|
||||||
|
{"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := New().Encode(test.value); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
|
||||||
|
for _, kind := range []dnd.NPCInteractionKind{
|
||||||
|
dnd.NPCInteractionKindMentioned,
|
||||||
|
dnd.NPCInteractionKindNoncombatPresence,
|
||||||
|
dnd.NPCInteractionKindDialogue,
|
||||||
|
dnd.NPCInteractionKindCombatAlly,
|
||||||
|
dnd.NPCInteractionKindCombatOpponent,
|
||||||
|
dnd.NPCInteractionKindOther,
|
||||||
|
} {
|
||||||
|
value := validList()
|
||||||
|
value.Interactions[0].Kind = kind
|
||||||
|
if _, err := New().Encode(value); err != nil {
|
||||||
|
t.Fatalf("Encode(%q) error = %v", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
first := codec.Schema()
|
||||||
|
first.JSONSchema[0] = '['
|
||||||
|
if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
|
||||||
|
t.Fatal("Schema() returned shared bytes")
|
||||||
|
}
|
||||||
|
metadata := codec.Metadata(validList())
|
||||||
|
metadata["other"] = true
|
||||||
|
if next := codec.Metadata(validList()); len(next) != 1 || next["interaction_count"] != 2 {
|
||||||
|
t.Fatalf("Metadata() = %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList {
|
||||||
|
change(&value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
18
internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json
vendored
Normal file
18
internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"name": "Mira Thorn",
|
||||||
|
"kind": "dialogue",
|
||||||
|
"source_refs": [
|
||||||
|
{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hooded Guard",
|
||||||
|
"kind": "combat_opponent",
|
||||||
|
"source_refs": [
|
||||||
|
{"source_id": "session-alpha", "start_unit_id": 3, "end_unit_id": 3}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ const NPCListKind contracts.ArtifactKind = "dnd/npc-list"
|
|||||||
|
|
||||||
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
||||||
|
|
||||||
|
const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
|
||||||
|
|
||||||
type SpellList struct {
|
type SpellList struct {
|
||||||
SpellCasts []SpellCast `json:"spell_casts"`
|
SpellCasts []SpellCast `json:"spell_casts"`
|
||||||
}
|
}
|
||||||
@@ -51,3 +53,24 @@ type CombatTurn struct {
|
|||||||
TurnKind CombatTurnKind `json:"turn_kind"`
|
TurnKind CombatTurnKind `json:"turn_kind"`
|
||||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NPCInteractionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NPCInteractionKindMentioned NPCInteractionKind = "mentioned"
|
||||||
|
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence"
|
||||||
|
NPCInteractionKindDialogue NPCInteractionKind = "dialogue"
|
||||||
|
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally"
|
||||||
|
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent"
|
||||||
|
NPCInteractionKindOther NPCInteractionKind = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NPCInteractionList struct {
|
||||||
|
Interactions []NPCInteraction `json:"interactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NPCInteraction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind NPCInteractionKind `json:"kind"`
|
||||||
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user