Compare commits

...

26 Commits

Author SHA1 Message Date
6dc9d522b1 Audit the D&D modules 2026-07-24 13:43:53 +00:00
8adcf6840d Clean up completed D&D scene chunking work 2026-07-24 03:14:34 +00:00
f5ed30e455 Retire annotated D&D scene plans 2026-07-24 00:40:53 +00:00
cacf3f24e7 Simplify D&D scene chunking responses 2026-07-24 00:35:10 +00:00
f08ca4ddfa Plan simpler D&D scene chunking 2026-07-24 00:29:46 +00:00
e1c2f3c202 Harden D&D scene description extraction 2026-07-23 23:03:37 +00:00
9614eb540d Format shared D&D prompt assets 2026-07-23 20:30:47 +00:00
1b46596a39 Document D&D scene description artifacts 2026-07-23 20:29:01 +00:00
e043d61a99 Register D&D scene description pipeline 2026-07-23 20:22:05 +00:00
ad89782c9b Add D&D scene description validation 2026-07-23 20:17:18 +00:00
cd29265d5d Add D&D scene description extractor 2026-07-23 20:09:59 +00:00
2f36b7c3b6 Add D&D scene description codec 2026-07-23 20:02:37 +00:00
b8b3f3abfa Plan D&D scene description extraction 2026-07-23 18:59:34 +00:00
b490297cde Harden chunk map export and retire completed plans 2026-07-23 17:38:04 +00:00
06148074a2 Document accepted chunk map export 2026-07-23 15:06:33 +00:00
16a998055c Add opt-in JSON chunk map export 2026-07-23 15:02:01 +00:00
97c9a8e5ce Carry accepted chunk maps through the runner 2026-07-23 14:58:14 +00:00
66415fd1fa Add accepted chunk map contract 2026-07-23 14:52:25 +00:00
bfe25609a7 Tighten NPC interaction validation and consistency 2026-07-23 14:25:32 +00:00
36e0512454 Complete D&D NPC interaction integration 2026-07-23 13:59:02 +00:00
b02f667107 Register NPC interaction production lane 2026-07-23 13:49:34 +00:00
ed2b6f4580 Add NPC interaction normalization 2026-07-23 13:42:37 +00:00
cb7f145c76 Add NPC interaction validators 2026-07-23 13:35:15 +00:00
2b9d2eaeaa Add grounded NPC interaction extractor 2026-07-23 13:26:27 +00:00
61016671ab Add D&D NPC interaction artifact codec 2026-07-23 13:18:38 +00:00
b2c076946b Plan chunk map exports and scene descriptions 2026-07-23 04:33:18 +00:00
118 changed files with 10170 additions and 1123 deletions

View File

@@ -31,4 +31,5 @@ Useful references:
- [Internal implementation docs](docs/internal/overview.md)
- [Maintained example config](examples/dnd-spells.config.yml)
- [NPC-grounded example config](examples/dnd-npc-grounded.config.yml)
- [NPC interaction example config](examples/dnd-npc-interactions.config.yml)
- [Maintained example input](examples/seriatim-minimal-transcript.json)

View File

@@ -24,6 +24,8 @@ The explicit-path option is defined in the [CLI reference](cli.md).
- [D&D NPC configuration](../examples/dnd-npcs.config.yml)
- [D&D combat-turn configuration](../examples/dnd-combat-turns.config.yml)
- [D&D NPC-grounded spell and combat configuration](../examples/dnd-npc-grounded.config.yml)
- [D&D NPC interaction configuration](../examples/dnd-npc-interactions.config.yml)
- [D&D scene chunk-map configuration](../examples/dnd-scene-chunk-map.config.yml)
All are complete version 3 files. The fragments below illustrate individual
fields and are not alternate complete configurations.
@@ -330,6 +332,25 @@ During resolution, each selected module's registered option validator runs.
Production input, chunk, and output bindings reject unknown or invalid options
with the affected binding context.
### JSON Output Options
The `json` output module accepts only `include_chunk_map`, a boolean that
defaults to `false`. When `true`, it adds the accepted pipeline-wide chunk map
to the logical output bundle when one exists. It does not create a CLI flag or
change output placement.
```yaml
output:
module: json
options:
include_chunk_map: true
```
The payload and index descriptor are defined by the
[Accepted Chunk Map contract](integrations/chunk-map.md). See the complete
[D&D scene chunk-map configuration](../examples/dnd-scene-chunk-map.config.yml)
for a copyable pipeline.
Validator bindings use the same shorthand or object module-binding form, but
only these fields are supported:
@@ -355,12 +376,16 @@ production validators do not call the LLM and must not set `llm_profile`.
| extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. |
| extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. |
| extract | `dnd/combat-turns` | Extracts typed D&D combat-turn-list artifacts. |
| extract | `dnd/npc-interactions` | Extracts typed D&D NPC-interaction-list artifacts from a supplied NPC registry. |
| extract | `dnd/scene-descriptions` | Extracts one typed D&D scene description for each accepted chunk. |
| 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 name and unions exact evidence. |
| normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. |
| output | `json` | Produces JSON output files for normalized `application/json` lanes. |
| normalize | `dnd/npc-interactions` | Canonicalizes registry NPC names, orders interaction occurrences, and removes only exact duplicates. |
| normalize | `dnd/scene-descriptions` | Validates, orders, trims, and de-duplicates typed D&D scene-description artifacts. |
| output | `json` | Produces JSON output files for normalized `application/json` lanes and can opt in to an accepted chunk map. |
## Implemented Production Validators
@@ -382,6 +407,15 @@ production validators do not call the LLM and must not set `llm_profile`.
| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. |
| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor is not found near cited source text. |
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. |
| `extract/dnd/npc-interactions/shape` | deterministic | Rejects malformed D&D NPC-interaction-list artifacts. |
| `extract/dnd/npc-interactions/registry` | deterministic | Rejects interaction names absent from the supplied NPC registry. |
| `extract/dnd/npc-interactions/source_refs` | deterministic | Rejects missing, invalid, or extract-chunk-external D&D interaction source references. |
| `extract/dnd/npc-interactions/source_relatedness` | deterministic | Emits bounded warnings when an interaction name is not found in its cited source text. |
| `normalize/dnd/npc-interactions/invariants` | deterministic | Rejects normalized interaction identity, evidence-order, and chronology violations. |
| `extract/dnd/scene-descriptions/shape` | deterministic | Rejects malformed D&D scene-description-list artifacts. |
| `extract/dnd/scene-descriptions/source_refs` | deterministic | Rejects invalid current-source references and extract records not exactly attached to their chunk. |
| `extract/dnd/scene-descriptions/source_relatedness` | deterministic | Emits bounded advisory warnings for title or summary text not grounded in its cited transcript range. |
| `normalize/dnd/scene-descriptions/invariants` | deterministic | Rejects non-normalized scene order, exact duplicates, and ID or range conflicts. |
The production default chain for `dnd/spells` is used for both its extract and
normalize stages:
@@ -436,6 +470,48 @@ normalize:
- extract/dnd/combat-turns/source_relatedness
```
The production default chains for `dnd/npc-interactions` are:
```yaml
extract:
validators:
- 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
normalize:
validators:
- 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
```
The production default chains for `dnd/scene-descriptions` are:
```yaml
extract:
validators:
- generic/valid_json
- extract/dnd/scene-descriptions/shape
- extract/dnd/scene-descriptions/source_refs
- generic/valid_json_schema
- extract/dnd/scene-descriptions/source_relatedness
normalize:
validators:
- generic/valid_json
- extract/dnd/scene-descriptions/shape
- normalize/dnd/scene-descriptions/invariants
- extract/dnd/scene-descriptions/source_refs
- generic/valid_json_schema
- extract/dnd/scene-descriptions/source_relatedness
```
Empty chains approve output by default.
The `generic` chunker accepts:
@@ -492,6 +568,13 @@ accepts no references. The maintained
accepted normalized output to the later spell and combat targets through an
explicit ordered step.
The `dnd/scene-descriptions` extractor accepts only the optional `players`,
`party`, and `glossary` campaign slots. They can disambiguate terms in the
model-facing title and summary but are never source evidence. Its normalizer
accepts no references. No NPC registry or generated artifact is required. See
the [D&D scene-description artifact contract](integrations/dnd-scene-description-artifacts.md)
and the copyable [scene-description configuration](../examples/dnd-scene-descriptions.config.yml).
The `dnd/combat-turns` extractor declares the optional campaign slots and the
structured `npcs` slot. Campaign references guide only the LLM extraction
stage. The deterministic normalizer declares only `npcs`, whose operation-time
@@ -516,6 +599,16 @@ identity; names, content, and paths are not recorded there. When
absent, the combat prompt receives the exact empty registry value
`{"npcs":[]}` with its projection digest and no registry provenance.
Both `dnd/npc-interactions` stages require the structured `npcs` slot. Bind one
accepted normalized NPC artifact from an earlier ordered step; the interaction
extractor receives only its names-only projection, while the normalizer uses
the immutable registry for exact canonical-name lookup. A missing, rejected,
or incompatible producer prevents the interaction step from running. Registry
source references remain identity provenance and never supply interaction
evidence: every interaction must cite current transcript units. The complete
durable contract and two-step configuration are defined in the
[D&D NPC interaction artifact contract](integrations/dnd-npc-interaction-artifacts.md).
## State Surfaces
The `output`, `cache`, and `debug` top-level fields select independent physical

View File

@@ -0,0 +1,83 @@
# Accepted Chunk Map
This document defines the durable accepted chunk-map artifact that the JSON
output encoder can write as `chunk-map.json`. It describes the exact accepted,
materialized chunks used by a run; it is not a lane artifact and is never an
input to later pipeline steps. Enable it with the JSON output option described
in [Configuration](../config.md#json-output-options).
## Identity
- Artifact kind: `source/chunk-map`
- Logical file: `chunk-map.json`
- Schema ID: `notarius.source.chunk_map`
- Schema name: `notarius_source_chunk_map_v1`
- Schema version: `v1`
- Media type: `application/json`
The checked-in [JSON Schema](../../internal/framework/chunkmap/assets/schemas/source_chunk_map.v1.json)
defines the strict wire shape. `chunk-map.json` is listed by the optional
`chunk_map` descriptor in [the JSON output index](json-output.md#indexjson),
not by the lane-oriented `output_files` collection.
## Payload
The payload has these required fields:
- `source_id`: accepted source-document identity.
- `source_digest`: canonical lower-case `sha256:` digest of that document.
- `plan_digest`: canonical lower-case `sha256:` digest of the accepted logical
plan.
- `requested_chunker`: chunk module selected by the current resolved pipeline.
- `producer`: the original accepted-plan producer, with required
`input_module` and `chunk_module`; `llm_profile` is present only for an
LLM-backed producer.
- `plan_annotations`: accepted plan-level annotation namespace map. It is
`{}` when no namespaces are present.
- `chunks`: non-empty execution-order collection of accepted chunks.
Each chunk has `id`, zero-based `index`, `source_ref`, positive `unit_count`,
and an explicit `annotations` namespace map. A source reference has the source
ID and inclusive positive `start_unit_id` and `end_unit_id` endpoints.
Annotation values are arbitrary valid JSON under non-empty namespaces. They
are preserved as canonical JSON without interpreting any module-specific
namespace.
## Invariants
The framework constructs this artifact only after materializing the selected
logical plan and accepting it through the configured chunk validator chain.
Construction proves the source and plan digests, source-document range order,
materialized chunk IDs and indexes, source references, unit membership and
counts, and plan/range annotations agree exactly. Chunk IDs are unique and
indexes are contiguous and agree with array order.
The codec rejects unknown fixed-object fields, malformed identities or
digests, invalid annotation JSON, trailing JSON content, and any payload whose
reconstructed logical plan does not reproduce `plan_digest`. It makes
defensive copies at serialization and decoding boundaries.
## Acceptance And Provenance
The artifact is available only when the chunk plan was accepted. It remains
available when a later extraction, merge, or normalization result is rejected;
it is absent when chunk validation rejects the candidate plan.
`requested_chunker` describes the current pipeline selection. `producer`
describes who originally produced the accepted plan. On a cache hit these can
differ: the accepted ranges, annotations, digests, and stable materialized IDs
are reused, while the producer remains the stored producer. Cache paths,
actions, references, metadata, warnings, timestamps, and detailed provenance
remain in the run manifest rather than this payload.
## Data Handling
The map contains structure, not source content. It excludes transcript bytes,
materialized units, source-unit metadata, chunk content, private model
responses, rejected proposals, debug data, external-reference content, and
filesystem paths.
Annotations can nevertheless be source- or model-derived. Treat an enabled
`chunk-map.json` with the same sensitivity and retention expectations as lane
output. Physical placement, confined atomic writing, and permissions follow
the ordinary [output operation](../operations.md#output).

View File

@@ -5,6 +5,10 @@ the selectable production NPC pipeline. The normalized JSON payload can be
passed explicitly to the spell extractor as an optional caster-name registry
or to the combat extractor and normalizer as an actor registry. It
remains a reference, not spell or combat evidence.
The NPC interaction extractor and normalizer also consume this registry for
canonical identity; registry source references remain provenance and never
become interaction evidence. Their occurrence contract is defined in the
[D&D NPC interaction artifact](dnd-npc-interaction-artifacts.md).
## Identity

View File

@@ -0,0 +1,148 @@
# D&D NPC Interaction Artifact
This document defines the durable D&D NPC-interaction-list artifact and its
two-step production pipeline. It records discrete, source-grounded occurrences
for NPCs already accepted into a normalized NPC registry; it does not expand
the registry or summarize events.
## Identity And JSON
- 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`
- Media type: `application/json`
The payload is one strict JSON object with only an `interactions` array. The
array may be empty. Each item has exactly `name`, `kind`, and `source_refs`:
```json
{
"interactions": [
{
"name": "Mira Thorn",
"kind": "dialogue",
"source_refs": [
{"source_id": "session-7", "start_unit_id": 12, "end_unit_id": 13}
]
}
]
}
```
`name` is the canonical display name from the required NPC registry.
`source_refs` contains one or more current-source ranges with required
`source_id`, `start_unit_id`, and `end_unit_id`; unit IDs are positive integers.
During extraction, every range must be wholly contained in the current accepted
chunk. This prevents a candidate from citing valid units that were not presented
to that extraction call.
Unknown fields are rejected.
## Interaction Categories
`kind` is exactly one of:
| 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 a fallback
for uncertain classification. When activities overlap, active combat
participation outranks dialogue, presence, and mention; dialogue outranks
non-combat presence and mention; and non-combat presence outranks mention.
Combat alignment is not resolved by precedence: a meaningful change between
ally and opponent creates separate occurrences.
These categories do not encode summaries, relationships, state, motives, or
unobserved events.
## Occurrence Boundaries And Ordering
One occurrence represents one NPC, one kind, and one locally coherent passage
within one accepted chunk. Repeated evidence belongs to the same occurrence
only while it supports the same uninterrupted activity. A kind change, combat
alignment change, intervening scene or meaningful absence, or transition from
mention to presence starts a new occurrence. Occurrences never span chunks, and
merge or normalization never semantically combines nearby, overlapping, or
cross-chunk records.
Normalization orders records by:
1. earliest valid source-document position;
2. the NPC identity comparison key;
3. the exact canonical NPC display name;
4. interaction kind in lexical order; and
5. the complete canonical source-reference sequence, ordered by source ID and
the source-document positions of each range's start and end.
Only records with identical canonical names, kinds, and complete valid evidence
sequences are duplicates. Different categories, ranges, or separately grounded
occurrences remain separate.
## Evidence, Registry, And Normalization
The registry proves only the canonical NPC identity. Its source references are
registry provenance and are never interaction evidence. Every durable
interaction must cite current transcript units supporting both the name and its
classification.
The extractor receives a names-only registry projection such as
`{"npcs":[{"name":"Mira Thorn"}]}`. The normalizer uses the full immutable
registry for exact canonical-name lookup. It canonicalizes source references
and applies the ordering and exact-duplicate rules above.
## Production Pipeline
The extractor and normalizer key is `dnd/npc-interactions`. Both require the
structured `npcs` slot, so an accepted normalized registry must come from an
earlier step:
```yaml
pipelines:
dnd-npc-interactions:
input: seriatim
steps:
- id: identify-npcs
artifacts:
npcs:
extract: dnd/npcs
normalize: dnd/npcs
- id: extract-interactions
references:
npcs:
artifact:
step: identify-npcs
lane: npcs
artifacts:
interactions:
extract: dnd/npc-interactions
normalize: dnd/npc-interactions
```
The framework passes only the accepted normalized producer. A missing, rejected,
or incompatible NPC artifact prevents the consumer step from executing. It
records generated artifact identity and bounded producer provenance without
copying registry names, source ranges, or payload content into the manifest.
## Validation And Metadata
The default extract chain is `generic/valid_json`, interaction shape, registry,
and source-reference validation, `generic/valid_json_schema`, then warning-only
source relatedness. The normalize chain runs normalized invariants after
registry validation and before source-reference and schema validation, followed
by relatedness. Normalizer and relatedness warnings are bounded and end with an
omission summary when necessary. The codec metadata contains only
`interaction_count`. Extractor metadata identifies its prompt and private
response schema; component-local checkpoint identities include the names-only
registry projection where relevant. Generated registry identity stays in
framework provenance and dependency fingerprints.
See [Configuration](../config.md#implemented-production-modules) for selectable
keys and chains, [the NPC artifact contract](dnd-npc-artifacts.md) for the
registry boundary, and the copyable
[maintained example](../../examples/dnd-npc-interactions.config.yml).

View File

@@ -0,0 +1,120 @@
# D&D Scene Description Artifact Contract
This document defines the durable `dnd/scene-description-list` artifact
emitted by the D&D scene-description lane.
## Artifact identity
| Property | Value |
| --- | --- |
| Artifact kind | `dnd/scene-description-list` |
| Schema ID | `notarius.dnd.scene_descriptions` |
| Schema name | `notarius_dnd_scene_descriptions_v1` |
| Schema version | `v1` |
| Media type | `application/json` |
The normalized payload is written by the JSON output module to
`lanes/<lane-id>.json`. See [JSON output](json-output.md) for the surrounding
output bundle.
## JSON shape
The payload is a JSON object containing exactly one required field, `scenes`.
Each scene object contains exactly these required fields:
| Field | Shape and ownership |
| --- | --- |
| `id` | Non-empty accepted chunk ID, assigned by Notarius. |
| `source_ref` | Exact inclusive accepted chunk range, assigned by Notarius. |
| `kind` | One of `combat`, `narrative`, `recap`, or `meta`. |
| `title` | Non-empty, trimmed, source-grounded title. |
| `summary` | Non-empty, trimmed, source-grounded summary. |
All object layers reject unknown fields. The `scenes` array must be present and
non-empty. `source_ref` has exactly `source_id`, `start_unit_id`, and
`end_unit_id`; its source ID is non-empty and its unit IDs are positive
integers.
```json
{
"scenes": [
{
"id": "chunk-000001",
"source_ref": {
"source_id": "session-alpha",
"start_unit_id": 1,
"end_unit_id": 3
},
"kind": "narrative",
"title": "Arrival at the watchtower",
"summary": "The party reaches the ruined watchtower and begins to investigate it."
}
]
}
```
`id` and `source_ref` are application-owned identity and evidence. The model
provides only `kind`, `title`, and `summary`; it is not asked for chunk IDs,
source IDs, unit IDs, ranges, participants, or confidence.
## Scene kinds
| Kind | Meaning |
| --- | --- |
| `combat` | Active combat is a substantive central activity. |
| `narrative` | Current-session in-world play that is not principally combat, recap, or meta discussion. |
| `recap` | The table is primarily recounting a previous session. |
| `meta` | Sustained out-of-character discussion is the scene's primary purpose. |
For a mixed accepted chunk, classification prefers `combat`, then `recap`,
then `meta`, then `narrative`. Brief table talk, rules clarification, or a
short recollection does not replace the main current-session activity.
## Extraction and evidence
The extractor runs once for each accepted chunk and maps one successful model
response to one scene record. It copies the current chunk ID and exact chunk
range, preserves the model kind without repair, and trims only surrounding
whitespace from title and summary. A model response cannot represent an empty
result; extraction failure follows the configured retry and rejection policy.
Optional `players`, `party`, and `glossary` campaign references can help
disambiguate names or setting terms. They never supply scene evidence or add
events absent from the accepted chunk. The lane requires no NPC registry or
other generated artifact reference.
## Merge and normalization
Accepted per-chunk lists are appended in chunk order. Normalization then:
1. validates the current-source range, non-empty ID and prose, and closed kind;
2. trims only title and summary whitespace;
3. sorts records by source-document start position, then ID;
4. removes records only when all five durable fields are identical;
5. rejects a reused ID when any remaining durable field differs; and
6. rejects the same exact range when `kind`, `title`, or `summary` differs.
Two different IDs with the same range and identical model-owned content remain
separate records. Normalization does not join adjacent ranges, rewrite prose,
repair kinds, infer missing scenes, or use chunk annotations.
## Validation and warnings
Extraction validation requires exactly one record with an ID and range exactly
equal to its current chunk. Later validation checks source membership without a
current chunk. Shape, source-range, kind, ID, and normalized-invariant failures
reject the artifact.
Relatedness checks are advisory. They separately warn when a scene title or
summary has no significant lexical token in its cited transcript range. The
check ignores short tokens and common function words, uses transcript text only,
and does not treat campaign references as evidence. Warning diagnostics are
bounded and do not copy transcript or campaign-reference content.
## Configuration
Use `dnd/scenes` to form accepted scene chunks, then select
`dnd/scene-descriptions` for extraction and normalization. The maintained
[scene-description configuration](../../examples/dnd-scene-descriptions.config.yml)
is a copyable minimal lane. Selectable keys and default validator chains are
defined in [Configuration](../config.md).

View File

@@ -18,6 +18,8 @@ The encoder writes:
- `lanes/<lane-id>.json`, one file per normalized serialized artifact
- `rejected.json`
- `warnings.json`
- `chunk-map.json`, only when the JSON output binding enables
`include_chunk_map` and the run has an accepted chunk map
Files are pretty-printed JSON with a trailing newline when the payload is JSON.
Logical file paths are relative, slash-separated, and may not contain `..`.
@@ -60,6 +62,13 @@ contains the normalized payload `media_type`, normalizer `module_key`, and
response `schema_id`, `schema_name`, and `schema_version` when those values are
available.
When present, the top-level optional `chunk_map` descriptor contains exactly
`artifact_kind`, `file`, `media_type`, `schema_id`, `schema_name`, and
`schema_version`. It identifies the pipeline-wide `chunk-map.json`; it is not
a lane output and never appears in `output_files`. The descriptor and file are
both absent when export is disabled or no chunk plan was accepted. Its payload
contract is defined by [Accepted Chunk Map](chunk-map.md).
## `manifest.json`
`manifest.json` contains a run manifest. This abridged example shows its core
@@ -153,7 +162,8 @@ 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 lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md),
[D&D NPC Artifact](dnd-npc-artifacts.md), and
[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md).
[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md), and
[D&D Scene Description Artifact](dnd-scene-description-artifacts.md).
## `rejected.json`

View File

@@ -39,7 +39,8 @@ 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, NPC, and combat extractors retain this
The D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and
scene-description extractors retain this
injected client and use it for every structured completion. Operation requests
do not carry an LLM client.
@@ -98,7 +99,8 @@ D&D prompt content. `internal/framework/promptfs` provides the domain-neutral
filesystem composition helper used to combine module-owned files with shared
domain prompt fragments.
The D&D scene chunker and spell, NPC, and combat-turn extractors each declare an
The D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and
scene-description extractors each declare an
ordered prompt asset manifest. The manifest lists the package-owned YAML and
Markdown files, then the exact shared fragments rendered by that prompt; the
same ordered list drives both filesystem mounting and the prompt fingerprint.
@@ -126,21 +128,31 @@ per-chunk transcript changes. Cache reuse requires the preceding message
sequence and content to be exactly identical; semantic similarity is not
sufficient. Cache boundaries belong at the ends of reusable stable tiers,
subject to the provider's cache-boundary limit. The shared identity and
campaign-reference messages form the first two extraction boundaries. Spell
and combat prompts add a boundary at the shared NPC registry. Each extraction
campaign-reference messages form the first two extraction boundaries. Spell,
combat, and interaction prompts add a boundary at the shared NPC registry. Each extraction
prompt places its final boundary on its lane-specific instructions, immediately
before the transcript. The transcript does not carry cache control because no
reusable content follows it.
Accordingly, the common prefix of all three extraction prompts is system,
Accordingly, the common prefix of the spell, NPC, combat, and interaction
extraction prompts is system,
extraction evidence, identity, and campaign references. The NPC prompt then
renders task, instructions, and transcript. Spell renders the NPC registry,
catalog, task, instructions, and transcript. Combat renders the NPC registry,
task, instructions, and transcript. The
task, instructions, and transcript. NPC interaction renders the names-only NPC
registry, task, instructions, and transcript. The
scene chunker is not an extraction lane: it retains its separate system,
transcript, campaign-reference, task, and instruction order and marks its
transcript and campaign-reference messages ephemeral.
The scene-description extractor deliberately omits the citation-oriented
`common-dnd-extraction-evidence.md` asset because Notarius attaches the whole
accepted chunk range itself. Its manifest is system, shared identity, shared
campaign references, lane task, lane instructions, then the transcript. The
identity, campaign-reference, and instruction messages are ephemeral cache
boundaries; the transcript is last and has no cache control. Compatible shared
messages remain canonical shared assets rather than copied package text.
Shared wording belongs in the canonical assets under
`internal/modules/dnd/shared`; extraction packages reference those assets in
their manifests instead of copying similar text into package-local files.
@@ -154,7 +166,8 @@ return defensive copies, and expose a diagnostics map that omits schema bytes.
The small framework registry contains only generic test schemas; production
schemas remain package-owned.
The spell, NPC, and combat extractors' package-owned prompts declare their
The spell, NPC, combat, NPC-interaction, and scene-description extractors'
package-owned prompts declare their
structured JSON inputs and private response schemas. Each private response
schema remains separate from its durable artifact codec schema; this work does
not use shared schema fragments or schema generation. Those private schemas own
@@ -167,14 +180,14 @@ numbers. The spell extractor's prompt declares a required
the catalog input from its prepared
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
The shared D&D prompt assets include a generic NPC grounding fragment directly
after the campaign reference message for spell and combat prompts. When an NPC
after the campaign reference message for spell, combat, and interaction prompts. When an NPC
registry is bound, the
domain registry boundary strictly decodes and identity-validates one durable
artifact, re-encodes canonical JSON for provenance, and separately generates a
names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
Prompt input and component-local checkpoint digests cover the projected bytes;
manifests retain the optional full registry digest/count rather than names,
overlay bytes, registry paths, or source metadata. Combat prompt,
overlay bytes, registry paths, or source metadata. Combat and interaction prompt,
response-schema, mapping, normalization, identity, and registry-projection
fingerprints remain separate semantic inputs to checkpoint identity.

View File

@@ -17,16 +17,16 @@ validator registry. Package-family registrars compose those leaf registrations
into the production catalog and own family-level policy such as default
validator chains and prompt asset collection.
Production input, chunk, output, and D&D spell- and combat-extract packages
Production input, chunk, output, and D&D spell-, NPC-, combat-, interaction-, and scene-description-extract packages
register strict option decoders and run-local builders. Preparation decodes their options into
implementation-owned values and injects dependencies plus the materialized
reference set for the selected target. Each builder receives an isolated clone
of that set; input and output builders receive no references. The spell and
combat extractors are typed over the canonical D&D model. D&D validators, merge,
of that set; input and output builders receive no references. The spell, NPC,
combat, interaction, and scene-description extractors are 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 the canonical typed spell,
NPC, and combat implementations, including their kind-specific merge and
NPC, combat, interaction, and scene-description implementations, including their kind-specific merge and
normalize behavior.
For D&D artifact defaults, generic JSON syntax validation runs first. Rejecting
@@ -54,7 +54,7 @@ package-local embedded assets. Shared filesystem composition belongs in
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
declarations, prompt-input assembly, and source-unit/citation helpers belong in
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
D&D scene chunker and spell, NPC, and combat-turn extractors use ordered
D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and scene-description extractors use ordered
package-local prompt manifests for both rendering and prompt fingerprinting, so
only the shared fragments each prompt actually renders participate in either
operation. Extraction prompts place stable shared and lane-specific context
@@ -65,7 +65,7 @@ contracts expose only Notarius structured-completion types, not Scriptorium
public types.
The shared `ChunkPromptMaterial` helper owns common transcript material
preparation for the spell, NPC, and combat-turn extractors. It clones supplied
preparation for the spell, NPC, combat-turn, and NPC-interaction extractors. It clones supplied
source metadata, falls back to the materialized chunk when content is absent,
checks that content remains chunk-identical, and fills only the common default
fields. Extractors retain their request validation and wrap helper errors with
@@ -122,6 +122,18 @@ The `internal/modules/dnd/codec/combatturns` package owns the durable
is registered by the production D&D family registrar for the selectable combat
lane.
The `internal/modules/dnd/codec/npcinteractions` package owns the durable
`dnd/npc-interaction-list` schema and candidate versus approved JSON boundary.
It is registered by the production D&D family registrar for the selectable
interaction lane. Its external contract is documented in the
[D&D NPC interaction artifact contract](../integrations/dnd-npc-interaction-artifacts.md).
The `internal/modules/dnd/codec/scenedescriptions` package owns the durable
`dnd/scene-description-list` schema and candidate versus approved JSON boundary.
It is registered by the production D&D family registrar. Its external contract
is documented in the
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
## Input Adapter
### `internal/modules/seriatim/input/transcript`
@@ -165,17 +177,16 @@ window settings used by `Plan`.
The scene chunker prepares a structured Scriptorium request from the full
transcript, session, and optional D&D reference inputs. It validates the model's
scene boundaries against source-unit IDs and converts them into deterministic
plan ranges with optional scene annotations. Preparation injects the shared
structured LLM client into the chunker; `Plan`
supplies only the run-specific profile, session, source, references, and
metadata.
inclusive source-unit endpoints against document position and converts them
into deterministic plan ranges. Preparation injects the shared structured LLM
client into the chunker; `Plan` supplies only the run-specific profile, session,
source, references, and metadata.
Scene validation requires sequential, contiguous, non-overlapping coverage from
the first source unit through the last. Scene descriptions, boundaries,
confidence, and participants are module-owned annotations. Boundary caveats
become warnings. Malformed
structured output is returned as an error; there is no fallback chunker.
the first source unit through the last. Its private response contains only the
boundary endpoints; the accepted plan has no D&D-specific annotations and
produces no boundary warnings. Malformed structured output is returned as an
error; there is no fallback chunker.
The package embeds its prompt and response schema and reports their non-secret
identity and hashes through singleton module metadata. Shared D&D assets supply
@@ -262,6 +273,18 @@ integer source-unit candidates unchanged. Required semantic content, non-empty
evidence, and valid source ranges are rejected by the deterministic shape and
source-reference validators.
### `internal/modules/dnd/extract/scenedescriptions`
The scene-description extractor makes one structured completion for each
accepted chunk and maps its private `kind`, `title`, and `summary` response to
one `dnd.SceneDescription`. It assigns the current chunk ID and exact range,
preserves kind without repair, and trims only title and summary whitespace.
Optional players, party, and glossary references can disambiguate prompt terms
but do not supply evidence. The package owns its private schema, prompt assets,
and mapping fingerprint; deterministic validators own the durable semantic
checks. The durable contract is defined in the
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
### `internal/modules/dnd/extract/combatturns`
The combat extractor prepares one structured request per supplied chunk using
@@ -284,6 +307,28 @@ The combat normalizer accepts only the optional structured NPC registry.
Campaign references remain extractor-only LLM context and are not materialized
for deterministic normalization.
### `internal/modules/dnd/extract/npcinteractions`
The NPC interaction extractor requires the structured `npcs` registry slot. It
uses the registry's names-only prompt projection with shared extraction
evidence, identity, and transcript material, then maps private model records to
`dnd.NPCInteractionList` with the current source identity. Registry source
references are never reused as interaction evidence. The private response
schema carries only name, bounded interaction kind, and source-unit ranges;
deterministic validators own registry membership, source validity, and
relatedness. Extract-stage source validation additionally requires every cited
range to be wholly contained in the current materialized chunk. Prompt, schema,
mapping, and the names-only registry projection
participate in checkpoint identity, while generated producer identity remains
framework provenance.
The domain-owned `internal/modules/dnd/npcinteractions` package defines
canonical source-reference and occurrence ordering, valid-evidence eligibility,
and collision-safe exact identity. The interaction normalizer and normalized
invariants validator both consume those rules, so their production and checking
paths cannot drift. Normalizer and relatedness warning lists use the shared D&D
diagnostic cap and emit a final omission-summary warning when truncated.
### `internal/modules/dnd/normalize/npcs`
The NPC normalizer performs deterministic identity-aware consolidation in
@@ -344,6 +389,25 @@ digest/count metadata; generated identity is retained in framework provenance
and dependency fingerprints. The normalizer is included in the production D&D
registrar with the default combat normalization chain.
### `internal/modules/dnd/normalize/npcinteractions`
The interaction normalizer requires the same immutable NPC registry. It
canonicalizes exact registry-name matches, orders and de-duplicates source
references, stable-sorts occurrences by source-document position, and collapses
only exact interaction identities with valid evidence. It does not infer,
merge, or summarize distinct occurrences. Its metadata and fingerprints expose
the normalization and NPC identity policies; generated registry identity stays
in framework provenance and checkpoint dependencies.
### `internal/modules/dnd/normalize/scenedescriptions`
The scene-description normalizer has no options or references. It validates
each source range against the source document, trims title and summary
whitespace, orders records by source position then ID, removes only exactly
identical records, and rejects conflicting reused IDs or ranges. Its policy
fingerprint identifies this deterministic behavior; the matching invariant
validator checks the normalized result in the production chain.
## Output Encoder
### `internal/modules/generic/output/json`
@@ -353,6 +417,12 @@ safe logical names, pretty-prints JSON payloads, and assembles the logical index
manifest, rejected-result, warning, and lane files. Invalid JSON, unsupported
media types, unsafe names, and sanitized-name collisions are errors.
Its strict `include_chunk_map` option is disabled by default. When enabled, it
validates the framework-supplied accepted chunk map through its codec and adds
the pipeline-wide `chunk-map.json` plus its index descriptor; it does not treat
the map as a lane payload. The external shape is owned by the
[Accepted Chunk Map contract](../integrations/chunk-map.md).
The encoder returns logical files only. The CLI places them on disk, and the
[JSON output contract](../integrations/json-output.md) defines their external
paths and schemas.
@@ -428,6 +498,30 @@ expose local policy fingerprints. In the registered defaults, JSON syntax runs
first; combat shape, normalized invariants when applicable, and source-reference
validation precede JSON Schema validation; warning-only relatedness runs last.
## D&D NPC Interaction Validators
Interaction shape validation owns the required list, registry name, supported
kind, and non-empty source-reference collection. Registry validation checks
exact membership in the required immutable NPC registry. Source-reference and
relatedness validation use the current transcript only; malformed candidates
are deferred by later validators and produce no relatedness warning. The
normalized-invariants validator owns canonical registry names, source-reference
order, chronology, and exact duplicate identity. The production chains run
shape, registry, and source-reference checks before JSON Schema validation;
relatedness remains warning-only and last.
## D&D Scene Description Validators
Scene-description shape validation owns the non-empty list, trimmed ID and
prose, closed kind, and basic source-reference shape. Extract-stage source
validation additionally requires the one record to attach exactly to the
current accepted chunk; later source validation checks source membership.
Relatedness checks the title and summary independently against only their cited
transcript range and emits bounded advisory warnings. The normalized-invariants
validator owns ordering, exact duplicate elimination, and conflicting ID or
range detection. The production chains run shape and source-reference checks
before JSON Schema validation; the warning-only relatedness check is last.
## Production Registration
Production composition occurs through family registrars. The CLI allocates one
@@ -436,7 +530,7 @@ 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, typed spell, NPC, and combat default-validator
owns D&D leaf registrations, typed spell, NPC, combat, interaction, and scene-description default-validator
chains, typed append-order specializations, and D&D prompt/schema asset
collection. Its registration helpers group module, validator, prompt-asset, and
chain composition while retaining artifact-specific merge and clone behavior in

View File

@@ -50,6 +50,7 @@ required normalized artifacts have crossed the typed handoff.
| `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. |
| `internal/framework/checkpoint` | Root-based checkpoint loading, recording, identity, and payload serialization. |
| `internal/framework/chunkplan` | Source-addressed chunk-plan filesystem storage, envelope validation, and atomic publication. |
| `internal/framework/chunkmap` | Strict durable accepted chunk-map construction, schema, validation, cloning, and serialization. |
| `internal/framework/debug` | Root-based framework and LLM debug recording. |
Framework contracts provide typed artifact, provenance-wrapper, chunk-validator,
@@ -67,8 +68,10 @@ 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`, `dnd/npc-list`, and `dnd/combat-turn-list`
codecs, typed spell, NPC, and combat extractors and normalizers, validators,
the canonical `dnd/spell-list`, `dnd/npc-list`, `dnd/combat-turn-list`,
`dnd/npc-interaction-list`, and `dnd/scene-description-list` codecs, typed
spell, NPC, combat, interaction, and scene-description 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
@@ -87,15 +90,24 @@ Configuration. The implemented module packages are:
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, and combat-turn artifact types. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, combat-turn, NPC-interaction-list, and scene-description-list artifact types. |
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |
| `internal/modules/dnd/codec/npcinteractions` | Strictly decodes and stably encodes the durable D&D NPC-interaction-list representation. |
| `internal/modules/dnd/codec/scenedescriptions` | Strictly decodes and stably encodes the durable D&D scene-description-list representation. |
| `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. |
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
| `internal/modules/dnd/extract/scenedescriptions` | Maps one private scene description to the current accepted chunk's ID and exact range. |
| `internal/modules/dnd/npcinteractions` | Owns canonical source-reference ordering, occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/normalize/scenedescriptions` | Trims, source-orders, and removes only exactly identical scene descriptions while rejecting ID and range conflicts. |
| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat chains. |
| `internal/modules/dnd/validate/npcinteractions` | Provides deterministic shape, registry, source-reference, source-relatedness, and normalized-invariant validation for interaction chains. |
| `internal/modules/dnd/validate/scenedescriptions` | Provides deterministic shape, exact extraction attachment, source-relatedness, and normalized-invariant validation for scene-description chains. |
| `internal/modules/dnd/npcs/registry` | Resolves validated normalized NPC references into immutable grounding data and exact identity lookup. |
| `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. |
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
@@ -103,7 +115,7 @@ Configuration. The implemented module packages are:
| `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 canonical name, unions exact evidence, and reports bounded warnings. |
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. |
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, rejections, and an explicitly enabled accepted chunk map as logical JSON files. |
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
reference declarations, prompt input assembly, source-unit reference helpers,
@@ -116,14 +128,16 @@ The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
External references are strictly decoded and identity-validated during
preparation; generated references are decoded and identity-validated at the
ordered step handoff. Both paths retain canonical registry JSON for provenance
and emit a names-only projection to operation-time spell and combat prompts.
Combat normalization uses the canonical registry for exact actor lookup. The
and emit a names-only projection to operation-time spell, combat, and
interaction prompts. Combat and interaction normalization use the canonical
registry for exact name lookup. The
framework records generated identity and bounded producer provenance, while
the raw external reference remains independently tracked by pipeline
provenance. An absent registry is represented only by the empty prompt value
`{"npcs":[]}`. Spell
and combat consumers use this shared boundary without changing their public
module contracts.
module contracts. Interaction consumers require it and retain only current
transcript references as durable evidence.
Generic validators under `internal/modules/generic/validate` provide
unconditional test decisions, JSON syntax validation, and JSON Schema

View File

@@ -119,10 +119,10 @@ extractor's artifact kind, so variants under one reusable key may declare
different capabilities and reference slots. Kind-neutral registry inspection
selects the first registered artifact kind in sorted order.
Production composition registers the D&D spell-list codec and typed extractor,
matching typed merge, normalize, and semantic-validator variants, and
serialized JSON validators. Every artifact lane resolves through the typed
registries and a matching codec.
Production composition registers the D&D spell-list, NPC-list, combat-turn-list,
NPC-interaction-list, and scene-description-list codecs and typed lane
variants, plus serialized JSON validators. Every artifact lane resolves through
the typed registries and a matching codec.
A `ModuleSpec` declares its stage plus required and provided capabilities.
Chunk, extract, merge, and normalize specs may also declare reference slots.
@@ -208,15 +208,18 @@ The runner:
3. selects a stored plan or executes the configured chunker's `Plan` operation;
4. canonicalizes and materializes the plan, then validates the resulting
chunks;
5. executes each resolved step in configuration order. For one step, it
5. builds the framework-owned accepted chunk map from the accepted source,
logical plan, and exact materialized chunks, then supplies it to the output
request independently of output-module options;
6. executes each resolved step in configuration order. For one step, it
dispatches extract jobs in source-chunk then resolved-lane order, starts a
bounded lane continuation when all extracts for that lane are terminal, and
waits for every lane to become terminal;
6. encodes and validates each accepted normalized producer artifact, then
7. encodes and validates each accepted normalized producer artifact, then
builds the immutable generated reference sets for the next step;
7. invokes the prepared output encoder only after every step succeeds and
8. invokes the prepared output encoder only after every step succeeds and
validates its logical file results;
8. returns the assembled manifest, outcomes, warnings, and files.
9. returns the assembled manifest, outcomes, warnings, and files.
Within each artifact lane, it reuses the prepared extractor, merger, normalizer,
and validators while performing these transitions:
@@ -265,6 +268,14 @@ currently requested chunker and the effective plan producer. Cache state and
paths are configured and operated outside the runner; see
[Configuration](../config.md#state-surfaces) and [Operations](../operations.md).
For an accepted plan, the runner also constructs the strict framework-owned
[Accepted Chunk Map](../integrations/chunk-map.md) before lane execution. It
uses the current resolved chunker as `requested_chunker` and the stored or
generated record as `producer`, preserving that distinction on reuse. Chunk
rejection supplies no map; later lane rejection does not discard it. Output
encoders receive a defensively owned serialized value and may explicitly
ignore it.
The extract job channel has the same capacity as the effective extract worker
count, so dispatch applies backpressure. A fixed continuation executor prevents
ready or checkpoint-reused lanes from creating one goroutine each. Workers and

View File

@@ -17,6 +17,11 @@ debug roots.
The pipeline runner returns logical output files. After validating every
logical name, the CLI exclusively creates the run directory beneath the
selected output root and performs confined, atomic file writes within it.
The runner supplies an accepted chunk map as an optional, defensively owned
output-request artifact. The JSON encoder alone decides whether its explicit
option writes the map and optional index descriptor; neither the map payload
nor its annotations are copied into the run manifest. The durable fields are
owned by the [Accepted Chunk Map contract](../integrations/chunk-map.md).
`internal/framework/chunkplan` owns source-addressed plan storage, validation,
and atomic publication. Its store is constructed only when the selected mode is

View File

@@ -37,6 +37,12 @@ automatically removes output. The
[JSON output contract](integrations/json-output.md) owns the logical file
names, schemas, and media types inside a run directory.
An enabled JSON `include_chunk_map` option adds an accepted chunk map to durable
output. Its annotations may contain source- or model-derived data, so retain
and protect it like lane output. The map is opt-in and does not alter existing
bundles; its payload exclusions are defined in the
[Accepted Chunk Map contract](integrations/chunk-map.md).
Remove an output run directory only after its consumer data is no longer
needed. This is data deletion, not cache cleanup.

766
docs/roadmap/audit.md Normal file
View File

@@ -0,0 +1,766 @@
# D&D Extraction Module Refactoring Audit Strategy
Status: Strategy complete; final audit results recorded below
## Purpose
Define a disciplined audit of the five production D&D extraction modules:
- `dnd/spells`;
- `dnd/npcs`;
- `dnd/combat-turns`;
- `dnd/npc-interactions`; and
- `dnd/scene-descriptions`.
The audit will determine whether these modules follow a coherent set of
conventions, whether repeated implementation can be replaced by appropriately
scoped shared code or assets, and whether the accumulated design suggests other
maintainability improvements.
This document defines how to perform the audit. It does not contain audit
findings and does not authorize production changes.
## Audit Principles
The audit must distinguish consistency from uniformity. The five modules should
use the same conventions where they perform the same responsibility, but a
module should remain different when its artifact semantics, evidence model,
reference requirements, or normalization policy require it.
Recommendations must follow these principles:
- Prefer evidence from current code, tests, prompts, schemas, configuration,
and documentation over naming or visual similarity.
- Treat a divergence as a finding only when it is unexplained, increases
maintenance or correctness risk, or violates an intended convention.
- Do not recommend an abstraction solely to reduce line count. A shared helper
must own one coherent invariant and make future correct changes easier.
- Preserve package ownership of artifact semantics, private response DTOs,
private structured-output schemas, and lane-specific prompts.
- Keep D&D-specific behavior in D&D packages. Move behavior into a generic
framework package only when the contract is demonstrably domain-neutral and
has a non-D&D consumer or a clear framework-owned responsibility.
- Evaluate prompt sharing by byte identity and semantic ownership. Prompt
caching benefits only when repeated message content and ordering are exactly
identical.
- Apply the testing policy to proposed refactors. Prefer behavioral protection
at stable boundaries and do not add change-detector tests for helper usage,
prompt length, exact hashes, or private file layout.
## Scope
### Primary scope
Inspect the complete package-owned implementation beneath:
- `internal/modules/dnd/extract/spells`;
- `internal/modules/dnd/extract/npcs`;
- `internal/modules/dnd/extract/combatturns`;
- `internal/modules/dnd/extract/npcinteractions`; and
- `internal/modules/dnd/extract/scenedescriptions`.
For each package, include:
- module identity, capabilities, construction, options, registration, and
execution class;
- reference-slot declarations and construction-time or operation-time
reference handling;
- checkpoint fingerprints and manifest metadata;
- request validation and structured LLM request preparation;
- private response DTOs, response-schema loading, and response mapping;
- source-reference resolution, evidence attachment, canonicalization,
ordering, and exact deduplication;
- embedded prompt manifests, shared and local assets, message order, cache
boundaries, and schema assets;
- errors, warnings, diagnostics, cloning, and mutation safety; and
- package-local tests and test support.
### Contextual scope
Inspect a neighboring component only when needed to determine ownership,
duplication, or compatibility:
- `internal/modules/dnd/shared` and focused D&D subpackages used by more than
one extractor;
- the five artifact model and codec contracts;
- corresponding merge, normalize, and validate variants;
- production registration and default validator composition;
- pipeline reference, fingerprint, and LLM contracts;
- canonical current-behavior documentation and integration contracts; and
- representative production and integration tests.
Contextual inspection is not a request to redesign every lane stage. Findings
outside extraction should be reported only when they directly explain an
extractor inconsistency or reveal a misplaced responsibility.
### Exclusions
Do not use this audit to:
- change durable artifact schemas or extraction policy;
- redesign the fixed pipeline shape or ordered-step model;
- combine distinct artifacts into a larger D&D result;
- evaluate live-model output quality;
- introduce schema generation, a dependency-injection framework, or a general
module superclass;
- move domain rules into the generic framework;
- rewrite tests merely to make their file layout look alike; or
- implement any recommended refactor.
If the audit exposes a product-contract concern, record it separately from
refactoring recommendations and identify the additional decision required.
## Comparison Method
### 1. Establish a module inventory
Create one row per module in a working comparison matrix. Record exact current
facts rather than inferred conventions:
| Dimension | Facts to record |
| --- | --- |
| Identity | Module key, artifact kind, capabilities, execution class |
| Files | Production files, embedded assets, focused test files |
| Construction | Dependencies, options, reference decoding, immutable prepared state |
| Registration | `ModuleSpec`, builder, option validation, declared slots |
| Provenance | Manifest metadata and checkpoint fingerprint keys and values |
| Prompt | Prompt ID/version, manifest messages, inputs, shared assets, cache boundaries |
| Schema | Private schema identity, strictness, loader, diagnostics behavior |
| Execution | Request validation, LLM call, response mapping, errors and warnings |
| Evidence | Source identity, range resolution, canonicalization, ordering, deduplication |
| Tests | Contract owner, malformed cases, integration coverage, test-only helpers |
Use the matrix to identify exact agreement, intentional variation, and
unexplained variation. Do not infer a preferred convention from whichever
module was implemented first. Determine the preferred shape from architecture,
current documentation, shared contracts, and the clearest implementation.
### 2. Classify every divergence
Assign each observed difference one classification:
- **Required specialization:** the artifact or reference contract requires the
difference. No harmonization is recommended.
- **Permitted variation:** implementations differ without meaningful
maintenance or correctness cost.
- **Convention drift:** equivalent responsibilities use different names,
layouts, error behavior, metadata, validation, or tests without a reason.
- **Architectural divergence:** responsibility is placed in the wrong layer or
bypasses a shared contract.
- **Undetermined:** more evidence or a policy decision is needed.
For required specialization, document the reason briefly so a future audit
does not repeatedly flag it. For drift or architectural divergence, identify
the preferred convention and why it is preferable.
### 3. Build a duplication inventory
Search for three kinds of repetition:
1. **Exact duplication:** identical Go logic, prompt text, schema fragments, or
test support.
2. **Structural duplication:** the same algorithm or lifecycle expressed with
renamed domain types.
3. **Policy duplication:** the same invariant is independently encoded in
several production or test layers.
Trace callers and consumers before recommending extraction. Record:
- the repeated responsibility;
- participating modules;
- meaningful semantic differences;
- change history or likely change cadence when discoverable;
- defect risk if copies drift;
- proposed owner and API shape; and
- code or assets that would remain module-owned.
Similar code is not sufficient evidence. Prefer a shared abstraction when at
least one of the following is true:
- three or more modules independently implement the same nontrivial invariant;
- two modules share correctness-sensitive behavior that must evolve together;
- existing duplicated code has already drifted or caused a defect; or
- an existing shared contract is being reimplemented locally.
Avoid extraction when the shared API would need artifact-specific callbacks,
large configuration objects, type erasure, or branching on module identity.
Those are signs that visual similarity is masking separate responsibilities.
## Convention Review
Evaluate the following conventions across all five modules.
### Package organization
- Comparable responsibilities use predictable filenames and package-local
ownership.
- Optional specialized files, such as catalog or registry wiring, are present
only where the module has that responsibility.
- Exported identifiers are limited to framework and registration contracts.
- Test helpers remain local unless sharing them improves test clarity without
coupling independent suites.
### Construction and registration
- Required dependencies fail during construction.
- Options are decoded and unknown options rejected consistently.
- `ModuleSpec`, reference slots, artifact kind, execution class, and builder
behavior agree with runtime behavior.
- Construction resolves stable reference-derived state where possible, while
operation requests own genuinely run- or chunk-specific inputs.
- Returned specs, metadata, fingerprints, and byte slices have consistent
defensive-copy behavior.
### Prompt and schema boundary
- Shared prompt messages come from canonical shared assets; package assets
contain only lane-specific wording.
- Shared content is exactly identical across manifests and appears in the
documented cache-friendly order.
- Stable messages and cache boundaries precede the variable transcript.
- Declared prompt inputs match reference slots and generated projections.
- Private response schemas are strict structural envelopes and reject unknown
fields.
- Semantic validation remains in deterministic code at the intended boundary.
- Schema identity, version, digest, and diagnostics are exposed consistently
without leaking schema or prompt content.
### Extraction and evidence
- Request validation and contextual error wrapping follow one recognizable
pattern.
- Reference material aids disambiguation but never becomes transcript evidence.
- Source unit IDs are resolved against document order rather than numeric
assumptions.
- Mapping performs only the deterministic transformations owned by extraction.
- Canonical source references, stable ordering, and exact deduplication use
consistent policies where artifact semantics agree.
- Whole-chunk evidence in scene descriptions is treated as an intentional
specialization rather than forced through citation-oriented helpers.
- Results do not share mutable state with model responses, references, or
requests.
### Provenance and diagnostics
- Prompt and response-schema identities and hashes appear consistently in
manifest metadata.
- Checkpoint fingerprints cover every stable semantic input that could change
accepted output, without including credentials, paths, timestamps, or source
content.
- Registry and catalog projections use canonical bytes and retain bounded
provenance.
- Errors and diagnostics are contextual, bounded, and free of raw reference or
secret content.
### Tests
- Each important contract has one clear test owner.
- Equivalent risks receive comparable coverage without requiring identical test
file layouts.
- Schema tests cover required structure and unknown-field rejection without
duplicating every semantic validator case.
- Extractor tests cover request validation, response mapping, evidence,
ordering, references, and provider failures at the narrowest stable boundary.
- Prompt tests prepare real embedded assets and protect input placement,
ordering, cache boundaries, and content-safety properties without freezing
exact prompt text.
- Registration or production tests prove assembly once and do not repeat
package-local behavior unnecessarily.
- Obsolete, redundant, representation-specific, or impossible fixtures are
identified for deletion or simplification.
## Shared-Code Decision Framework
Recommend the narrowest owner that matches the repeated responsibility:
1. Keep artifact semantics in the module that owns the artifact.
2. Use `internal/modules/dnd/shared` for D&D-wide mechanics with identical
semantics, such as prompt inputs or source-unit reference handling.
3. Use a focused D&D subpackage for behavior shared by a subset of lanes, such
as NPC registry projection, when it has a coherent domain contract.
4. Use a framework package only for transport-neutral or domain-neutral
behavior owned by the framework.
Evaluate these candidate categories without assuming they should be extracted:
- extractor request precondition validation;
- private response-schema loading and metadata assembly;
- prompt asset registration and hashing;
- source-reference conversion, canonical ordering, and exact deduplication;
- manifest metadata and checkpoint fingerprint assembly;
- NPC registry resolution and names-only projection;
- strict option decoding and module registration;
- diagnostic redaction and bounded error context; and
- repeated test fixtures or schema-validation utilities.
For prompts and schemas:
- Factor prompt wording into a shared asset only when every consumer needs the
exact same text and should receive future changes atomically.
- Prefer an existing shared asset over a new near-duplicate.
- Do not create shared prompt fragments solely because prose is similar.
- Keep private response schemas package-owned unless a genuine shared wire
contract exists.
- Do not introduce shared JSON Schema fragments or generation unless the audit
demonstrates a maintenance problem that outweighs tooling and indirection.
## Additional Quality Review
Beyond consistency and duplication, inspect:
- functions with high cognitive complexity or responsibilities that can be
separated without obscuring the extraction flow;
- repeated linear scans inside loops, avoidable serialization, unnecessary
allocations, or per-chunk reconstruction of stable state;
- hidden mutation, aliasing, or inconsistent clone boundaries;
- fingerprint omissions that could permit stale checkpoint reuse;
- prompt inputs or references repeated unnecessarily across messages;
- unreachable defensive checks or validation performed redundantly at several
layers;
- errors that lose module, field, source, or stage context;
- stale documentation, fixtures, names, compatibility aliases, or comments;
- public or package abstractions that have only one artificial consumer; and
- opportunities to delete code after a shared helper replaces it.
Performance recommendations must identify a plausible workload and complexity
impact. Do not recommend micro-optimization without evidence.
## Evidence Collection
Perform the audit in this order:
1. Read the architecture, testing, documentation, module, LLM, pipeline, and
relevant integration contracts.
2. Build the five-module comparison matrix from definitions and assets.
3. Use graph similarity only to identify candidates; read the complete
functions and trace their callers before classifying them.
4. Compare prompt manifests and shared assets byte-for-byte, then compare local
prompt semantics.
5. Compare private schemas structurally and map each field to its DTO, mapper,
durable artifact, and validator owner.
6. Trace reference and fingerprint data from construction through the LLM
request and checkpoint identity.
7. Review focused tests alongside the behavior they own.
8. Run the existing focused and repository-wide validation commands to
distinguish current failures from maintainability observations.
Do not modify production code, tests, prompts, schemas, examples, or
current-behavior documentation during the audit.
## Finding Standard
Every reported finding must contain:
- severity: high, medium, or low;
- category: convention drift, duplication, architecture, correctness,
performance, testing, or documentation;
- affected modules and exact file or symbol references;
- observed behavior and the convention or invariant it is compared against;
- concrete maintenance, correctness, cost, or security impact;
- recommended target state and ownership;
- why the recommendation is preferable to leaving the code separate; and
- validation or migration considerations.
Order findings by severity and impact, not by module. Separate confirmed
findings from optional improvements. State explicitly when no issue is found in
a comparison area.
For each apparent duplication, the audit must choose one outcome:
- extract now;
- harmonize without sharing;
- retain intentionally separate; or
- defer pending a named missing requirement.
Do not report speculative abstractions as findings. Record them, if useful, as
rejected or deferred candidates with the reason.
## Audit Deliverable
The completed audit should provide:
1. an executive conclusion addressing convention consistency, shared-code
opportunities, and overall code quality;
2. the completed five-module comparison matrix;
3. prioritized findings with evidence and recommendations;
4. intentional differences that should be preserved;
5. rejected or deferred sharing candidates and rationale;
6. a proposed refactoring sequence grouped into independently safe changes;
and
7. validation commands and any residual risks.
The audit should be actionable enough to support a later decision-complete
implementation plan, but it must not implement or silently commit any
recommendation.
## Completion Criteria
The strategy has been followed when:
- all five primary packages and their prompts, schemas, registration,
provenance, reference handling, mapping, and tests have been compared;
- every divergence has a classification;
- every repeated candidate has an ownership and keep/share decision;
- architecture and testing-policy constraints are applied explicitly;
- findings cite exact evidence and explain impact;
- intentional specialization is documented alongside drift;
- repository validation results are recorded; and
- no production changes were made as part of the audit.
## Audit Results
Status: Complete
### Executive Conclusion
The five D&D extraction modules follow a coherent overall convention: all are
typed, production-registered extractors with strict private response schemas,
package-owned artifact mapping, bounded provenance, shared canonical prompt
assets, defensive result ownership, and deterministic downstream validation.
Their differences in catalog/registry state, reference slots, local prompt
assets, whole-chunk versus cited evidence, and scene prose cleanup are explained
by artifact semantics and should remain.
The consistency question is therefore **mostly yes, with two material
exceptions**. Spell artifact ordering and all four citation extractors'
reference ordering assume numeric unit IDs instead of source-document order,
and spell/NPC checkpoint identities omit stable mapping policies. Both can
change durable results or reuse results produced under different semantics.
Smaller drift exists in common request preflight, scene semantic-validation
ownership, prompt cache-boundary coverage, and package surface conventions.
The shared-code question is **yes, but only for two narrow D&D-wide
responsibilities**: common chunk-extraction preflight and document-aware
source-reference ordering/canonicalization. They belong in
`internal/modules/dnd/shared`, not in the framework. Private response DTOs,
artifact mapping, structured LLM calls, prompt prose, response schemas,
metadata assembly, error prefixes, and typed test fakes should remain
package-owned; sharing them would require callbacks, type erasure, module
branching, or broad configuration.
Overall code quality is **good**. Construction, registration, cloning,
diagnostics, schema loading, prompt composition, and validator composition are
clear and consistently tested. The repository passes all current validation.
The recommended work is targeted correction and consolidation, not a redesign
of the extractor family.
### Final Module Comparison Matrix
| Module | Common contract | Required specialization | Prompt/schema boundary | Evidence and ordering | Provenance and tests |
| --- | --- | --- | --- | --- | --- |
| Spells | `dnd/spells` -> `dnd/spell-list`; typed LLM extractor; strict private DTO/schema; append merge and typed normalize/validate ([spec](../../internal/modules/dnd/extract/spells/extractor.go#L207)) | Prepared spell catalog and optional NPC registry projection; catalog prompt input ([constructor](../../internal/modules/dnd/extract/spells/extractor.go#L73)) | Shared evidence/input messages plus local task, instructions, catalog, and transcript; semantic catalog validation remains deterministic ([manifest](../../internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml#L23)) | Attaches current transcript ID and preserves invalid candidates, but sorts artifacts/references by numeric unit ID rather than document position ([canonicalization](../../internal/modules/dnd/extract/spells/canonicalize.go#L10)) | Prompt/schema/catalog/NPC projection fingerprints, but no mapping-policy fingerprint; broad extractor tests, incomplete full prompt cache-order assertion ([fingerprints](../../internal/modules/dnd/extract/spells/extractor.go#L144)) |
| NPCs | `dnd/npcs` -> `dnd/npc-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/npcs/extractor.go#L159)) | Deterministic NPC identity derivation; campaign references remain request context ([mapping](../../internal/modules/dnd/extract/npcs/canonicalize.go#L94)) | Shared evidence/input messages plus NPC-local task/instructions/transcript; private schema owns transport only ([manifest](../../internal/modules/dnd/extract/npcs/assets/prompts/dnd.npcs.yaml#L17)) | Artifact order resolves document positions; reference list still sorts numeric IDs before exact deduplication ([canonicalization](../../internal/modules/dnd/extract/npcs/canonicalize.go#L11)) | Prompt/schema/identity fingerprints, but no mapping-policy fingerprint; weakest focused preflight matrix and partial prompt ordering coverage ([fingerprints](../../internal/modules/dnd/extract/npcs/extractor.go#L91)) |
| Combat turns | `dnd/combat-turns` -> `dnd/combat-turn-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/combatturns/extractor.go#L201)) | Optional prepared NPC registry; all combat kinds remain raw validator candidates ([constructor](../../internal/modules/dnd/extract/combatturns/extractor.go#L68)) | Shared evidence/NPC/input messages plus combat-local task/instructions/transcript ([manifest](../../internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml#L20)) | Artifact order uses valid source positions; extractor, normalizer, and invariant validator duplicate differing reference-order mechanics ([extractor](../../internal/modules/dnd/extract/combatturns/canonicalize.go#L10), [normalizer](../../internal/modules/dnd/normalize/combatturns/normalizer.go#L228)) | Mapping policy is fingerprinted; complete request preconditions, mapping, registry, and provider tests, but incomplete full prompt cache-order assertion ([fingerprints](../../internal/modules/dnd/extract/combatturns/extractor.go#L126)) |
| NPC interactions | `dnd/npc-interactions` -> `dnd/npc-interaction-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L203)) | Required NPC registry and names-only projection; exact identity remains in focused domain package ([constructor](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L68)) | Shared evidence/NPC/input messages plus interaction-local task/instructions/transcript ([manifest](../../internal/modules/dnd/extract/npcinteractions/assets/prompts/dnd.npc_interactions.yaml#L20)) | Extractor references sort numeric IDs, while the interaction model already owns correct document-aware canonicalization at too-narrow a layer ([extractor](../../internal/modules/dnd/extract/npcinteractions/canonicalize.go#L10), [model helper](../../internal/modules/dnd/npcinteractions/canonical.go#L17)) | Mapping, identity, prompt, schema, and registry projection are fingerprinted; focused suite omits nil-receiver and full prompt cache-order cases ([fingerprints](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L126)) |
| Scene descriptions | `dnd/scene-descriptions` -> `dnd/scene-description-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L174)) | One summary per chunk, whole-chunk evidence, and fingerprinted prose trimming; no catalog/NPC projection ([mapping](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L150)) | Local task/instructions plus shared input/transcript; schema currently duplicates semantic enum/non-empty policy owned by shape validation ([schema](../../internal/modules/dnd/extract/scenedescriptions/assets/schemas/dnd_scene_descriptions_llm.v1.json#L8), [validator](../../internal/modules/dnd/validate/scenedescriptions/shape/validator.go#L49)) | Whole materialized chunk becomes one source range; citation ordering is not applicable | Mapping policy is fingerprinted; strongest full prompt order/cache test, but preflight omits nil receiver/context cases ([fingerprints](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L98), [prompt test](../../internal/modules/dnd/extract/scenedescriptions/scriptorium_assets_test.go#L15)) |
All five reject unknown options, register through the production D&D registrar,
return independently owned results, and use package-local structured response
types. No inconsistent secret handling, raw prompt/schema diagnostic exposure,
hidden result aliasing, or unregistered audited extractor was found.
### Prioritized Findings
#### High
1. **Source references and spell artifacts can be durably ordered contrary to
transcript order.**
- **Category:** correctness and architecture.
- **Affected modules:** spell, NPC, combat-turn, and NPC-interaction
extractors; related spell/combat/NPC normalizers and combat invariants.
- **Evidence:** spell selects the smallest positive numeric unit ID
([`earliestSourceUnit`](../../internal/modules/dnd/extract/spells/canonicalize.go#L62));
all four citation extractors sort reference endpoints numerically
([spell](../../internal/modules/dnd/extract/spells/canonicalize.go#L30),
[NPC](../../internal/modules/dnd/extract/npcs/canonicalize.go#L31),
[combat](../../internal/modules/dnd/extract/combatturns/canonicalize.go#L30),
[interaction](../../internal/modules/dnd/extract/npcinteractions/canonicalize.go#L30)).
Valid source documents require unique positive IDs, not monotonically
increasing IDs
([`ValidateDocument`](../../internal/core/source/validation.go#L8)).
The interaction model demonstrates the correct document-aware comparison
([`SourceRefLess`](../../internal/modules/dnd/npcinteractions/canonical.go#L51)).
- **Impact:** valid evidence can be reordered away from transcript order;
spell casts with invalid or later evidence can precede earlier valid
casts. This changes durable list order, evidence presentation, merge input,
and checkpointed results.
- **Target state and owner:** move a document-backed `SourceRefOrder` to
`internal/modules/dnd/shared`, with `EarliestValid` and `Canonicalize`
operations. Preserve invalid candidates, exact deduplication, stable ties,
nil/empty distinction, cloning, and deterministic invalid fallback.
Artifact comparison, DTO conversion, source-ID attachment, and repair
accounting remain local.
- **Why shared:** the rule is a D&D-wide evidence invariant already
implemented by extract, normalize, and validate consumers; continued
copies have already diverged. A shared position index also reduces repeated
`UnitIndex` scans from approximately `O(A log A * R * U)` ordering work to
`O(U + A*R + A log A)` for `A` artifacts, `R` references, and `U` units.
- **Migration/validation:** add non-monotonic, invalid, duplicate, nil/empty,
aliasing, stable-tie, and repair-count tests; migrate the interaction model
and combat normalize/invariant pair before extractors; add/bump mapping
policy fingerprints so old checkpoints miss intentionally.
2. **Spell and NPC checkpoint identities omit stable mapping policies.**
- **Category:** correctness and data integrity.
- **Affected modules:** spell and NPC extractors.
- **Evidence:** both extractors perform deterministic ordering,
canonicalization, source attachment, and mapping
([spell mapping](../../internal/modules/dnd/extract/spells/canonicalize.go#L10),
[NPC mapping](../../internal/modules/dnd/extract/npcs/canonicalize.go#L11)),
but their fingerprint providers name catalog/projection or identity
policies without a mapping policy
([spell fingerprints](../../internal/modules/dnd/extract/spells/extractor.go#L144),
[NPC fingerprints](../../internal/modules/dnd/extract/npcs/extractor.go#L91)).
Prepared fingerprints are lane-scoped
([collector](../../internal/framework/pipeline/prepared_fingerprints.go#L23))
and restore requires exact normalized equality
([comparison](../../internal/framework/checkpoint/loader.go#L301)).
- **Impact:** a mapping-policy code change can reuse a checkpoint produced
under older artifact/evidence semantics when prompt and schema bytes are
unchanged.
- **Target state and owner:** each extractor owns an explicit stable
mapping/canonicalization policy fingerprint. Metadata assembly stays
package-local.
- **Why preferable:** a local named semantic fingerprint directly closes
the reuse gap; a generic metadata builder would only hide module-specific
omissions behind configuration.
- **Migration/validation:** add provider and prepared-checkpoint restore
tests. Adding a fingerprint safely invalidates existing identities by full
list mismatch; no artifact payload migration is required.
#### Medium
3. **Common extraction preflight is copied across five production callers and
has already drifted in test protection.**
- **Category:** duplication and testing.
- **Affected modules:** all five extractors.
- **Evidence:** every `Extract` validates the same context/source/chunk/unit
prerequisites before calling
[`ChunkPromptMaterial`](../../internal/modules/dnd/shared/extraction_inputs.go#L12),
but focused coverage ranges from a complete spell/combat matrix to only
cancellation and source mismatch for NPCs
([spell tests](../../internal/modules/dnd/extract/spells/extractor_test.go#L235),
[NPC tests](../../internal/modules/dnd/extract/npcs/extractor_test.go#L139)).
- **Impact:** validation order, error behavior, or a newly required common
precondition can diverge silently among modules.
- **Target state and owner:** add
`shared.PrepareChunkExtraction(ctx, req) (contracts.LLMInputMaterial,
error)` for cancellation, non-nil source/chunk, non-empty units, and
matching cloned material. Receiver/client checks, specialized references,
provider calls, typed results, and contextual wrapping remain local.
- **Why shared:** this extends the existing common material boundary with
one coherent invariant and serves five real callers without callbacks or
module configuration.
- **Migration/validation:** shared table tests own common inputs; package
tests retain nil receiver/dependency, specialized reference, wrapped
error, and provider cases. Preserve current validation order and useful
package context.
4. **Scene semantic validity has two production owners.**
- **Category:** architecture.
- **Affected module:** scene descriptions.
- **Evidence:** the private response schema enforces the scene-kind enum and
non-empty title/summary
([schema](../../internal/modules/dnd/extract/scenedescriptions/assets/schemas/dnd_scene_descriptions_llm.v1.json#L8));
deterministic shape validation independently enforces the same policy
([validator](../../internal/modules/dnd/validate/scenedescriptions/shape/validator.go#L49)).
The documented schema boundary assigns semantic enum/non-empty rules to
deterministic validators
([LLM internals](../internal/llm.md#L169)).
- **Impact:** the two policies can drift and produce provider-dependent
rejection before typed validation, while non-LLM artifacts see only the
validator.
- **Target state and owner:** retain JSON type, required/nullability, and
unknown-field constraints in the package-private schema; make the scene
shape validator the sole semantic owner.
- **Why harmonize without sharing:** only scene has this duplicated policy;
moving it to the existing validator removes an owner without inventing an
abstraction.
- **Migration/validation:** update schema structural tests and validator
semantic tests, then expect the schema digest/checkpoint identity to
change. Verify provider-decoded invalid candidates reach deterministic
validation.
#### Low
5. **Four prompt suites do not fully protect the documented message-order and
cache-boundary contract.**
- **Category:** testing.
- **Affected modules:** spells, NPCs, combat turns, and NPC interactions.
- **Evidence:** scene descriptions asserts the complete prepared role order
and ephemeral/no-cache placement
([test](../../internal/modules/dnd/extract/scenedescriptions/scriptorium_assets_test.go#L15));
the four citation suites cover registration and selected inputs but not
the full documented sequence
([spell tests](../../internal/modules/dnd/extract/spells/scriptorium_assets_test.go),
[NPC tests](../../internal/modules/dnd/extract/npcs/scriptorium_assets_test.go),
[combat tests](../../internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go),
[interaction tests](../../internal/modules/dnd/extract/npcinteractions/scriptorium_assets_test.go)).
- **Impact:** manifest edits can move variable content into a cacheable
prefix or reorder stable grounding without a focused failure, increasing
request cost or reducing prompt quality.
- **Target state and owner:** each package test should assert its complete
documented prepared sequence and cache flags using the real registry.
- **Why local:** lane inputs differ and the behavior belongs to each prompt
manifest; shared test setup would obscure the boundary.
- **Migration/validation:** assert roles/input identities/cache flags, not
exact prompt text, byte counts, or hashes.
6. **Small exported-surface and defensive-behavior drift remains.**
- **Category:** convention drift.
- **Affected modules:** spells, combat turns, and D&D shared.
- **Evidence:** spell and combat export unused singular `ArtifactType`
constants while production uses typed `ArtifactKind`
([spell](../../internal/modules/dnd/extract/spells/extractor.go#L17),
[combat](../../internal/modules/dnd/extract/combatturns/extractor.go#L17));
spell `ManifestMetadata` lacks the nil guard used by the other four
([spell](../../internal/modules/dnd/extract/spells/extractor.go#L122),
[NPC example](../../internal/modules/dnd/extract/npcs/extractor.go#L74));
[`SourceRefCandidate`](../../internal/modules/dnd/shared/unit_refs.go#L90)
has only a test caller, ignores its document parameter, and is unsafe for
extractor provenance because it trusts model-supplied source identity.
- **Impact:** the package surface presents competing artifact vocabulary,
zero-value behavior is inconsistent, and an artificial shared API invites
incorrect reuse. Immediate runtime impact is limited.
- **Target state and owner:** delete the unused constants; align spell nil
metadata behavior locally; delete `SourceRefCandidate` when the correct
shared reference API lands.
- **Why preferable:** deletion and local harmonization clarify existing
contracts without adding a helper.
- **Migration/validation:** graph search found no production consumers;
compile all internal packages and add one spell zero-value metadata test.
#### Optional improvement
The current module documentation lists only four production consumers of
`ChunkPromptMaterial`, omitting scene descriptions
([documentation](../internal/modules.md#L67)). Correct that list when the shared
preflight boundary is documented. This is localized documentation maintenance,
not a separate production design finding.
No actionable issue was found in option strictness, production registration,
typed codec selection, result cloning, prompt/schema content redaction, local
prompt semantic ownership, catalog/NPC source-evidence separation, provider
error wrapping, or secret handling.
### Intentional Differences To Preserve
- **Prepared specialized state:** spells retains a catalog and optional NPC
resolver; combat retains an optional NPC resolver; interactions requires one;
NPC and scene extraction need only request material. Construction-time
identity belongs only where the artifact uses it.
- **Reference slots:** catalog and NPC registry slots follow grounding needs.
The interaction registry is required; combat/spell registry use is optional.
Campaign references remain prompt context and never become evidence.
- **Evidence model:** scene descriptions cites the whole materialized chunk;
forcing it through citation DTO/canonicalization machinery would weaken its
one-summary-per-chunk contract.
- **Prompt assets:** only byte-identical canonical messages are shared. Local
task, instruction, catalog, identity, and evidence wording changes with the
artifact and must not be coupled.
- **Private schemas and DTOs:** similar `source_refs` fragments do not form a
separately versioned wire contract. Package ownership keeps response changes
aligned with mapping and diagnostics.
- **Prose handling:** scene title/summary trimming is a deliberate fingerprinted
mapping policy; citation-lane names and enum candidates remain raw for their
normalizers and validators.
- **Test organization:** file layout, typed fakes, schema compilation helpers,
and real-registry prompt setup remain package-local. Equivalent risks need
comparable coverage, not identical fixtures or filenames.
### Shared-Code Decisions
| Decision | Owner and scope | Why this is the narrow correct boundary |
| --- | --- | --- |
| Extract common preflight | `internal/modules/dnd/shared`; validate common typed extraction request state and return cloned matching `LLMInputMaterial` | Five current callers repeat one prerequisite to the existing `ChunkPromptMaterial` boundary. Receiver/dependency/specialized checks remain readable and local. |
| Extract source-reference order | `internal/modules/dnd/shared`; document-backed `SourceRefOrder.EarliestValid` and `.Canonicalize` over durable `[]source.SourceRef` | Multiple extract, normalize, model, and validate consumers must evolve together; the rule is D&D-wide but has no demonstrated non-D&D/framework consumer. |
| Harmonize mapping fingerprints | Spell and NPC extractor packages | The missing values are module-semantic; local named fingerprints are safer than a configurable metadata builder. |
| Harmonize scene validation ownership | Private scene schema plus `validate/scenedescriptions/shape` | Remove semantic keywords from the transport schema and keep the already registered durable validator as sole owner. No sharing is needed. |
| Harmonize prompt contract tests | Four citation extractor test suites | Each manifest owns its role/input/cache sequence; consistent assertions should stay beside distinct assets. |
| Harmonize preflight coverage | All five package suites after shared preflight | Shared tests own common branches; local tests retain package-visible context and specializations. |
| Harmonize package hygiene | Spell/combat extractors and D&D shared | Delete unused exports/artificial API and align spell metadata nil behavior without introducing a new abstraction. |
### Rejected Or Deferred Candidates
| Candidate | Decision and rationale |
| --- | --- |
| Deprecated `roster` slot asymmetry | **Deferred pending a product compatibility decision.** Configuration documents `roster` as a deprecated `party` alias ([config](../config.md#L526)), while scene removes it. Choose a removal release or uniform alias lifetime before changing slots and migration guidance. |
| Spell response-schema key/ID naming | **Deferred pending a provenance migration decision.** The spell names differ from the `_llm`/`.llm` convention, but schema identity is persisted metadata. Renaming needs an alias or an explicit manifest/checkpoint compatibility break. |
| Prompt registration/hash wrapper | **Rejected.** `shared.PromptAssetManifest` already owns coherent composition/hashing. Hiding filesystems, registries, `sync.Once`, and diagnostic nouns would require broad configuration. |
| Manifest/fingerprint builder | **Rejected.** Stable inputs are module-specific; a generic builder would accept the same keys/policies as arguments and could conceal omissions such as the current spell/NPC gap. |
| Generic artifact mapper | **Rejected.** Private DTO fields, identity, enums, and artifact types require callbacks, type erasure, or module branching. Only reference ordering is genuinely common. |
| Generic structured-LLM call wrapper | **Rejected.** Generic output and request configuration would hide an already clear framework client boundary without removing semantic work. |
| Shared error-prefix helper | **Rejected.** It would add a module-name parameter to replace three transparent lines and weaken local diagnostic ownership. |
| Shared response schemas or prompt prose | **Retain intentionally separate.** Similar structure/text is not an atomic shared contract; private schema and lane prompt changes should not propagate together. |
| Shared schema-test or provider-fake utilities | **Retain intentionally separate.** Typed DTOs and package assets are the behavior under test; central fixtures would couple suites and obscure failures. |
| Framework-level preflight/reference API | **Rejected.** No non-D&D consumer or framework-owned invariant was found. The D&D shared layer preserves dependency direction and domain ownership. |
### Recommended Refactoring Sequence
1. **Land checkpoint identity protection independently.** Add spell and NPC
mapping-policy fingerprints with provider and checkpoint-restore tests.
This is a small package-local change and protects all later semantic
migrations from stale reuse.
2. **Consolidate source-reference mechanics.** Add and exhaustively test the
D&D shared document-position index/API. Migrate the existing interaction
model helper first, then combat normalize plus invariants, then NPC/spell
normalizers, and finally the four citation extractors. Keep package mapping
and repair accounting local; remove numeric comparators and the unused
`SourceRefCandidate` only after all consumers move.
3. **Consolidate common extraction preflight.** Extend the existing shared
chunk-material boundary, migrate one extractor to establish error/validation
compatibility, then migrate the remaining four. Move common branch tables
to shared tests and retain package-context smoke tests.
4. **Resolve scene validation ownership independently.** Adjust schema tests,
remove semantic enum/non-empty constraints from the private schema, retain
validator cases, and verify the expected schema fingerprint/checkpoint miss.
5. **Close prompt contract coverage.** Add full role/input/cache-boundary
assertions to each citation prompt suite without shared fixtures or exact
text/hash assertions.
6. **Apply low-risk package/documentation cleanup.** Remove unused
`ArtifactType` constants, add the spell metadata nil guard/test, and update
module documentation for the shared helper consumers and boundaries.
Each scope can be reviewed and reverted independently. The sequence is not a
decision-complete implementation plan; implementation should still pin exact
fingerprint values, exported names, error compatibility, and per-package test
cases.
### Validation And Residual Risks
Final validation on 2026-07-24:
```text
go test -count=1 ./... PASS
go vet ./... PASS
go build ./cmd/notarius PASS
gofmt -l . PASS (no files listed)
git diff --check PASS
git diff --no-index --check /dev/null docs/roadmap/audit.md
PASS (no whitespace errors; exit 1 denotes differences)
```
The audit also checked that cited relative paths exist in the current working
tree. The audit changed no production code, tests, prompts, schemas, examples,
or current-behavior documentation.
Residual risks and limits:
- This was a static and deterministic-test audit; it did not evaluate live
model extraction quality, prompt effectiveness, provider cache-hit rates, or
token cost. Prompt changes still need representative human/model evaluation.
- Existing tests use mostly monotonic unit IDs, so the ordering defect is not a
current failing test. The proposed non-monotonic fixtures are required before
changing behavior.
- The exact durable order expected for invalid references must remain
deterministic and diagnostics-friendly during API design; invalid references
must not be discarded merely because default validators usually reject them
later.
- Mapping and schema fingerprint additions intentionally invalidate prior
checkpoint identities. Operators should be told to expect recomputation; no
serialized artifact migration is otherwise indicated.
- The `roster` alias lifetime and spell schema-identity migration remain human
product/compatibility decisions.
- Passing repository checks establishes current deterministic correctness, not
absence of model-quality regressions or correctness under unrepresented
source-document shapes.

View File

@@ -0,0 +1,200 @@
# D&D NPC Interactions
Status: Implemented
The current durable and configuration contract is documented in the
[D&D NPC interaction artifact](../integrations/dnd-npc-interaction-artifacts.md).
## 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.

View File

@@ -16,28 +16,18 @@ not as committed release dates.
validator, and normalizer development. Treat model-quality review as an
iterative human evaluation aid, not a deterministic correctness gate.
### Expand Sequential D&D Artifacts
### Use D&D Scene Chunking
- Add narrative extraction for scene summaries, party actions, and NPCs
encountered when that output proves useful beyond the dedicated NPC artifact.
- Use ordered pipeline steps when a later artifact needs an accepted earlier
artifact as context. Keep independent lanes in the same step and do not
introduce a general DAG or concurrent cross-lane reconciliation model.
### Improve D&D Scene Classification
- Extend scene annotations with classifications that downstream extractors can
use, including reliable combat and narrative indicators.
- Strengthen the scene prompt so every scene containing combat turns is marked
as combat, and add validation capable of detecting missing or inconsistent
combat classifications.
- Allow the combat extractor to no-op for chunks that are not classified as
combat, avoiding unnecessary model calls where practical.
- Allow a narrative extractor to select the corresponding scene classification
rather than processing every chunk indiscriminately.
- Allow the combat extractor to no-op for chunks classified as non-combat only
after the scene-description artifact can be supplied through an explicit
ordered dependency. Do not make generic chunk materialization depend on a D&D
classification.
- Use ordered pipeline steps whenever a later artifact needs an accepted
earlier artifact as context. Keep independent lanes in the same step and do
not introduce a general DAG or concurrent cross-lane reconciliation model.
- Reassess whether one shared scene plan provides enough context for NPC,
spell, combat, and narrative pipelines after these extractors have real-world
usage. Add more complex chunking only in response to demonstrated failures.
spell, combat, interaction, and scene-description lanes after real-world use.
Add more complex chunking only in response to demonstrated failures.
## Shared Normalization And Quality Work

View File

@@ -1,373 +1,425 @@
# Minimal D&D Extraction Contracts Implementation Plan
# D&D Extraction Module Audit Execution Plan
**Status:** Implemented
Status: Ready for audit execution
## Objective
This document divides the audit defined in
[D&D Extraction Module Refactoring Audit Strategy](audit.md) into five prompts.
Execute the stages in order. Each stage is a read-only code audit: it may update
`docs/roadmap/audit.md`, but it must not change production code, tests, prompts,
schemas, examples, current-behavior documentation, or configuration.
Implement the durable contract redesign defined by
[Minimal D&D Extraction Contracts](minimal-dnd-extraction-contracts.md) and
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md).
The result is a coordinated in-place redesign of NPC, spell-cast, and combat-turn
extraction. Each lane must emit only its narrow, evidence-grounded facts; no
removed rich-schema field may survive as an optional field, placeholder, compatibility
shim, or deterministic synthesis.
The word "implementation" in this filename refers to implementing the audit
strategy, not implementing refactors.
This plan deliberately starts with NPCs because the NPC registry is a generated
reference consumed by spell and combat extraction. Spell and combat then change
independently, followed by one repository-wide contract and documentation
pass.
## Global Instructions
## Fixed Decisions And Guardrails
Every stage must:
The implementing agent must treat these as decisions, not open design choices:
1. Read `docs/roadmap/audit.md` in full before beginning.
2. Follow `AGENTS.md`, `docs/development.md`, and all policies under
`docs/policy/`.
3. Prefer the codebase knowledge graph for code discovery and call tracing.
Use direct file and text searches for prompt assets, JSON Schemas,
configuration, documentation, and exact string comparison.
4. Inspect all five extractors where the stage calls for comparison:
`spells`, `npcs`, `combatturns`, `npcinteractions`, and
`scenedescriptions`.
5. Cite exact files and symbols for every observation written to `audit.md`.
6. Distinguish confirmed facts, provisional interpretations, intentional
specialization, and actionable findings.
7. Avoid treating visual similarity, file count, or line count as sufficient
evidence for sharing.
8. Preserve findings from earlier stages unless new evidence disproves them.
When revising an earlier conclusion, edit it in place and record the reason;
do not append contradictory conclusions.
9. Keep implementation out of scope. Do not modify code to test whether a
proposed refactor is convenient.
10. Leave the repository otherwise unchanged and report any pre-existing dirty
worktree state before proceeding.
- Keep the artifact kinds `dnd/npc-list`, `dnd/spell-list`, and
`dnd/combat-turn-list`, their module keys and capabilities, their media types,
and their prompt IDs stable.
- Retain exactly the existing v1 schema keys, IDs, names, versions, filenames,
prompt IDs, and prompt versions listed in the feature roadmap. Change their
unpublished shapes and content in place; do not harmonize the private spell
identity as part of this work.
- Do not add v2 assets, runtime version negotiation, migration code, dual-write
behavior, a second module registration, or compatibility fixtures for the
superseded pre-release shapes. Existing fixtures should be rewritten or
deleted according to whether they still protect current behavior.
- Require the top-level list field and every record field. A list may be empty,
but every returned record must have at least one source reference. Use strict
JSON objects with unknown fields rejected and no nullable or optional legacy
fields.
- Keep private source references limited to `start_unit_id` and `end_unit_id`.
Mapping assigns the current source document ID; campaign references and an
NPC registry never become event evidence.
- Keep the shared prompt ordering policy: stable shared instructions first,
stable campaign and generated references next, module task material after
those references, and the chunk-variable transcript last.
- Preserve the existing production validator-chain order and the rule that a
configured chain is authoritative. Remove or simplify validators; do not
silently reorder chains.
- Preserve the existing NPC ID derivation algorithm and comparison/display
normalization for canonical names. Retain existing semantic-policy
identifiers as well as schema and prompt versions; pre-redesign development
state is disposable. Unchanged canonical names must not receive new IDs.
- Use deterministic code only for display normalization, known catalog or NPC
canonicalization, application-owned IDs, evidence canonicalization, ordering,
and exact duplicate collapse. Do not introduce fuzzy matching or inferred
enrichment.
- Follow the testing policy: protect schemas and behavior at their owning
boundaries, delete obsolete tests, and avoid tests that snapshot prompt prose
or detect exact shared-prefix length. No live or paid model call is a stage
completion requirement.
- Each stage must leave the repository compiling and `go test ./...` passing.
Update all affected fixtures and callers within the stage that changes a
public Go type; do not leave an intentionally broken intermediate commit.
## Working Results Structure
## Stage 1: Cut Over The NPC Contract And Name Projection
Stages 1 through 4 maintain one working area at the end of `audit.md`:
This stage establishes the redesigned generated reference on which later stages
depend.
```markdown
## Audit Results
### 1.1 Replace the durable and private data shapes
Status: In progress
- Reduce `dnd.NPC` in `internal/modules/dnd/types.go` to exactly `ID`, `Name`,
and `SourceRefs`. Delete `NPCRelationship` and every alias, description, and
relationship field or helper that becomes unused.
- Rewrite the existing strict durable schema `dnd_npcs.v1.json` with required
top-level `npcs`, and records containing only required `id`, `name`, and
`source_refs`. Keep the NPC codec at schema version `v1`, name
`notarius_dnd_npcs_v1`, and its existing schema ID and media type.
- Rewrite the strict private schema `dnd_npcs_llm.v1.json`. Its records contain
only required `name` and model-facing source ranges. Keep the extractor schema
identity and prompt ID/version unchanged.
- Reduce the private response DTO and mapping accordingly. Preserve candidate
data at the mapping boundary so deterministic validators, rather than mapping
defaults, reject blank names or invalid ranges. Assign the current source ID
and derive the NPC ID in application code.
- Rewrite the NPC task and instruction assets to ask only for individually
identifiable NPC names and supporting transcript ranges. Explicitly exclude
anonymous groups, generic roles, invented labels, descriptions, aliases, and
relationships. Keep transcript material last in the manifest.
### Baseline And Module Matrix
### Divergence Register
### Prompt, Schema, And LLM Review
### Extraction And Canonicalization Review
### Duplication Register
### Contextual Architecture And Ownership Review
### Candidate Decisions
```
### 1.2 Simplify NPC identity, normalization, and validation
Create headings when their owning stage begins. Later stages may refine earlier
sections but must not duplicate them. Use compact tables where they improve
five-way comparison. Keep detailed evidence in the relevant review section and
keep the divergence and duplication registers concise.
- Keep the identity policy at `dnd.npcs.identity.v1`. Retain display
normalization, comparison keys, ID syntax, and deterministic ID derivation.
Validate nonblank canonical names, exact ID/name agreement, duplicate
canonical names, and duplicate IDs. Delete alias validation and alias-specific
issue locations/codes.
- Keep NPC normalization at `dnd.npcs.normalize.v1`. Normalize the retained
display name, derive its ID, canonicalize and deduplicate source references,
consolidate records only by the canonical-name comparison key, preserve the
first stable record, and union exact evidence. Remove alias promotion,
relationship merge/rewrites, and their warning codes.
- Keep the existing NPC shape and source-relatedness policy identifiers. The
shape validator checks only list presence, nonblank `id` and `name`, and
nonempty source references; source-relatedness grounds a record only through
its retained name. The existing source-ref validator remains the owner of
range validity.
- Keep the default validation order domain-first, then JSON Schema, then the
advisory relatedness validator. Simplify only the validator implementations
and selections whose owned behavior changed.
Each divergence-register entry must have:
### 1.3 Separate durable registry provenance from model input
- a stable identifier such as `D-01`;
- affected modules;
- observed difference;
- provisional classification from the audit strategy;
- evidence links;
- impact or reason it may be harmless; and
- status: open, confirmed, intentional, rejected, or superseded.
- Continue to canonicalize and retain the complete redesigned NPC artifact for
registry validation, cache identity, manifests, and provenance.
`Registry.Digest()` remains the digest of that complete canonical artifact
when bound and remains empty when no registry was supplied.
- Build a second structural JSON projection for model grounding with the exact
shape `{"npcs":[{"name":"Mira Thorn"}]}` in normalized registry order. It
contains names only: no IDs, source references, origin URI, aliases, or other
provenance. Generate it with typed values and `json.Marshal`, not string
concatenation. The empty projection is exactly `{"npcs":[]}`.
- Add an explicit projection digest accessor. The digest is SHA-256 over the
exact projected bytes, including for an absent or empty registry, and
`PromptInput().Digest` must equal it. Keep full-artifact and projection digests
distinct even when their content happens to coincide.
- Index `Registry.Lookup` by canonical name only. Retain immutable return values
and defensive copies. Remove alias indexing and alias-aware comments.
- In spell extraction, combat extraction, and combat normalization, use the NPC
name-projection digest for the component-local checkpoint fingerprint because
it exactly describes the names that affect those operations. Keep the full
registry digest and count in manifest metadata for provenance. Framework-owned
generated-reference fingerprints may still invalidate a run when any upstream
artifact byte changes; do not broaden this stage into a framework cache
redesign.
Each duplication-register entry must have:
### 1.4 Update owners, consumers, and tests
- a stable identifier such as `R-01`;
- participating modules;
- repeated responsibility;
- exact, structural, or policy-duplication classification;
- meaningful differences;
- candidate owner, if any;
- evidence links; and
- provisional outcome: extract, harmonize without sharing, retain separately,
or defer.
- Update NPC codec, schema, extractor, identity, validator, normalizer, registry,
registration, pipeline-integration, and CLI fixtures to the new shape. Adapt
spell and combat tests that construct `dnd.NPC` values so the repository
remains buildable, but do not change their own artifact contracts yet.
- Add or rewrite focused tests for strict schema acceptance/rejection,
codec round trips, unchanged ID derivation for known names, name-only
consolidation, evidence union, registry immutability, and canonical-name-only
lookup. Rewrite the existing fixture for the minimal current contract; do not
retain the superseded rich fixture solely to test backwards incompatibility.
- Test the projection as a data contract: it contains only ordered names,
equivalent normalized registries produce identical bytes and digest,
evidence/ID-only changes do not change the projection digest, and name/order
changes do. This is not authorization to snapshot assembled prompt prose or
prefix lengths.
- Update `docs/integrations/dnd-npc-artifacts.md` to own the redesigned v1
durable schema. Update `docs/internal/modules.md` and
`docs/internal/llm.md` only for current NPC behavior that lands in this stage.
Do not assign final severity until Stage 5 has traced ownership and evaluated
impact.
### Stage 1 completion criteria
## Stage 1: Inventory And Convention Matrix
- NPC durable and private schemas expose no removed enrichment fields.
- The generated NPC prompt input contains names only while manifests retain
full registry provenance.
- Spell and combat consumers accept generated or external registries in the
redesigned v1 shape; rich pre-redesign registry JSON fails strict decoding.
- Focused NPC, spell-wiring, combat-wiring, integration, and CLI tests pass, and
`go test ./...` passes.
### Objective
## Stage 2: Cut Over Spell-Cast Extraction
Establish the complete, factual five-module baseline and identify convention
differences without yet recommending shared abstractions.
### 2.1 Replace the spell contract and prompt
### Required review
- Reduce `dnd.SpellCast` to exactly `Caster`, `Spell`, and `SourceRefs`.
- Rewrite `dnd_spells.v1.json` in place. Keep the durable codec at version v1,
name `notarius_dnd_spells_v1`, and its existing schema ID. Its strict record
contains only required `caster`, `spell`, and `source_refs`.
- Rewrite `dnd_spells_llm.v1.json` with the same logical fields and model-facing
ranges. Keep the existing private key, ID, name, prompt version, and schema
path unchanged.
- Reduce the private DTO, canonicalization, and mapping to the retained fields.
Continue assigning current source IDs, stable-ordering candidates by evidence,
and preserving semantically invalid candidates for deterministic validation.
- Rewrite spell task and instruction assets around the narrow casting-event
boundary. Retain the spell catalog and name-only NPC projection as
disambiguation inputs, never evidence. Remove effect, outcome, and narrative
duties and remove the `common-dnd-immediate-resolution.md` message from the
spell manifest and spell asset registration. Do not delete the shared file in
this stage because combat still uses it.
For every extractor, inspect:
### 2.2 Simplify spell policies
- production and test file inventory;
- module key, artifact kind, capabilities, and execution class;
- constructor inputs and retained prepared state;
- option decoding and unknown-option behavior;
- `ModuleSpec`, registration builder, and reference slots;
- manifest metadata and checkpoint fingerprint providers;
- exported versus package-private surface;
- embedded asset registration;
- principal extractor entry point and result type; and
- package-local test organization.
- Keep the spell shape policy identifier unchanged and validate only list
presence, nonblank caster/spell, and nonempty source references.
- Keep catalog validation, source-reference validation, and source-relatedness
behavior and policy identities unchanged where their actual semantics are
already limited to spell name, caster, and evidence.
- Simplify normalization and duplicate comparison to caster, catalog-canonical
spell name, and the complete valid evidence set. Remove all prose selection,
copying, fixture fields, and assertions. Preserve catalog fingerprints and
metadata.
- Keep the exact inclusion rule from the feature roadmap: an actual casting or
unambiguous declared attempt is included; mentions, plans, rules discussion,
and catalog matches are not.
Trace registration into production composition far enough to confirm that the
declared contract is the one actually selected. Inspect neighboring packages
only as required to verify an identity or ownership fact.
### 2.3 Update owners and tests
### Write to `audit.md`
- Update spell codec, private schema, extractor, validators, normalizer, merge
and registration tests, pipeline integration, CLI output fixtures, and any
maintained examples to the minimal shape. Rewrite or delete rich-schema
fixtures rather than retaining them as compatibility cases.
- Replace tests of effects and narrative descriptions with focused tests of the
retained contract: strict unknown-field rejection, codec round trips, current
source-ID assignment, catalog canonicalization, evidence ordering, duplicate
collapse, and NPC name-projection wiring.
- Update `docs/integrations/dnd-spell-artifacts.md` as the canonical redesigned
v1 contract and update current internal module/LLM documentation for the
smaller prompt and response. Do not duplicate the spell catalog contract
owned by its existing integration document.
Create `## Audit Results`, mark it `Status: In progress`, and add:
### Stage 2 completion criteria
1. `### Baseline And Module Matrix`
- one row per module;
- columns for every comparison dimension in the audit strategy;
- concise facts with links rather than judgments; and
- an explicit note where a dimension is not applicable.
2. `### Divergence Register`
- record every observed organizational, naming, construction, registration,
provenance, or test-layout difference;
- classify only as required specialization, permitted variation, convention
drift, architectural divergence, or undetermined; and
- do not propose helper extraction in this stage.
- No production spell type, schema, prompt, validator, normalizer, fixture, or
documentation contract refers to effect or narrative description.
- Spell prompt/schema identities remain exactly their existing v1 values.
- Focused spell and pipeline tests pass, and `go test ./...` passes.
## Stage 3: Cut Over Combat-Turn Extraction
### 3.1 Replace the combat contract and prompt
- Reduce `dnd.CombatTurn` to exactly `Actor`, `TurnKind`, and `SourceRefs`.
Delete `CombatAction`, `CombatActionCategory`, their constants, and all
now-unused helpers. Retain the existing five `CombatTurnKind` values.
- Rewrite `dnd_combat_turns.v1.json` in place. Keep the durable codec at version
v1, name `notarius_dnd_combat_turns_v1`, and its existing schema ID. Use a
strict required record with `actor`, `turn_kind`, and `source_refs` only.
- Simplify the durable codec to direct strict encoding/decoding if its custom
wire representation exists only to distinguish removed nullable fields.
Preserve presence semantics for the top-level list and strict unknown-field
rejection.
- Rewrite `dnd_combat_turns_llm.v1.json` while retaining its private schema and
prompt identities, and reduce the DTO/mapping to actor, turn kind, and
model-facing ranges. Keep the semantic mapping policy identifier unchanged.
- Rewrite combat task/instruction assets around detecting ordered turns and
discrete interrupting events. Remove round, action, target, declaration,
resolution, outcome, and summary duties. Remove the shared immediate-
resolution message from the combat manifest.
- Once both spell and combat manifests no longer use it, delete
`common-dnd-immediate-resolution.md` and its shared and module asset
registrations. Retain all other shared evidence, identity, reference, NPC,
and transcript assets in their cache-friendly order.
### 3.2 Simplify combat normalization and validation
- Keep combat normalization at `dnd.combat_turns.normalize.v1`. Continue to
display-normalize and registry-canonicalize actors, canonicalize evidence,
order records chronologically, and collapse exact duplicates by actor, turn
kind, and complete valid evidence. Delete action/target/prose normalization
and warning codes.
- Keep the invariant policy identifier unchanged. It checks canonical actor
display, canonical and chronological evidence, stable event ordering, and
absence of duplicate event identities; it performs no nested-action checks.
- Keep the shape policy identifier unchanged and validate only list presence,
nonblank actor, allowed turn kind, and nonempty source references.
- Keep the source-relatedness policy identifier unchanged and compare only the
actor against cited transcript material. Remove declaration-token heuristics
and their now-unused helpers. Keep source-reference validation unchanged if
its semantics did not change.
- Simplify merge/clone behavior to copy only retained values and source refs.
Preserve stable ordering and defensive ownership.
### 3.3 Update owners and tests
- Update codec, private schema, extractor, normalizer, validators, merge,
registration, pipeline integration, CLI fixtures, and examples to the minimal
shape. Rewrite or delete rich-schema fixtures rather than retaining them as
compatibility cases.
- Delete tests whose sole policy was round/action/summary handling. Add or
rewrite focused tests for strict schemas, enum validation, mapping and
source-ID assignment, actor canonicalization through the redesigned NPC
registry, chronology, exact duplicate collapse, and invariant validation.
- Update `docs/integrations/dnd-combat-turn-artifacts.md` as the canonical
redesigned v1 contract and update current internal module/LLM documentation
for the implemented behavior.
### Stage 3 completion criteria
- No production combat type, prompt, schema, policy, normalizer, fixture, or
current documentation refers to rounds, actions, summaries, declarations,
targets, resolutions, or action categories.
- The immediate-resolution shared asset has no remaining registration or file.
- Focused combat and pipeline tests pass, and `go test ./...` passes.
## Stage 4: Complete The Repository-Wide Cutover
### 4.1 Audit contract identity and stale surface area
- Search code, embedded assets, tests, examples, and current documentation for
all removed field names and unintended v2 schema/prompt names. Removed fields
may remain only in historical ADR or roadmap context; active schema, prompt,
fixture, and current-behavior surfaces must describe the minimal v1 contract.
- Verify the three artifact registrations still bind their original kinds and
exact redesigned Go types through extract, merge, normalize, codec, and
validators.
- Verify all prompt manifests still point at their v1 private schemas, stable
reference material precedes module-variable material, and transcript content
remains last. Do not add a change-detector test for prompt message count,
prose, shared-prefix content, or prefix length.
- Verify pre-redesign development checkpoints naturally miss through changed
prompt/schema content and generated-dependency fingerprints. Do not add
migration or compatibility handling for disposable pre-release state.
### 4.2 Exercise representative assembled behavior
- Keep unit case matrices at their owning schema, validator, normalizer, and
registry boundaries. At the assembled-pipeline boundary, retain only
representative tests proving each redesigned lane is registered, strict
JSON/schema failures are attributed to the correct validator, semantic
failures are attributed to the correct domain validator, and generated NPC
output is accepted by later spell/combat stages.
- Cover an ordered multi-step run in which NPC extraction produces the minimal
artifact and spell/combat consume its name projection. Assert that downstream
event source references point only to the current transcript and not to NPC
registry evidence.
- Verify CLI logical output and manifests retain the v1 schema/prompt identities,
full NPC registry provenance, and the relevant component fingerprints without
exposing prompt bodies or reference payloads.
### 4.3 Finish documentation and lifecycle state
- Reconcile the three integration documents, `docs/internal/modules.md`, and
`docs/internal/llm.md` with the final code. Correct any stale validator-order
descriptions while doing so; current docs must describe the registered order,
not preserve an older generic ordering example.
- Mark ADR-0009 `Accepted` and the feature roadmap `Implemented` when the code,
tests, and current-behavior documentation all land. Remove the completed item
from `docs/roadmap/future.md`; retain the feature roadmap and ADR as design
rationale unless the repository's normal roadmap-retirement practice calls
for moving the completed roadmap later.
- Record any human-reviewed rich/minimal model evaluation separately from CI
results. Evaluation may motivate later prompt tuning but does not reopen the
approved durable minimal field set within this implementation.
### 4.4 Final verification
Run, in order:
Also add a short baseline-validation note recording:
```sh
gofmt -w <changed Go files>
go test ./...
go test -count=1 ./...
go vet ./...
go build ./cmd/notarius
go test -race ./internal/modules/dnd/...
gofmt -l .
git diff --check
```
If the repository-wide race command exposes an unrelated, pre-existing failure,
document it with the narrower affected package result; do not weaken or skip
ordinary tests for the changed D&D packages.
If a command fails, record the exact command, affected package, and concise
failure classification. Do not fix it during the audit.
### Stage 4 completion criteria
### Completion gate
- All three lanes use only their minimal v1 contracts from model response
through durable output.
- No compatibility shim, removed-field policy, unintended v2 asset, or stale
current-behavior documentation remains.
- Generated NPC references are name-only for LLM input and remain full-fidelity
for durable provenance.
- Repository tests, vet, build, race checks for the changed domain, and diff
hygiene checks pass.
Stage 1 is complete when every matrix cell is populated or marked not
applicable, every observed baseline divergence has a stable register entry, and
the repository's starting validation state is recorded.
## Stage 2: Prompts, Schemas, And LLM Boundaries
### Objective
Determine whether the five modules consistently present stable context,
references, lane instructions, transcripts, and structured-output contracts to
the LLM, and identify exact or near-duplicate assets without changing them.
### Required review
For every extractor:
- read the complete prompt manifest and every referenced local and shared
asset;
- record the exact ordered message sequence, role, input, and cache-control
boundary;
- compare shared message files by identity and bytes, not by paraphrased
meaning;
- verify stable-to-variable ordering against `docs/internal/llm.md`;
- map declared prompt inputs to module reference slots and generated inputs;
- trace optional, required, empty, and generated reference projections;
- inspect prompt and schema registration, hashing, and diagnostic redaction;
- inspect the complete private response schema for identity, required fields,
nullability, strict objects, and semantic constraints;
- map schema fields to private DTOs and response mapping; and
- compare prompt and schema tests at their behavioral boundaries.
Treat the scene-description whole-chunk evidence model as a specialization to
explain, not a presumption of drift. Likewise, treat catalog and NPC registry
inputs as subset-specific responsibilities unless evidence shows inconsistent
handling of the same contract.
### Write to `audit.md`
Add `### Prompt, Schema, And LLM Review` containing:
- a five-way prompt-order and cache-boundary table;
- a prompt-input and reference-projection table;
- a private-schema and DTO ownership table;
- exact shared-asset usage;
- local assets with identical or near-identical content;
- content-safety and diagnostic observations; and
- evidence-backed deviations from documented LLM conventions.
Update the divergence register for confirmed or newly discovered prompt,
schema, reference, provenance, and testing differences.
Create `### Duplication Register` and add prompt-, schema-, metadata-, and
asset-related candidates. For each prompt candidate, state whether the text is
byte-identical, merely similar, or semantically different. Do not recommend a
shared prompt asset unless all intended consumers should receive future edits
atomically.
### Completion gate
Stage 2 is complete when every prompt message and input is accounted for, every
private schema field has an owner, cache-prefix claims are based on exact
message identity, and every asset-sharing candidate has a provisional keep or
share outcome.
## Stage 3: Extraction, Evidence, And Canonicalization
### Objective
Compare runtime extraction flow and identify repeated algorithms or policy
without erasing artifact-specific semantics.
### Required review
For every extractor, inspect and trace:
- request, context, source, chunk, and dependency validation;
- construction-time state versus operation-time overrides;
- structured request assembly and provider-error wrapping;
- response-to-artifact mapping;
- source identity attachment and unit-ID resolution;
- evidence range validation assumptions;
- canonical source-reference ordering and exact deduplication;
- artifact ordering and deterministic tie-breakers;
- enum or canonical-name handling;
- warnings and diagnostics;
- cloning and aliasing boundaries; and
- focused extractor, model, canonicalization, and malformed-output tests.
Compare complete algorithms rather than function names alone. For similar
canonicalization helpers, identify which parts are:
- identical source-reference mechanics;
- artifact-specific mapping;
- artifact-specific ordering policy; or
- validation that belongs to a later validator rather than extraction.
Use call traces and complexity data to inspect repeated scans, allocations,
serialization, or high-cognitive-complexity paths. Report performance only when
a plausible workload and complexity impact exist.
### Write to `audit.md`
Add `### Extraction And Canonicalization Review` containing:
- a five-way extraction-flow table;
- an evidence and ordering-policy table;
- clone, mutation, error, and warning observations;
- test-ownership comparisons; and
- code-quality or performance candidates with concrete impact.
Update the divergence register, revising earlier provisional classifications
where runtime evidence explains or contradicts them.
Expand the duplication register with request-validation, reference,
canonicalization, ordering, deduplication, mapping, error, and test-support
candidates. Give each candidate a provisional owner and explicitly identify the
artifact-specific code that must remain local.
### Completion gate
Stage 3 is complete when the full extraction path of all five modules is
accounted for, every similar canonicalization path has been decomposed into
shared mechanics versus domain policy, and every runtime duplication candidate
has a provisional ownership decision.
## Stage 4: Contextual Architecture And Ownership Review
### Objective
Validate candidate findings against the surrounding D&D and framework
architecture, and decide which apparent similarities should actually be shared.
### Required review
For every open divergence and duplication candidate:
- trace callers and consumers;
- inspect existing facilities in `internal/modules/dnd/shared`;
- inspect focused D&D registry, identity, catalog, codec, normalize, and
validate packages as relevant;
- verify the typed artifact and reference contracts;
- inspect production registration, default validator composition, and
checkpoint fingerprint assembly;
- verify documentation ownership and current durable contracts; and
- check whether a proposed generic helper has a genuine domain-neutral owner.
Apply the shared-code hierarchy from `audit.md`:
1. module-owned artifact semantics;
2. D&D-wide shared mechanics;
3. focused subset-specific D&D packages; and
4. framework-owned domain-neutral behavior.
Reject or defer candidates whose API would require artifact-specific callbacks,
type erasure, module-key branching, a broad configuration object, or speculative
future consumers.
### Write to `audit.md`
Add `### Contextual Architecture And Ownership Review` containing:
- traced ownership evidence for every open candidate;
- dependency-direction and layer-boundary conclusions;
- checkpoint, provenance, and reference compatibility conclusions;
- documentation or test ownership implications; and
- any product-contract questions that cannot be decided as refactors.
Add `### Candidate Decisions`, with one row per divergence and duplication
identifier. Choose exactly one outcome:
- extract now;
- harmonize without sharing;
- retain intentionally separate;
- reject as harmful abstraction; or
- defer pending a named missing requirement or product decision.
For extract or harmonize outcomes, specify:
- target owner;
- minimal responsibility and proposed API shape;
- participating modules;
- behavior that remains package-owned;
- migration order;
- relevant tests; and
- principal risks.
Update every register entry to confirmed, intentional, rejected, superseded, or
explicitly deferred. No entry may remain merely open at the end of this stage.
### Completion gate
Stage 4 is complete when all candidates have traced ownership and a final
keep/share/harmonize/defer decision, no proposed helper violates dependency
direction, and all required product decisions are separated from executable
refactoring recommendations.
## Stage 5: Synthesis And Final Audit
### Objective
Turn the working evidence into one concise, internally consistent audit that
can support roadmap decisions and a later implementation plan.
### Required work
1. Re-read the complete strategy and all working audit results.
2. Recheck every cited file and symbol against the current working tree.
3. Reconcile duplicate, overlapping, or contradictory observations.
4. Assign severity only to confirmed actionable findings:
- **high:** correctness, security, data integrity, or architectural failure
with substantial impact;
- **medium:** meaningful drift, duplication, or design weakness likely to
cause defects or costly divergence;
- **low:** localized maintainability, clarity, test-quality, or
documentation issue with limited immediate impact.
5. Keep optional improvements separate from findings.
6. Confirm intentional specializations and rejected sharing candidates are
documented so future work does not repeatedly reopen them without evidence.
7. Run the repository-wide validation commands from Stage 1 again and record
the final result. Do not modify code in response to failures.
### Rewrite `audit.md`
Preserve the strategy sections above `## Audit Results`, but replace the
provisional working area with this final structure:
```markdown
## Audit Results
Status: Complete
### Executive Conclusion
### Final Module Comparison Matrix
### Prioritized Findings
### Intentional Differences To Preserve
### Shared-Code Decisions
### Rejected Or Deferred Candidates
### Recommended Refactoring Sequence
### Validation And Residual Risks
```
Requirements for the final sections:
- `Executive Conclusion` directly answers the user's three audit questions.
- `Final Module Comparison Matrix` remains factual and compact.
- `Prioritized Findings` follows the finding standard in `audit.md`, is ordered
by severity and impact, and cites exact evidence.
- `Intentional Differences To Preserve` explains why harmonization would be
incorrect.
- `Shared-Code Decisions` records extract and harmonize recommendations,
proposed ownership, and why sharing is better than continued separation.
- `Rejected Or Deferred Candidates` records superficially attractive
abstractions and why they should not be pursued now.
- `Recommended Refactoring Sequence` groups accepted recommendations into
independently safe, dependency-ordered scopes. It is a sequence, not a
decision-complete implementation plan.
- `Validation And Residual Risks` records commands, results, limits of the
static audit, and any human or model-quality evaluation still needed.
Remove the working divergence and duplication registers after their evidence
has been incorporated into the final sections. Do not leave raw stage notes,
provisional severities, or superseded conclusions in the completed audit.
### Completion gate
Stage 5 is complete when:
- the final audit satisfies every deliverable and completion criterion in
`audit.md`;
- the three user questions receive explicit answers;
- every recommendation has evidence, ownership, scope, and rationale;
- no finding relies only on similarity or stylistic preference;
- validation results are current;
- no code or current-behavior files changed; and
- `git status --short` shows only the intended `audit.md` audit-result changes
plus any pre-existing user changes.
## Open Questions
None. The feature roadmap, ADR, and fixed decisions above define the cutover,
pre-release schema, evidence, projection, validation, testing, and documentation
policies needed to implement each stage without further product decisions.
None. The audit stages, working-document structure, decision rules, and final
deliverable are fully specified.

View File

@@ -1,220 +0,0 @@
# Minimal D&D Extraction Contracts
**Status:** Implemented
## Intent
Redesign the D&D spell, NPC, and combat-turn artifacts around the principle in
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md):
each extractor should answer one narrow question with the smallest useful set
of source-grounded fields.
The redesign favors extraction precision, evidence quality, valid-output rate,
smaller-model reliability, and lower prompt and response cost over descriptive
richness. It removes synthesis responsibilities rather than preserving obsolete
fields as optional, nullable, empty, or application-generated placeholders.
## Goals
- Make every model-produced field necessary to the artifact's core question.
- Require direct transcript evidence for every extracted record.
- Remove overlapping prose, inferred enrichment, and nested structures without
a demonstrated consumer.
- Keep catalog and identity references as disambiguation aids rather than
evidence.
- Preserve deterministic canonicalization, evidence ordering, exact
deduplication, identity assignment, and bounded domain diagnostics where
those responsibilities still apply.
- Reduce downstream prompt material to the fields a consumer actually needs.
- Keep the unpublished v1 identities while replacing their pre-release shapes
in place.
## Non-Goals
- Generating session narrative, rules analysis, biographies, relationship
graphs, encounter summaries, or prose descriptions.
- Preserving removed fields for source compatibility through empty strings,
nullable values, or synthetic defaults.
- Adding fuzzy entity resolution, LLM-assisted enrichment, or a general schema
migration framework.
- Treating campaign references, catalogs, or earlier artifacts as evidence that
an event occurred in the current transcript.
- Combining the three D&D artifact families into one model call.
## Shared Contract Policy
All three artifacts remain ordered lists. Each record contains at least one
source reference, and the complete reference collection supports every
model-produced field in that record. Source IDs continue to be assigned by the
application from the current input; the model returns only source-unit ranges.
Private LLM schemas remain strict about their transport envelope: required
fields, JSON types, object and array shape, nullability where applicable, and
unknown-field rejection. Deterministic validators continue to own semantic
rules such as nonblank identities, catalog membership, enum membership,
positive and resolvable source units, and canonical normalized invariants.
Prompts retain the shared D&D evidence, identity, reference, NPC-grounding, and
transcript assets that remain relevant. Module-specific task and instruction
assets must delete duties associated with removed fields. The existing
cache-friendly ordering keeps stable shared and reference material before the
chunk-variable transcript.
## Spell Cast
The spell extractor answers:
> Which spell was cast, by which in-world caster, and where is that event
> established in the source?
The artifact kind remains `dnd/spell-list`. Its durable schema remains v1.
Each spell-cast record contains exactly:
- `caster`: required nonblank in-world display identity;
- `spell`: required nonblank canonical or catalog-resolvable spell name; and
- `source_refs`: one or more current-source evidence ranges.
The model-facing response contains the same fields except for application-owned
`source_id` values within references. The current `effect` and
`narrative_description` fields are removed from the private response, public Go
type, durable schema, codec, validators, normalizer, fixtures, and integration
contract.
The inclusion boundary remains an actual casting event or an unambiguously
declared casting attempt, not a spell mention, hypothetical plan, rules
discussion, or catalog match. The spell catalog helps recognize and canonicalize
the name but never establishes that a cast occurred.
Normalization continues to canonicalize spell names, canonicalize evidence,
and collapse exact duplicate events using caster, canonical spell name, and
complete valid evidence. It performs no prose selection or merging.
## NPC Registry
The NPC extractor answers:
> Which individually identifiable non-player characters are established in the
> source, and where is each identity established?
The artifact kind remains `dnd/npc-list`. Its durable schema remains v1.
Each durable NPC record contains exactly:
- `id`: deterministic application-assigned identity derived under the NPC
identity policy;
- `name`: required nonblank source-supported display identity; and
- `source_refs`: one or more evidence ranges supporting that identity.
The private model response omits `id` and reference `source_id` values. A
`name` may be a proper name or a stable, individually distinguishing title or
alias supported by the transcript. The extractor does not invent descriptive
labels for anonymous creatures, crowds, or generic roles.
The current `aliases`, `description`, and `relationships` fields are removed
from the private response, public Go type, durable schema, codec, validators,
normalizer, registry, fixtures, and integration contract. Normalization
consolidates only identities supported by the retained name policy and unions
exact evidence; it does not infer alias equivalence or relationships.
Spell and combat consumers receive a prompt projection containing only the
canonical NPC names needed for identity grounding. Application-owned NPC IDs
remain available to deterministic registry and normalization code but are not
sent to a model that cannot return or otherwise consume them. NPC source
references remain provenance in the durable registry and are not included as
current-transcript evidence or copied into downstream event artifacts.
Encounter context is deliberately not a scalar NPC registry field. Dialogue,
combat alignment, presence, or third-party mention can vary across occurrences.
If a demonstrated consumer needs that information, add a separate ordered
NPC-occurrence artifact whose records contain `name`, a small mutually
exclusive context enum, and `source_refs`. A candidate starting vocabulary is
`dialogue`, `combat_ally`, `combat_opponent`, `noncombat_presence`, `mentioned`,
and `other`; its exact semantics require a separate feature decision.
## Combat Event
The combat extractor answers:
> Which in-world participant took a turn or discrete interrupting combat
> event, what kind of event was it, and where is it established in the source?
The existing `dnd/combat-turn-list` artifact kind and v1 durable-schema identity
remain. Each record contains exactly:
- `actor`: required nonblank in-world display identity;
- `turn_kind`: one of `turn`, `reaction`, `legendary_action`, `lair_action`, or
`other`; and
- `source_refs`: one or more current-source evidence ranges.
The current `round`, `actions`, and `summary` fields, including nested action
categories, declarations, targets, and resolutions, are removed from the
private response, public Go types, durable schema, codec, validators,
normalizer, fixtures, and integration contract.
Normalization continues to display-normalize and registry-canonicalize actors,
canonicalize evidence, order events by source position, and collapse exact
duplicates using actor, turn kind, and complete valid evidence. It no longer
normalizes targets, declarations, summaries, or resolutions.
If action-level facts later have a demonstrated consumer, they belong in a
separate combat-action artifact rather than restoring a nested synthesis
contract to combat-turn detection. Spell casts and future item events remain
owned by their dedicated artifact lanes.
## Pre-Release Schema Policy
Notarius and these contracts are pre-release. The existing v1 artifacts and
private model-response schemas have not been published as compatibility
contracts, so their shapes change in place. The implementation does not retain
the rich pre-redesign shape, add v2 assets, migrate old output, support multiple
versions, or preserve old fixtures solely for compatibility testing.
Artifact kinds, schema keys and IDs, schema names and versions, prompt IDs and
versions, module keys, capabilities, and media types all remain unchanged.
Changing prompt and schema content invalidates the relevant content-addressed
development state; any remaining pre-redesign local output or cache is
disposable and may be regenerated.
| Lane | Durable v1 schema | Private model-response v1 schema | Prompt ID |
| --- | --- | --- | --- |
| Spell cast | ID `notarius.dnd.spells`, name `notarius_dnd_spells_v1` | key `dnd_spells`, ID `notarius.dnd.spells`, name `notarius_dnd_spells_v1` | `dnd.spells` |
| NPC registry | ID `notarius.dnd.npcs`, name `notarius_dnd_npcs_v1` | key `dnd_npcs_llm`, ID `notarius.dnd.npcs.llm`, name `notarius_dnd_npcs_llm_v1` | `dnd.npcs` |
| Combat event | ID `notarius.dnd.combat_turns`, name `notarius_dnd_combat_turns_v1` | key `dnd_combat_turns_llm`, ID `notarius.dnd.combat_turns.llm`, name `notarius_dnd_combat_turns_llm_v1` | `dnd.combat_turns` |
## Quality And Evaluation
The implemented contract should protect the remaining meaningful risks:
- private schemas accept only the new structural envelopes;
- durable codecs strictly round-trip the redesigned contracts;
- domain validators own blank identities, enum and catalog membership, and
invalid evidence;
- normalizers preserve source-grounded values while applying only their stated
deterministic transformations;
- generated NPC references expose the minimal identity projection and never
become event evidence; and
- representative assembled pipelines attribute retries and rejections to the
owning domain boundary.
Post-cutover model evaluation should compare the rich-schema baseline and the
minimal-schema result on a small human-reviewed transcript set using:
- event/entity precision and recall;
- caster, NPC, and combat-actor attribution accuracy;
- source-range validity and evidence sufficiency;
- catalog and enum accuracy;
- structurally valid completion rate and exhausted-retry rate;
- unsupported-claim rate; and
- input/output tokens, latency, and model cost.
The evaluation exists to identify prompt or model-quality follow-up work, not
to gate the approved minimal contract on live-provider behavior. Human review
and live model calls are evaluation aids rather than deterministic CI gates.
## Documentation Ownership
The spell, NPC, and combat integration documents are the canonical owners of
their redesigned durable schemas. Internal LLM and module documents own the
corresponding current prompt, DTO, validator, normalizer, and NPC
prompt-projection behavior. This roadmap records the implemented design and
policy; [the implementation plan](implementation.md) records the completed
sequencing and completion criteria.

View File

@@ -0,0 +1,34 @@
version: 3
output:
directory: ./notarius-output
cache:
chunk_plans:
mode: bypass
checkpoints:
enabled: false
directory: ""
debug:
directory: ./notarius-debug
pipelines:
dnd-npc-interactions:
input: seriatim
steps:
- id: identify-npcs
artifacts:
npcs:
extract:
module: dnd/npcs
retries: 2
normalize: dnd/npcs
- id: extract-interactions
references:
npcs:
artifact:
step: identify-npcs
lane: npcs
artifacts:
interactions:
extract:
module: dnd/npc-interactions
retries: 2
normalize: dnd/npc-interactions

View File

@@ -0,0 +1,23 @@
version: 3
output:
directory: ./notarius-output
cache:
chunk_plans:
mode: bypass
checkpoints:
enabled: false
directory: ""
debug:
directory: ./notarius-debug
pipelines:
dnd-scene-chunk-map:
input: seriatim
chunk: dnd/scenes
output:
module: json
options:
include_chunk_map: true
artifacts:
spells:
extract: dnd/spells
normalize: dnd/spells

View File

@@ -0,0 +1,20 @@
version: 3
output:
directory: ./notarius-output
cache:
chunk_plans:
mode: bypass
directory: ./notarius-cache/chunk-plans
checkpoints:
enabled: false
directory: ./notarius-cache/checkpoints
debug:
directory: ./notarius-debug
pipelines:
dnd-scene-descriptions:
input: seriatim
chunk: dnd/scenes
artifacts:
scene-descriptions:
extract: dnd/scene-descriptions
normalize: dnd/scene-descriptions

View File

@@ -0,0 +1,135 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
)
func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
components := productionTestComponents(t)
resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), pipeline.ResolveOptions{}, catalogFromRegistries(components.registries))
if err != nil {
t.Fatalf("ResolvePipeline() error = %v", err)
}
if len(resolved.Steps) != 2 || len(resolved.Steps[1].ArtifactLanes) != 1 {
t.Fatalf("resolved pipeline = %#v", resolved)
}
lane := resolved.Steps[1].ArtifactLanes[0]
if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key {
t.Fatalf("interaction lane = %#v", lane)
}
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
if len(bindings) != 1 || bindings[0].SlotName != "npcs" || bindings[0].Artifact == nil || bindings[0].Artifact.Step != "npcs" || bindings[0].Artifact.Lane != "npcs" {
t.Fatalf("generated bindings = %#v", bindings)
}
}
if _, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("Prepare() error = %v", err)
}
catalog := catalogFromRegistries(components.registries)
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind)
if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion {
t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
}
}
func TestProductionNPCInteractionReferencesRequireEarlierCompatibleProducer(t *testing.T) {
components := productionTestComponents(t)
catalog := catalogFromRegistries(components.registries)
laterProfile := npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs"))
laterProfile.Steps[0].ID = "seed"
laterProfile.Steps[0].Artifacts["seed"] = laterProfile.Steps[0].Artifacts["npcs"]
delete(laterProfile.Steps[0].Artifacts, "npcs")
laterProfile.Steps = append(laterProfile.Steps, pipeline.PipelineStepProfile{ID: "future", Artifacts: map[string]pipeline.ArtifactLaneProfile{
"npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)},
}})
laterProfile.Steps[1].References["npcs"] = pipeline.GeneratedReference("future", "npcs")
tests := []struct {
name string
profile pipeline.PipelineProfile
want string
}{
{name: "missing", profile: npcInteractionProfile(pipeline.ReferenceSource{}), want: "source must not be empty"},
{name: "same step", profile: npcInteractionProfile(pipeline.GeneratedReference("interactions", "interactions")), want: "earlier step"},
{name: "later step", profile: laterProfile, want: "earlier step"},
{name: "wrong artifact kind", profile: npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), want: "does not accept artifact kind"},
}
tests[3].profile.Steps[0].Artifacts["npcs"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("dnd/spells")}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := pipeline.ResolvePipeline(test.profile, pipeline.ResolveOptions{}, catalog)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("ResolvePipeline() error = %v, want %q", err, test.want)
}
})
}
}
func TestProductionNPCInteractionReferencesRejectIncompatibleExternalRegistries(t *testing.T) {
components := productionTestComponents(t)
catalog := catalogFromRegistries(components.registries)
root := t.TempDir()
for _, test := range []struct {
name string
file string
content string
prepare bool
want string
}{
{name: "media type", file: "registry.txt", content: "not JSON", want: "media type"},
{name: "artifact schema", file: "registry.json", content: `{"npcs":[{"name":"missing required fields"}]}`, prepare: true, want: "NPC registry"},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(root, test.file)
if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil {
t.Fatal(err)
}
resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.ExternalReference(path)), pipeline.ResolveOptions{}, catalog)
if err != nil {
t.Fatalf("ResolvePipeline() error = %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(resolved, catalog, pipeline.ReferenceMaterializationOptions{})
if !test.prepare {
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("MaterializeReferences() error = %v, want %q", err, test.want)
}
return
}
if err != nil {
t.Fatalf("MaterializeReferences() error = %v", err)
}
if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Prepare() error = %v, want %q", err, test.want)
}
})
}
}
func npcInteractionProfile(reference pipeline.ReferenceSource) pipeline.PipelineProfile {
profile := pipeline.PipelineProfile{
ID: "dnd-npc-interactions",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
Output: pipeline.Binding("json"),
Steps: []pipeline.PipelineStepProfile{
{ID: "npcs", Artifacts: map[string]pipeline.ArtifactLaneProfile{
"npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)},
}},
{ID: "interactions", References: map[string]pipeline.ReferenceSource{"npcs": reference}, Artifacts: map[string]pipeline.ArtifactLaneProfile{
"interactions": {Extract: pipeline.Binding(interactionextract.Key), Normalize: pipeline.Binding(interactionnormalize.Key)},
}},
},
}
return profile
}

View File

@@ -0,0 +1,111 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"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"
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
sceneextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
scenenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
)
func TestProductionSceneDescriptionWorkflow(t *testing.T) {
components := productionTestComponents(t)
cfg := config.Default()
cfg.Pipelines["scene-descriptions"] = pipeline.PipelineProfile{
ID: "scene-descriptions",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
Output: pipeline.Binding("json"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"scene-descriptions": {
Extract: pipeline.Binding(sceneextract.Key),
Normalize: pipeline.Binding(scenenormalize.Key),
},
},
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "scene-descriptions", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
if lane.ArtifactKind != dnd.SceneDescriptionListKind || lane.Extract.Module != sceneextract.Key || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != scenenormalize.Key {
t.Fatalf("resolved lane = %#v, want production scene-description composition", lane)
}
if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 {
t.Fatalf("resolved references = %#v / %#v, want no generated or required references", lane.ExtractReferences, lane.NormalizeReferences)
}
prepared, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: sceneDescriptionLLM{}})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
t.Fatalf("run output = %#v, want one approved normalized artifact", output)
}
normalizedOutput := output.NormalizeOutputs[0]
if normalizedOutput.NormalizerKey != scenenormalize.Key || normalizedOutput.Artifact.Kind != dnd.SceneDescriptionListKind || normalizedOutput.Artifact.Schema.ID != scenecodec.SchemaID || normalizedOutput.Artifact.Schema.Name != scenecodec.SchemaName || normalizedOutput.Artifact.Schema.Version != scenecodec.SchemaVersion {
t.Fatalf("normalized output = %#v, want registered durable scene-description schema", normalizedOutput)
}
var value dnd.SceneDescriptionList
if err := json.Unmarshal(normalizedOutput.Artifact.Content, &value); err != nil {
t.Fatalf("decode normalized artifact: %v", err)
}
want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{
{ID: "chunk-000001", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, Kind: dnd.SceneKindNarrative, Title: "Aria casts Cure Wounds", Summary: "Aria casts Cure Wounds."},
{ID: "chunk-000002", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}, Kind: dnd.SceneKindCombat, Title: "Bandit mage casts Shield", Summary: "The bandit mage casts Shield."},
}}
if !reflect.DeepEqual(value, want) {
t.Fatalf("normalized scene descriptions = %#v, want %#v", value, want)
}
durable := decodeAssembledOutput[dnd.SceneDescriptionList](t, output.OutputFiles, "lanes/scene-descriptions.json")
if !reflect.DeepEqual(durable, want) {
t.Fatalf("durable output payload = %#v, want %#v", durable, want)
}
if len(output.Warnings) != 0 {
t.Fatalf("warnings = %#v, want grounded descriptions without warnings", output.Warnings)
}
}
type sceneDescriptionLLM struct{}
func (sceneDescriptionLLM) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if req.PromptID != sceneextract.PromptID {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
}
transcript := string(req.Inputs["transcript"].Content)
var content string
switch {
case strings.Contains(transcript, "Cure Wounds"):
content = `{"kind":"narrative","title":" Aria casts Cure Wounds ","summary":" Aria casts Cure Wounds. "}`
case strings.Contains(transcript, "Shield"):
content = `{"kind":"combat","title":"Bandit mage casts Shield","summary":"The bandit mage casts Shield."}`
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected transcript material %q", transcript)
}
if err := json.Unmarshal([]byte(content), out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured response: %w", err)
}
return contracts.StructuredCompletionResponse{Content: []byte(content), Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
}

View File

@@ -18,6 +18,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
@@ -457,7 +458,7 @@ func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T)
t.Fatalf("resolved validator chains = %#v, want normalize chain for %q", effective.ResolvedPipeline.ValidatorChains, spellnormalize.Key)
}
func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
func TestProductionSceneRunRecordsAnnotationFreeChunkPlanAndProvenance(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
fake := &productionFakeLLMClient{}
@@ -481,11 +482,40 @@ func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
t.Fatalf("chunk producer schema metadata = %#v, want %q", got, scenes.ResponseSchemaID)
}
index := readProductionJSON[productionChunkMapIndex](t, filepath.Join(outputRoot, productionRunID, "index.json"))
if index.ChunkMap == nil || index.ChunkMap.ArtifactKind != chunkmap.ArtifactKind || index.ChunkMap.File != "chunk-map.json" || index.ChunkMap.MediaType != chunkmap.MediaType || index.ChunkMap.SchemaID != chunkmap.SchemaID || index.ChunkMap.SchemaName != chunkmap.SchemaName || index.ChunkMap.SchemaVersion != chunkmap.SchemaVersion {
t.Fatalf("chunk map index = %#v, want fixed chunk map descriptor", index.ChunkMap)
}
for _, output := range index.OutputFiles {
if output.File == index.ChunkMap.File {
t.Fatalf("lane output files = %#v, want no chunk map", index.OutputFiles)
}
}
content, err := os.ReadFile(filepath.Join(outputRoot, productionRunID, index.ChunkMap.File))
if err != nil {
t.Fatal(err)
}
chunkMap, err := chunkmap.New().Decode(content)
if err != nil {
t.Fatalf("Decode(chunk map) error = %v", err)
}
if chunkMap.SourceID != "session-alpha" || chunkMap.SourceDigest != manifest.ChunkPlan.SourceDigest || chunkMap.PlanDigest != manifest.ChunkPlan.PlanDigest || chunkMap.RequestedChunker != scenes.Key || chunkMap.Producer.InputModule != "seriatim" || chunkMap.Producer.ChunkModule != scenes.Key || chunkMap.Producer.LLMProfile != manifest.ChunkPlan.ProducerLLMProfile {
t.Fatalf("chunk map identity and producer = %#v, want accepted scene plan provenance", chunkMap)
}
if len(chunkMap.Chunks) != 1 || chunkMap.Chunks[0].ID != "chunk-000001" || chunkMap.Chunks[0].Index != 0 || chunkMap.Chunks[0].SourceRef.SourceID != "session-alpha" || chunkMap.Chunks[0].SourceRef.StartUnitID != 1 || chunkMap.Chunks[0].SourceRef.EndUnitID != 2 || chunkMap.Chunks[0].UnitCount != 2 {
t.Fatalf("chunk map chunks = %#v, want one stable accepted scene range", chunkMap.Chunks)
}
if len(chunkMap.PlanAnnotations) != 0 {
t.Fatalf("chunk map plan annotations = %#v, want none", chunkMap.PlanAnnotations)
}
if len(chunkMap.Chunks[0].Annotations) != 0 {
t.Fatalf("chunk map range annotations = %#v, want none", chunkMap.Chunks[0].Annotations)
}
warnings := readProductionJSON[struct {
Warnings []contracts.Warning `json:"warnings"`
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
if len(warnings.Warnings) != 1 || warnings.Warnings[0].ReasonCode != "scene_boundary_caveat" {
t.Fatalf("warnings = %#v, want one scene boundary warning", warnings.Warnings)
if len(warnings.Warnings) != 0 {
t.Fatalf("warnings = %#v, want none", warnings.Warnings)
}
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 {
t.Fatalf("fake prompt requests = %#v, want one scene and one spell request", fake.requestPrompts())
@@ -506,6 +536,9 @@ func maintainedExampleFiles(t *testing.T) []maintainedExample {
{name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml"), pipelineIDs: []string{"dnd-session"}},
{name: "combat", path: repositoryPath("examples", "dnd-combat-turns.config.yml"), pipelineIDs: []string{"dnd-combat"}},
{name: "npc-grounded", path: repositoryPath("examples", "dnd-npc-grounded.config.yml"), pipelineIDs: []string{"dnd-npc-grounded"}},
{name: "npc-interactions", path: repositoryPath("examples", "dnd-npc-interactions.config.yml"), pipelineIDs: []string{"dnd-npc-interactions"}},
{name: "scene-descriptions", path: repositoryPath("examples", "dnd-scene-descriptions.config.yml"), pipelineIDs: []string{"dnd-scene-descriptions"}},
{name: "scene-chunk-map", path: repositoryPath("examples", "dnd-scene-chunk-map.config.yml"), pipelineIDs: []string{"dnd-scene-chunk-map"}},
}
}
@@ -587,12 +620,30 @@ pipelines:
dnd-session:
input: seriatim
chunk: %s
output:
module: json
options:
include_chunk_map: true
artifacts:
spells:
extract: dnd/spells
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
}
type productionChunkMapIndex struct {
OutputFiles []struct {
File string `json:"file"`
} `json:"output_files"`
ChunkMap *struct {
ArtifactKind contracts.ArtifactKind `json:"artifact_kind"`
File string `json:"file"`
MediaType string `json:"media_type"`
SchemaID string `json:"schema_id"`
SchemaName string `json:"schema_name"`
SchemaVersion string `json:"schema_version"`
} `json:"chunk_map"`
}
func writeProductionContractConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
@@ -667,7 +718,7 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
var content []byte
switch req.PromptID {
case scenes.PromptID:
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2,"short_title":"Opening scene","primary_mode":"Narrative","main_participants":["Aria"],"summary":"The session opens.","boundary_note":"The opening covers the available transcript.","boundary_confidence":"High"}],"boundary_caveats":["The opening boundary is inferred from the short transcript."]}`)
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2}]}`)
case spells.PromptID:
if client.spellResponse != "" {
content = []byte(client.spellResponse)

View File

@@ -24,7 +24,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
const stateTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
const stateTestDigest = "sha256:e511d8906649b78eb639b11215fa57a9652a1a64f4aefa3ed68320dbda46f439"
func TestRunStateSurfaceMatrix(t *testing.T) {
for _, debug := range []bool{false, true} {
@@ -857,7 +857,13 @@ type stateTestInput struct{}
func (stateTestInput) Key() string { return "test/input" }
func (stateTestInput) Parse(_ context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
return &source.SourceDocument{ID: "source", Kind: "text", Format: "text/plain", Digest: stateTestDigest, Units: []source.SourceUnit{{ID: 1, Kind: "text", Text: string(req.Raw), Ref: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}}}}, nil
doc := &source.SourceDocument{ID: "source", Kind: "text", Format: "text/plain", Units: []source.SourceUnit{{ID: 1, Kind: "text", Text: string(req.Raw), Ref: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}}}}
digest, err := source.DigestDocument(doc)
if err != nil {
return nil, err
}
doc.Digest = digest
return doc, nil
}
type stateTestChunker struct{ harness *stateTestHarness }

View File

@@ -20,7 +20,7 @@ func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
func TestRunManifestChunkPlanIsAdditiveAndOmitsPlanContent(t *testing.T) {
manifest := RunManifest{ChunkPlan: &ChunkPlanManifest{
Mode: "auto", Action: "reused", SourceDigest: "sha256:source", PlanDigest: "sha256:plan",
PlanSchemaVersion: "notarius.chunk-plan.v1", RequestedModule: "chunk/current",
PlanSchemaVersion: "notarius.chunk-plan.v2", RequestedModule: "chunk/current",
ProducerInputModule: "input/original", ProducerModule: "chunk/original",
}}
encoded, err := json.Marshal(manifest)

View File

@@ -0,0 +1,65 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.source.chunk_map",
"title": "notarius_source_chunk_map_v1",
"type": "object",
"additionalProperties": false,
"required": [
"source_id",
"source_digest",
"plan_digest",
"requested_chunker",
"producer",
"plan_annotations",
"chunks"
],
"properties": {
"source_id": {"type": "string", "minLength": 1},
"source_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"plan_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"requested_chunker": {"type": "string", "minLength": 1},
"producer": {
"type": "object",
"additionalProperties": false,
"required": ["input_module", "chunk_module"],
"properties": {
"input_module": {"type": "string", "minLength": 1},
"chunk_module": {"type": "string", "minLength": 1},
"llm_profile": {"type": "string", "minLength": 1}
}
},
"plan_annotations": {"$ref": "#/$defs/annotations"},
"chunks": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "index", "source_ref", "unit_count", "annotations"],
"properties": {
"id": {"type": "string", "minLength": 1},
"index": {"type": "integer", "minimum": 0},
"source_ref": {
"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}
}
},
"unit_count": {"type": "integer", "minimum": 1},
"annotations": {"$ref": "#/$defs/annotations"}
}
}
}
},
"$defs": {
"annotations": {
"type": "object",
"propertyNames": {"type": "string", "minLength": 1},
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,400 @@
package chunkmap
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"github.com/santhosh-tekuri/jsonschema/v6"
)
//go:embed assets/schemas/source_chunk_map.v1.json
var schemaAssets embed.FS
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
var (
loadSchemaOnce sync.Once
loadedSchema []byte
compiledSchema *jsonschema.Schema
loadSchemaErr error
)
// Codec owns strict serialization for the durable chunk-map contract.
type Codec struct{}
func New() *Codec { return &Codec{} }
func (c *Codec) Kind() contracts.ArtifactKind { return ArtifactKind }
func (c *Codec) Schema() contracts.ArtifactSchema {
raw, err := c.schemaBytes()
if err != nil {
return contracts.ArtifactSchema{}
}
return contracts.ArtifactSchema{
ID: SchemaID,
Name: SchemaName,
Version: SchemaVersion,
JSONSchema: raw,
}
}
func (c *Codec) MediaType() string { return MediaType }
// Build proves that a durable value describes the exact accepted source plan
// and materialized chunk list supplied by the framework.
func Build(request BuildRequest) (ChunkMap, error) {
if err := source.ValidateDocument(request.Source); err != nil {
return ChunkMap{}, fmt.Errorf("validate source document: %w", err)
}
sourceDigest, err := source.DigestDocument(request.Source)
if err != nil {
return ChunkMap{}, fmt.Errorf("digest source document: %w", err)
}
if sourceDigest != request.Source.Digest {
return ChunkMap{}, fmt.Errorf("source digest %q does not match source document digest %q", sourceDigest, request.Source.Digest)
}
if sourceDigest != request.Plan.SourceDigest {
return ChunkMap{}, fmt.Errorf("source digest %q does not match chunk plan source digest %q", sourceDigest, request.Plan.SourceDigest)
}
plan, err := source.CanonicalizeChunkPlan(request.Plan)
if err != nil {
return ChunkMap{}, fmt.Errorf("canonicalize chunk plan: %w", err)
}
if err := source.ValidateChunkPlan(request.Source, plan); err != nil {
return ChunkMap{}, fmt.Errorf("validate accepted chunk plan: %w", err)
}
planDigest, err := source.DigestChunkPlan(plan)
if err != nil {
return ChunkMap{}, fmt.Errorf("digest accepted chunk plan: %w", err)
}
expected, err := source.MaterializeChunkPlan(request.Source, plan)
if err != nil {
return ChunkMap{}, fmt.Errorf("materialize accepted chunk plan: %w", err)
}
if err := verifyMaterializedChunks(request.Chunks, expected); err != nil {
return ChunkMap{}, err
}
value := ChunkMap{
SourceID: request.Source.ID,
SourceDigest: sourceDigest,
PlanDigest: planDigest,
RequestedChunker: request.RequestedChunker,
Producer: request.Producer,
PlanAnnotations: source.CloneChunkAnnotations(plan.Annotations),
Chunks: make([]Chunk, len(expected)),
}
for index, chunk := range expected {
value.Chunks[index] = Chunk{
ID: chunk.ID,
Index: chunk.Index,
SourceRef: chunk.Ref,
UnitCount: len(chunk.Units),
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
}
}
canonical, err := canonicalize(value)
if err != nil {
return ChunkMap{}, fmt.Errorf("validate chunk map: %w", err)
}
return clone(canonical), nil
}
// Serialize builds and encodes the framework-owned serialized artifact.
func Serialize(request BuildRequest) (contracts.SerializedArtifact, error) {
value, err := Build(request)
if err != nil {
return contracts.SerializedArtifact{}, err
}
codec := New()
content, err := codec.Encode(value)
if err != nil {
return contracts.SerializedArtifact{}, err
}
return contracts.SerializedArtifact{
Kind: ArtifactKind,
Schema: codec.Schema(),
MediaType: MediaType,
Content: content,
}, nil
}
func (c *Codec) Encode(value ChunkMap) ([]byte, error) {
if _, err := c.schemaBytes(); err != nil {
return nil, err
}
canonical, err := canonicalize(clone(value))
if err != nil {
return nil, fmt.Errorf("encode source chunk map: %w", err)
}
content, err := json.Marshal(canonical)
if err != nil {
return nil, fmt.Errorf("encode source chunk map: %w", err)
}
if err := validateSchemaInstance(content); err != nil {
return nil, fmt.Errorf("encode source chunk map: %w", err)
}
return content, nil
}
func (c *Codec) Decode(content []byte) (ChunkMap, error) {
if _, err := c.schemaBytes(); err != nil {
return ChunkMap{}, err
}
if err := validateSchemaInstance(content); err != nil {
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
}
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value ChunkMap
if err := decoder.Decode(&value); err != nil {
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values")
}
canonical, err := canonicalize(value)
if err != nil {
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
}
return clone(canonical), nil
}
func (c *Codec) schemaBytes() ([]byte, error) {
loadSchemaOnce.Do(loadAndCompileSchema)
if loadSchemaErr != nil {
return nil, loadSchemaErr
}
return append([]byte(nil), loadedSchema...), nil
}
func loadAndCompileSchema() {
raw, err := schemaAssets.ReadFile("assets/schemas/source_chunk_map.v1.json")
if err != nil {
loadSchemaErr = fmt.Errorf("read source chunk map schema: %w", err)
return
}
var identity struct {
ID string `json:"$id"`
Title string `json:"title"`
Type string `json:"type"`
Required []string `json:"required"`
}
if err := json.Unmarshal(raw, &identity); err != nil {
loadSchemaErr = fmt.Errorf("decode source chunk map schema: %w", err)
return
}
if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "object" || !hasRequiredFields(identity.Required) {
loadSchemaErr = fmt.Errorf("source chunk map schema identity or required fields are invalid")
return
}
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
loadSchemaErr = fmt.Errorf("parse source chunk map schema: %w", err)
return
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("source-chunk-map-schema.json", schemaDocument); err != nil {
loadSchemaErr = fmt.Errorf("load source chunk map schema: %w", err)
return
}
compiled, err := compiler.Compile("source-chunk-map-schema.json")
if err != nil {
loadSchemaErr = fmt.Errorf("compile source chunk map schema: %w", err)
return
}
loadedSchema = append([]byte(nil), raw...)
compiledSchema = compiled
}
func validateSchemaInstance(content []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(content))
if err != nil {
return fmt.Errorf("payload is not valid JSON: %w", err)
}
if err := compiledSchema.Validate(instance); err != nil {
return fmt.Errorf("payload does not conform to source chunk map schema: %w", err)
}
return nil
}
func hasRequiredFields(required []string) bool {
want := map[string]bool{
"source_id": true, "source_digest": true, "plan_digest": true,
"requested_chunker": true, "producer": true, "plan_annotations": true,
"chunks": true,
}
for _, field := range required {
delete(want, field)
}
return len(want) == 0
}
func canonicalize(value ChunkMap) (ChunkMap, error) {
if err := requireIdentity("source_id", value.SourceID); err != nil {
return ChunkMap{}, err
}
if err := requireDigest("source_digest", value.SourceDigest); err != nil {
return ChunkMap{}, err
}
if err := requireDigest("plan_digest", value.PlanDigest); err != nil {
return ChunkMap{}, err
}
if err := requireIdentity("requested_chunker", value.RequestedChunker); err != nil {
return ChunkMap{}, err
}
if err := requireIdentity("producer.input_module", value.Producer.InputModule); err != nil {
return ChunkMap{}, err
}
if err := requireIdentity("producer.chunk_module", value.Producer.ChunkModule); err != nil {
return ChunkMap{}, err
}
if value.Producer.LLMProfile != "" {
if err := requireIdentity("producer.llm_profile", value.Producer.LLMProfile); err != nil {
return ChunkMap{}, err
}
}
annotations, err := canonicalizeAnnotations("plan_annotations", value.PlanAnnotations)
if err != nil {
return ChunkMap{}, err
}
value.PlanAnnotations = annotations
if len(value.Chunks) == 0 {
return ChunkMap{}, fmt.Errorf("chunks must not be empty")
}
seenIDs := make(map[string]struct{}, len(value.Chunks))
plan := source.ChunkPlan{SourceDigest: value.SourceDigest, Annotations: annotations, Ranges: make([]source.ChunkRange, len(value.Chunks))}
for index := range value.Chunks {
chunk := &value.Chunks[index]
if err := requireIdentity(fmt.Sprintf("chunks[%d].id", index), chunk.ID); err != nil {
return ChunkMap{}, err
}
if _, exists := seenIDs[chunk.ID]; exists {
return ChunkMap{}, fmt.Errorf("chunks[%d].id %q is duplicated", index, chunk.ID)
}
seenIDs[chunk.ID] = struct{}{}
if chunk.Index != index {
return ChunkMap{}, fmt.Errorf("chunks[%d].index = %d, want %d", index, chunk.Index, index)
}
if chunk.SourceRef.SourceID != value.SourceID {
return ChunkMap{}, fmt.Errorf("chunks[%d].source_ref.source_id %q does not match source_id %q", index, chunk.SourceRef.SourceID, value.SourceID)
}
if chunk.SourceRef.StartUnitID <= 0 || chunk.SourceRef.EndUnitID <= 0 {
return ChunkMap{}, fmt.Errorf("chunks[%d].source_ref endpoints must be positive", index)
}
if chunk.UnitCount <= 0 {
return ChunkMap{}, fmt.Errorf("chunks[%d].unit_count must be positive", index)
}
chunkAnnotations, err := canonicalizeAnnotations(fmt.Sprintf("chunks[%d].annotations", index), chunk.Annotations)
if err != nil {
return ChunkMap{}, err
}
chunk.Annotations = chunkAnnotations
plan.Ranges[index] = source.ChunkRange{
StartUnitID: chunk.SourceRef.StartUnitID,
EndUnitID: chunk.SourceRef.EndUnitID,
Annotations: chunkAnnotations,
}
}
planDigest, err := source.DigestChunkPlan(plan)
if err != nil {
return ChunkMap{}, fmt.Errorf("reconstruct plan digest: %w", err)
}
if planDigest != value.PlanDigest {
return ChunkMap{}, fmt.Errorf("plan_digest %q does not match reconstructed plan digest %q", value.PlanDigest, planDigest)
}
return value, nil
}
func canonicalizeAnnotations(name string, annotations source.ChunkAnnotations) (source.ChunkAnnotations, error) {
for namespace := range annotations {
if strings.TrimSpace(namespace) == "" || namespace != strings.TrimSpace(namespace) {
return nil, fmt.Errorf("%s namespace %q must be non-empty and trimmed", name, namespace)
}
}
canonical, err := source.CanonicalizeChunkAnnotations(annotations)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
if canonical == nil {
canonical = source.ChunkAnnotations{}
}
return canonical, nil
}
func requireIdentity(name, value string) error {
if strings.TrimSpace(value) == "" || value != strings.TrimSpace(value) {
return fmt.Errorf("%s must be non-empty and trimmed", name)
}
return nil
}
func requireDigest(name, value string) error {
if !digestPattern.MatchString(value) {
return fmt.Errorf("%s must be a canonical sha256 digest", name)
}
return nil
}
func verifyMaterializedChunks(actual, expected []source.Chunk) error {
if len(actual) != len(expected) {
return fmt.Errorf("materialized chunks length = %d, want %d", len(actual), len(expected))
}
for index := range expected {
got, want := actual[index], expected[index]
if got.ID != want.ID || got.SourceID != want.SourceID || got.Index != want.Index || got.Ref != want.Ref {
return fmt.Errorf("materialized chunk[%d] identity or source range differs from accepted plan", index)
}
if len(got.Units) != len(want.Units) || !sameUnits(got.Units, want.Units) {
return fmt.Errorf("materialized chunk[%d] units differ from accepted source range", index)
}
if !sameAnnotations(got.PlanAnnotations, want.PlanAnnotations) || !sameAnnotations(got.Annotations, want.Annotations) {
return fmt.Errorf("materialized chunk[%d] annotations differ from accepted plan", index)
}
}
return nil
}
func sameUnits(left, right []source.SourceUnit) bool {
leftJSON, leftErr := json.Marshal(left)
rightJSON, rightErr := json.Marshal(right)
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
}
func sameAnnotations(left, right source.ChunkAnnotations) bool {
leftCanonical, leftErr := source.CanonicalizeChunkAnnotations(left)
rightCanonical, rightErr := source.CanonicalizeChunkAnnotations(right)
if leftErr != nil || rightErr != nil {
return false
}
leftJSON, leftErr := json.Marshal(leftCanonical)
rightJSON, rightErr := json.Marshal(rightCanonical)
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
}
func clone(value ChunkMap) ChunkMap {
value.PlanAnnotations = cloneAnnotations(value.PlanAnnotations)
value.Chunks = append([]Chunk(nil), value.Chunks...)
for index := range value.Chunks {
value.Chunks[index].Annotations = cloneAnnotations(value.Chunks[index].Annotations)
}
return value
}
func cloneAnnotations(annotations source.ChunkAnnotations) source.ChunkAnnotations {
cloned := source.CloneChunkAnnotations(annotations)
if cloned == nil {
return source.ChunkAnnotations{}
}
return cloned
}

View File

@@ -0,0 +1,263 @@
package chunkmap
import (
"bytes"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestBuildAndSerializeAcceptedChunkMap(t *testing.T) {
request := acceptedBuildRequest(t)
value, err := Build(request)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if value.SourceID != request.Source.ID || len(value.Chunks) != 2 || value.Chunks[0].UnitCount != 2 || value.Chunks[1].SourceRef.StartUnitID != 20 {
t.Fatalf("Build() = %#v, want exact accepted chunk structure", value)
}
if value.PlanAnnotations == nil || value.Chunks[1].Annotations == nil {
t.Fatalf("Build() annotations = %#v, want explicit maps", value)
}
artifact, err := Serialize(request)
if err != nil {
t.Fatalf("Serialize() error = %v", err)
}
if artifact.Kind != ArtifactKind || artifact.Schema.ID != SchemaID || artifact.Schema.Name != SchemaName || artifact.Schema.Version != SchemaVersion || artifact.MediaType != MediaType || artifact.Metadata != nil {
t.Fatalf("Serialize() = %#v, want fixed artifact envelope without metadata", artifact)
}
decoded, err := New().Decode(artifact.Content)
if err != nil {
t.Fatalf("Decode(Serialize()) error = %v", err)
}
if decoded.PlanDigest != value.PlanDigest || decoded.Chunks[0].ID != "chunk-000001" || decoded.Chunks[1].UnitCount != 1 {
t.Fatalf("Decode(Serialize()) = %#v, want durable chunk map", decoded)
}
}
func TestCodecRoundTripsValidFixture(t *testing.T) {
fixture, err := os.ReadFile("testdata/source_chunk_map.v1.json")
if err != nil {
t.Fatal(err)
}
codec := New()
value, err := codec.Decode(fixture)
if err != nil {
t.Fatalf("Decode(fixture) error = %v", err)
}
encoded, err := codec.Encode(value)
if err != nil {
t.Fatalf("Encode(decoded fixture) error = %v", err)
}
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
}
}
func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {
first := acceptedBuildRequest(t)
second := acceptedBuildRequest(t)
second.Plan.Annotations["test/chunker"] = json.RawMessage(" { \n \t\"label\" : \"fixture\" \n } ")
canonical, err := source.CanonicalizeChunkPlan(second.Plan)
if err != nil {
t.Fatalf("CanonicalizeChunkPlan() error = %v", err)
}
second.Chunks, err = source.MaterializeChunkPlan(second.Source, canonical)
if err != nil {
t.Fatalf("MaterializeChunkPlan() error = %v", err)
}
firstArtifact, err := Serialize(first)
if err != nil {
t.Fatalf("Serialize(first) error = %v", err)
}
secondArtifact, err := Serialize(second)
if err != nil {
t.Fatalf("Serialize(second) error = %v", err)
}
if !bytes.Equal(firstArtifact.Content, secondArtifact.Content) {
t.Fatalf("serialized content differs only because annotation whitespace changed\nfirst: %s\nsecond: %s", firstArtifact.Content, secondArtifact.Content)
}
}
func TestBuildRejectsChunksOutsideAcceptedPlan(t *testing.T) {
request := acceptedBuildRequest(t)
request.Chunks[0].Units[0].ID = 999
if _, err := Build(request); err == nil {
t.Fatal("Build() error = nil, want rejection for chunk units outside accepted source range")
}
}
func TestCodecRejectsInvalidDurableBoundaries(t *testing.T) {
value, err := Build(acceptedBuildRequest(t))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
mutate func(*ChunkMap)
}{
{name: "blank identity", mutate: func(value *ChunkMap) { value.RequestedChunker = " " }},
{name: "malformed digest", mutate: func(value *ChunkMap) { value.SourceDigest = "sha256:ABC" }},
{name: "index mismatch", mutate: func(value *ChunkMap) { value.Chunks[1].Index = 4 }},
{name: "duplicate chunk id", mutate: func(value *ChunkMap) { value.Chunks[1].ID = value.Chunks[0].ID }},
{name: "source mismatch", mutate: func(value *ChunkMap) { value.Chunks[0].SourceRef.SourceID = "other" }},
{name: "invalid range", mutate: func(value *ChunkMap) { value.Chunks[0].SourceRef.StartUnitID = 0 }},
{name: "invalid count", mutate: func(value *ChunkMap) { value.Chunks[0].UnitCount = 0 }},
{name: "invalid namespace", mutate: func(value *ChunkMap) { value.PlanAnnotations[" "] = json.RawMessage(`null`) }},
{name: "invalid annotation", mutate: func(value *ChunkMap) { value.Chunks[0].Annotations["test/chunker"] = json.RawMessage(`{`) }},
{name: "plan digest mismatch", mutate: func(value *ChunkMap) { value.PlanDigest = "sha256:" + strings.Repeat("a", 64) }},
} {
t.Run(test.name, func(t *testing.T) {
candidate := clone(value)
test.mutate(&candidate)
if _, err := New().Encode(candidate); err == nil {
t.Fatal("Encode() error = nil, want invalid durable value rejection")
}
})
}
content, err := New().Encode(value)
if err != nil {
t.Fatal(err)
}
for _, raw := range [][]byte{
append(append([]byte(nil), content[:len(content)-1]...), []byte(`,"unknown":true}`)...),
append(append([]byte(nil), content...), []byte(` {}`)...),
} {
if _, err := New().Decode(raw); err == nil {
t.Fatalf("Decode(%s) error = nil, want strict JSON rejection", raw)
}
}
formatted := bytes.Replace(content, []byte(`{"label":"fixture"}`), []byte("{\n \"label\": \"fixture\"\n}"), 1)
decoded, err := New().Decode(formatted)
if err != nil {
t.Fatalf("Decode(formatted annotations) error = %v", err)
}
if string(decoded.PlanAnnotations["test/chunker"]) != `{"label":"fixture"}` {
t.Fatalf("decoded annotation = %s, want canonical JSON", decoded.PlanAnnotations["test/chunker"])
}
}
func TestDecodeEnforcesRequiredSchemaFieldsAndTypes(t *testing.T) {
request := acceptedBuildRequest(t)
request.Plan.Annotations = nil
var err error
request.Chunks, err = source.MaterializeChunkPlan(request.Source, request.Plan)
if err != nil {
t.Fatal(err)
}
artifact, err := Serialize(request)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
mutate func(map[string]any)
}{
{name: "missing plan annotations", mutate: func(value map[string]any) { delete(value, "plan_annotations") }},
{name: "null plan annotations", mutate: func(value map[string]any) { value["plan_annotations"] = nil }},
{name: "missing first index", mutate: func(value map[string]any) { delete(chunkDocument(value, 0), "index") }},
{name: "null first index", mutate: func(value map[string]any) { chunkDocument(value, 0)["index"] = nil }},
{name: "missing empty chunk annotations", mutate: func(value map[string]any) { delete(chunkDocument(value, 1), "annotations") }},
{name: "null empty chunk annotations", mutate: func(value map[string]any) { chunkDocument(value, 1)["annotations"] = nil }},
{name: "explicit empty llm profile", mutate: func(value map[string]any) {
value["producer"].(map[string]any)["llm_profile"] = ""
}},
} {
t.Run(test.name, func(t *testing.T) {
value := decodeJSONDocument(t, artifact.Content)
test.mutate(value)
content, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if _, err := New().Decode(content); err == nil {
t.Fatalf("Decode(%s) error = nil, want schema rejection", content)
}
})
}
}
func TestEncodeDoesNotMutateValue(t *testing.T) {
value, err := Build(acceptedBuildRequest(t))
if err != nil {
t.Fatal(err)
}
value.Chunks[0].Annotations["test/chunker"] = json.RawMessage(" { \n \"category\" : \"sample\" \n } ")
before := clone(value)
if _, err := New().Encode(value); err != nil {
t.Fatalf("Encode() error = %v", err)
}
if !reflect.DeepEqual(value, before) {
t.Fatalf("Encode() mutated value:\nbefore: %#v\nafter: %#v", before, value)
}
}
func TestChunkMapOwnershipIsIndependent(t *testing.T) {
request := acceptedBuildRequest(t)
first, err := Build(request)
if err != nil {
t.Fatal(err)
}
request.Plan.Annotations["test/chunker"][0] = '['
first.PlanAnnotations["test/chunker"][0] = '['
second, err := Build(acceptedBuildRequest(t))
if err != nil {
t.Fatal(err)
}
if string(second.PlanAnnotations["test/chunker"]) != `{"label":"fixture"}` {
t.Fatalf("Build() shared mutable annotations: %s", second.PlanAnnotations["test/chunker"])
}
}
func decodeJSONDocument(t *testing.T, content []byte) map[string]any {
t.Helper()
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.UseNumber()
var value map[string]any
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
return value
}
func chunkDocument(value map[string]any, index int) map[string]any {
return value["chunks"].([]any)[index].(map[string]any)
}
func acceptedBuildRequest(t *testing.T) BuildRequest {
t.Helper()
document := &source.SourceDocument{
ID: "source-test", Kind: "transcript", Format: "application/json",
Units: []source.SourceUnit{
{ID: 10, Kind: "segment", Text: "First unit.", Ref: source.SourceRef{SourceID: "source-test", StartUnitID: 10, EndUnitID: 10}},
{ID: 3, Kind: "segment", Text: "Second unit.", Ref: source.SourceRef{SourceID: "source-test", StartUnitID: 3, EndUnitID: 3}},
{ID: 20, Kind: "segment", Text: "Third unit.", Ref: source.SourceRef{SourceID: "source-test", StartUnitID: 20, EndUnitID: 20}},
},
}
digest, err := source.DigestDocument(document)
if err != nil {
t.Fatal(err)
}
document.Digest = digest
plan := source.ChunkPlan{
SourceDigest: digest,
Annotations: source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"label":"fixture"}`)},
Ranges: []source.ChunkRange{
{StartUnitID: 10, EndUnitID: 3, Annotations: source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"category":"sample"}`)}},
{StartUnitID: 20, EndUnitID: 20},
},
}
chunks, err := source.MaterializeChunkPlan(document, plan)
if err != nil {
t.Fatal(err)
}
return BuildRequest{
Source: document, Plan: plan, Chunks: chunks, RequestedChunker: "chunk/requested",
Producer: Producer{InputModule: "input/producer", ChunkModule: "chunk/producer", LLMProfile: "profile/test"},
}
}

View File

@@ -0,0 +1,51 @@
// Package chunkmap owns the durable accepted source chunk-map contract.
package chunkmap
import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const (
ArtifactKind contracts.ArtifactKind = "source/chunk-map"
SchemaID = "notarius.source.chunk_map"
SchemaName = "notarius_source_chunk_map_v1"
SchemaVersion = "v1"
MediaType = "application/json"
)
// ChunkMap is the durable representation of one accepted materialized chunk plan.
type ChunkMap struct {
SourceID string `json:"source_id"`
SourceDigest string `json:"source_digest"`
PlanDigest string `json:"plan_digest"`
RequestedChunker string `json:"requested_chunker"`
Producer Producer `json:"producer"`
PlanAnnotations source.ChunkAnnotations `json:"plan_annotations"`
Chunks []Chunk `json:"chunks"`
}
// Producer identifies the component that produced the accepted logical plan.
type Producer struct {
InputModule string `json:"input_module"`
ChunkModule string `json:"chunk_module"`
LLMProfile string `json:"llm_profile,omitempty"`
}
// Chunk describes one accepted materialized range without source content.
type Chunk struct {
ID string `json:"id"`
Index int `json:"index"`
SourceRef source.SourceRef `json:"source_ref"`
UnitCount int `json:"unit_count"`
Annotations source.ChunkAnnotations `json:"annotations"`
}
// BuildRequest supplies the accepted runtime state used to build a chunk map.
type BuildRequest struct {
Source *source.SourceDocument
Plan source.ChunkPlan
Chunks []source.Chunk
RequestedChunker string
Producer Producer
}

View File

@@ -0,0 +1 @@
{"source_id":"source-test","source_digest":"sha256:186b2d30029e7fda40545f88e75ade38f22bff3e5543af4dfeb86587536a01af","plan_digest":"sha256:e50cc7da9070ac8a4339d79b2c206be842846c5c22af7b0ad58f7e9a34ceaf97","requested_chunker":"chunk/requested","producer":{"input_module":"input/producer","chunk_module":"chunk/producer","llm_profile":"profile/test"},"plan_annotations":{"test/chunker":{"label":"fixture"}},"chunks":[{"id":"chunk-000001","index":0,"source_ref":{"source_id":"source-test","start_unit_id":10,"end_unit_id":3},"unit_count":2,"annotations":{"test/chunker":{"category":"sample"}}},{"id":"chunk-000002","index":1,"source_ref":{"source_id":"source-test","start_unit_id":20,"end_unit_id":20},"unit_count":1,"annotations":{}}]}

View File

@@ -281,7 +281,7 @@ func TestFilesystemStoreReportsInvalidRecordsAsRecoverable(t *testing.T) {
return bytes.Replace(data, []byte(`{"schema_version"`), []byte(`{"SENTINEL_UNKNOWN_FIELD":true,"schema_version"`), 1)
}},
{name: "truncated JSON", mutate: func(data []byte) []byte { return data[:len(data)/2] }},
{name: "schema mismatch", mutate: replaceJSON(`notarius.chunk-plan.v1`, `SENTINEL_SCHEMA_VALUE`)},
{name: "legacy v1 record", mutate: replaceJSON(`notarius.chunk-plan.v2`, `notarius.chunk-plan.v1`)},
{name: "source mismatch", mutate: replaceJSON(testSourceDigest, "sha256:"+strings.Repeat("b", 64))},
{name: "plan digest mismatch", mutate: func(data []byte) []byte {
prefix := []byte(`"plan_digest":"sha256:`)

View File

@@ -69,6 +69,16 @@ func CloneSerializedArtifact(artifact SerializedArtifact) SerializedArtifact {
return artifact
}
// CloneSerializedArtifactPointer returns an independently owned artifact when
// one is present.
func CloneSerializedArtifactPointer(artifact *SerializedArtifact) *SerializedArtifact {
if artifact == nil {
return nil
}
cloned := CloneSerializedArtifact(*artifact)
return &cloned
}
func CloneSerializedOutput(output SerializedOutput) SerializedOutput {
output.Artifact = CloneSerializedArtifact(output.Artifact)
return output

View File

@@ -291,6 +291,7 @@ type OutputRequest struct {
Warnings []Warning `json:"warnings,omitempty"`
LLMProfile string `json:"llm_profile,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
ChunkMap *SerializedArtifact `json:"chunk_map,omitempty"`
}
type OutputFile struct {

View File

@@ -26,6 +26,25 @@ func TestCloneReferenceSlotsEmptyInputReturnsNil(t *testing.T) {
}
}
func TestCloneSerializedArtifactPointerOwnsArtifactData(t *testing.T) {
original := &SerializedArtifact{
Kind: "test/artifact",
Schema: ArtifactSchema{ID: "test.schema", JSONSchema: []byte(`{"type":"object"}`)},
Content: []byte(`{"items":[]}`),
Metadata: map[string]any{"count": 1},
}
cloned := CloneSerializedArtifactPointer(original)
original.Schema.JSONSchema[0] = '['
original.Content[0] = '['
original.Metadata["count"] = 2
if cloned == nil || string(cloned.Schema.JSONSchema) != `{"type":"object"}` || string(cloned.Content) != `{"items":[]}` || cloned.Metadata["count"] != 1 {
t.Fatalf("CloneSerializedArtifactPointer() = %#v, want independent artifact data", cloned)
}
if CloneSerializedArtifactPointer(nil) != nil {
t.Fatal("CloneSerializedArtifactPointer(nil) must return nil")
}
}
func TestCloneReferenceSlotsPreservesFields(t *testing.T) {
slots := []ReferenceSlot{
{

View File

@@ -8,7 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const ChunkPlanSchemaVersion = "notarius.chunk-plan.v1"
const ChunkPlanSchemaVersion = "notarius.chunk-plan.v2"
type ChunkPlanProducer struct {
InputModule string `json:"input_module"`

View File

@@ -16,6 +16,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
@@ -251,6 +252,28 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
return failOutput(output), fmt.Errorf("write chunk debug artifact: %w", err)
}
var acceptedChunkMap *contracts.SerializedArtifact
if chunkResult.accepted {
if chunkResult.plan == nil || chunkResult.record == nil {
return failOutput(output), fmt.Errorf("accepted chunk plan is missing plan or producer record")
}
artifact, buildErr := chunkmap.Serialize(chunkmap.BuildRequest{
Source: doc,
Plan: *chunkResult.plan,
Chunks: chunkResult.chunks,
RequestedChunker: input.pipeline.Chunk.Module,
Producer: chunkmap.Producer{
InputModule: chunkResult.record.Producer.InputModule,
ChunkModule: chunkResult.record.Producer.ChunkModule,
LLMProfile: chunkResult.record.Producer.LLMProfile,
},
})
if buildErr != nil {
return failOutput(output), fmt.Errorf("serialize accepted chunk map: %w", buildErr)
}
acceptedChunkMap = &artifact
}
if chunkResult.accepted {
if err := r.runPreparedSteps(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, chunkResult.chunks, &output); err != nil {
return failOutput(output), err
@@ -270,18 +293,22 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
return failOutput(output), err
}
outputStarted := time.Now().UTC()
outputDebugPayload := map[string]any{
"manifest": output.Manifest,
"normalize_outputs": debugSerializedOutputEnvelopes(output.NormalizeOutputs),
"rejected": debugRejectedOutputEnvelopes(output.Rejected),
"warnings": output.Warnings,
"options": redactSensitiveMap(input.pipeline.Output.Options),
"metadata": redactSensitiveMap(input.Metadata),
}
if acceptedChunkMap != nil {
outputDebugPayload["chunk_map"] = debugSerializedOutputEnvelope(contracts.SerializedOutput{Artifact: *acceptedChunkMap})
}
if err := writeDebugTimed(debugRecorder, "output/input.json", debugTimedEnvelope{
Stage: string(StageOutput),
ModuleKey: encoder.Key(),
StartedAt: outputStarted,
Payload: map[string]any{
"manifest": output.Manifest,
"normalize_outputs": debugSerializedOutputEnvelopes(output.NormalizeOutputs),
"rejected": debugRejectedOutputEnvelopes(output.Rejected),
"warnings": output.Warnings,
"options": redactSensitiveMap(input.pipeline.Output.Options),
"metadata": redactSensitiveMap(input.Metadata),
},
Payload: outputDebugPayload,
}); err != nil {
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
}
@@ -296,6 +323,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
Warnings: output.Warnings,
LLMProfile: input.pipeline.Output.LLMProfile,
Metadata: outputMetadata,
ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap),
})
output.Warnings = append(output.Warnings, encoded.Warnings...)
if err != nil {

View File

@@ -10,6 +10,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
@@ -40,6 +41,17 @@ type countingChunkValidator struct {
err error
}
type capturingChunkMapOutput struct {
requests []contracts.OutputRequest
}
func (*capturingChunkMapOutput) Key() string { return "capture/chunk-map" }
func (output *capturingChunkMapOutput) Encode(_ context.Context, request contracts.OutputRequest) (contracts.OutputResult, error) {
output.requests = append(output.requests, request)
return contracts.OutputResult{}, nil
}
func (*countingChunkValidator) Name() string { return "test/counting-chunks" }
func (*countingChunkValidator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
@@ -205,6 +217,116 @@ func TestRunnerChunkPlanHitUsesStoredProducerProvenance(t *testing.T) {
}
}
func TestRunnerProvidesAcceptedChunkMapToOutput(t *testing.T) {
for _, test := range []struct {
name string
configure func(*PreparedPipeline, source.ChunkPlan) (RunInput, chunkmap.Producer)
}{
{
name: "bypassed plan",
configure: func(prepared *PreparedPipeline, _ source.ChunkPlan) (RunInput, chunkmap.Producer) {
return RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheBypass}, chunkmap.Producer{
InputModule: prepared.input.Key(), ChunkModule: prepared.chunker.Key(),
}
},
},
{
name: "cached plan",
configure: func(prepared *PreparedPipeline, plan source.ChunkPlan) (RunInput, chunkmap.Producer) {
prepared.resolved.Chunk.Module = "chunk/requested"
record := chunkPlanRecord(t, prepared, plan)
record.Producer.InputModule = "input/original"
record.Producer.ChunkModule = "chunk/original"
record.Producer.LLMProfile = "original-profile"
return RunInput{
Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto,
ChunkPlans: &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}},
}, chunkmap.Producer{InputModule: "input/original", ChunkModule: "chunk/original", LLMProfile: "original-profile"}
},
},
} {
t.Run(test.name, func(t *testing.T) {
prepared, plan := preparedTerminalDebugPipeline(t)
encoder := &capturingChunkMapOutput{}
prepared.output = encoder
input, wantProducer := test.configure(prepared, plan)
if _, err := New().Run(context.Background(), input); err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(encoder.requests) != 1 || encoder.requests[0].ChunkMap == nil {
t.Fatalf("output requests = %#v, want one accepted chunk map", encoder.requests)
}
artifact := encoder.requests[0].ChunkMap
if artifact.Kind != chunkmap.ArtifactKind || artifact.Schema.ID != chunkmap.SchemaID || artifact.MediaType != chunkmap.MediaType {
t.Fatalf("chunk map artifact = %#v, want fixed serialized identity", artifact)
}
value, err := chunkmap.New().Decode(artifact.Content)
if err != nil {
t.Fatalf("decode output chunk map: %v", err)
}
if value.RequestedChunker != prepared.resolved.Chunk.Module || value.Producer != wantProducer {
t.Fatalf("chunk map = %#v, want current request and producer %#v", value, wantProducer)
}
})
}
}
func TestRunnerOmitsChunkMapForRejectedPlan(t *testing.T) {
prepared, _ := preparedTerminalDebugPipeline(t)
encoder := &capturingChunkMapOutput{}
prepared.output = encoder
prepared.chunkValidators.validators = []preparedValidator{{
resolved: ResolvedValidator{Binding: Binding("reject/chunk"), Target: ValidatorTargetChunk},
chunk: &countingChunkValidator{result: contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "not accepted"}},
}}
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(output.Rejected) != 1 || len(encoder.requests) != 1 || encoder.requests[0].ChunkMap != nil {
t.Fatalf("output = %#v requests = %#v, want rejected plan without chunk map", output.Rejected, encoder.requests)
}
}
func TestRunnerRetainsChunkMapAfterLaneRejection(t *testing.T) {
prepared, _ := preparedTerminalDebugPipeline(t)
encoder := &capturingChunkMapOutput{}
prepared.output = encoder
prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{{
resolved: ResolvedValidator{Binding: Binding("reject/lane"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "not accepted"}, nil
},
}}
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(output.Rejected) != 1 || len(encoder.requests) != 1 || encoder.requests[0].ChunkMap == nil {
t.Fatalf("output = %#v requests = %#v, want lane rejection with accepted chunk map", output.Rejected, encoder.requests)
}
}
func TestRunnerChunkMapRequestDoesNotAliasStoredPlan(t *testing.T) {
prepared, plan := preparedTerminalDebugPipeline(t)
encoder := &capturingChunkMapOutput{}
prepared.output = encoder
record := chunkPlanRecord(t, prepared, plan)
record.Plan.Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Opening"}`)}
store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store}); err != nil {
t.Fatalf("Run() error = %v", err)
}
store.record.Plan.Annotations["dnd/scenes"][0] = '['
value, err := chunkmap.New().Decode(encoder.requests[0].ChunkMap.Content)
if err != nil {
t.Fatalf("decode captured chunk map after store mutation: %v", err)
}
if string(value.PlanAnnotations["dnd/scenes"]) != `{"title":"Opening"}` {
t.Fatalf("captured chunk map aliases stored plan: %s", value.PlanAnnotations["dnd/scenes"])
}
}
func TestRunnerChunkPlanManifestRetainsCandidateOnRejection(t *testing.T) {
prepared, _ := preparedTerminalDebugPipeline(t)
validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: false, ReasonCode: "policy", Message: "no"}}
@@ -280,19 +402,19 @@ func TestRunnerRegeneratesStructurallyInvalidHit(t *testing.T) {
}
}
func TestRunnerReusesCrossDomainAnnotationsAsOptionalData(t *testing.T) {
func TestRunnerReusesAnnotationsFromDifferentChunkerAsOptionalData(t *testing.T) {
prepared, plan := preparedTerminalDebugPipeline(t)
plan.Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"boundary_caveats":["uncertain"]}`)}
plan.Ranges[0].Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Opening"}`)}
plan.Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"label":"fixture"}`)}
plan.Ranges[0].Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"category":"sample"}`)}
record := chunkPlanRecord(t, prepared, plan)
record.Producer.ChunkModule = "dnd/scenes"
record.Producer.ChunkModule = "chunk/producer"
store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
if err != nil {
t.Fatal(err)
}
if output.Manifest.ChunkPlan.ProducerModule != "dnd/scenes" || output.Manifest.ChunkPlan.Action != "reused" {
t.Fatalf("cross-domain annotation plan was not reused: %#v", output.Manifest.ChunkPlan)
if output.Manifest.ChunkPlan.ProducerModule != "chunk/producer" || output.Manifest.ChunkPlan.Action != "reused" {
t.Fatalf("different-chunker annotation plan was not reused: %#v", output.Manifest.ChunkPlan)
}
}

View File

@@ -1,52 +1,8 @@
Good reasons to start a new scene include:
- the party moves to a new location;
- a combat encounter begins or ends;
- combat changes into a substantially different phase;
- the party shifts between combat, exploration, social interaction, discussion,
planning, travel, rest, or downtime;
- a new NPC, faction, threat, or objective becomes central;
- the party completes one immediate goal and begins another;
- a major table-level rules discussion interrupts and materially changes play.
Cover the complete provided transcript from its first source unit to its last
source unit. Return scenes in source-unit order with no gaps or overlaps. Use
only positive integer source-unit IDs from the transcript, and give every scene
one inclusive `start_unit_id` and one inclusive `end_unit_id`.
Do not start a new scene merely because:
- the speaker changes;
- a new combat round begins;
- a player asks a brief rules question;
- there is a joke, aside, or short table comment;
- a character takes a routine turn;
- the same encounter continues without a meaningful change in situation.
dnd/scenes boundary policy:
- cover the full provided transcript from the first source unit to the last
source unit;
- return sequential scenes with no gaps;
- do not overlap scenes;
- preserve source-unit order;
- use integer source-unit IDs from the transcript;
- each scene must have start_unit_id and end_unit_id;
- do not include final chunk IDs or chunk indexes.
For each scene:
- short_title should be brief and factual;
- primary_mode must be Recap, Discussion, Combat, or Narrative;
- main_participants should include only principal characters, NPCs, factions, or
groups involved;
- summary should be factual and compact, usually one to three sentences;
- boundary_note should explain why the scene begins at start_unit_id and ends at
end_unit_id;
- boundary_confidence must be High, Medium, or Low.
Primary mode guidance:
- Use Recap for opening recap, initiative setup, session framing, or immediate
continuation from prior events.
- Use Discussion when the party is primarily discussing options or choosing a
course of action.
- Use Combat when active combat or combat-resolution mechanics dominate.
- Use Narrative for all other non-combat gameplay, including exploration, social
interactions, shopping, preparation, travel, rest, and downtime.
In boundary_caveats, list overall caveats about scene divisions. Include scenes
that could reasonably be split differently, combat phases that were kept
together, gradual transitions, or places where map context would have helped.
Return exactly one JSON object and no explanatory text.
Return exactly one JSON object and no explanatory text. The object must contain
only a non-empty `scenes` array. Each scene object must contain only
`start_unit_id` and `end_unit_id`.

View File

@@ -1,5 +1,18 @@
Divide the provided transcript into coherent Dungeons & Dragons scenes for the
dnd/scenes chunk module.
`dnd/scenes` chunk module.
A scene is a coherent unit of play. Start a new scene when there is a meaningful
change in location, objective, threat, activity, encounter, or mode of play.
A scene is a coherent unit of play. Start a new scene when the transcript
establishes a meaningful change in location, objective, threat, activity,
encounter, or mode of play. Good reasons include a material move, beginning or
ending combat, a substantially different encounter phase, a shift between
combat, exploration, social interaction, planning, travel, rest, or downtime,
a change in the central NPC, faction, threat, or objective, or a sustained
table-level interruption that materially changes the activity.
Do not split a scene merely because a speaker or combat round changes, a
routine turn occurs, or the table briefly digresses. Prefer fewer coherent
scenes over speculative or fine-grained boundaries.
Return only inclusive `start_unit_id` and `end_unit_id` endpoints for each
scene. Do not return titles, modes, participants, summaries, boundary notes,
confidence, caveats, final chunk IDs, or chunk indexes.

View File

@@ -3,10 +3,7 @@
"$id": "notarius.dnd.scenes",
"type": "object",
"additionalProperties": false,
"required": [
"scenes",
"boundary_caveats"
],
"required": ["scenes"],
"properties": {
"scenes": {
"type": "array",
@@ -14,16 +11,7 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"start_unit_id",
"end_unit_id",
"short_title",
"primary_mode",
"main_participants",
"summary",
"boundary_note",
"boundary_confidence"
],
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {
"type": "integer",
@@ -32,53 +20,9 @@
"end_unit_id": {
"type": "integer",
"minimum": 1
},
"short_title": {
"type": "string",
"minLength": 1
},
"primary_mode": {
"type": "string",
"enum": [
"Recap",
"Discussion",
"Combat",
"Narrative"
]
},
"main_participants": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"summary": {
"type": "string",
"minLength": 1
},
"boundary_note": {
"type": "string",
"minLength": 1
},
"boundary_confidence": {
"type": "string",
"enum": [
"High",
"Medium",
"Low"
]
}
}
}
},
"boundary_caveats": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
}

View File

@@ -2,9 +2,7 @@ package scenes
import (
"context"
"encoding/json"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -22,8 +20,6 @@ var providedCapabilities = []string{
"chunks",
}
const annotationNamespace = "dnd/scenes"
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
Party: "Optional party roster reference material used only for scene disambiguation.",
@@ -113,18 +109,11 @@ func (c *Chunker) Plan(ctx context.Context, req contracts.ChunkRequest) (contrac
return contracts.ChunkPlanResult{}, chunkerErrorf("complete structured output: %w", err)
}
warnings, err := warningsFromCaveats(response.BoundaryCaveats)
plan, err := planFromResponse(req.Source, response)
if err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
}
plan, err := planFromResponse(req.Source, response, warnings)
if err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
}
return contracts.ChunkPlanResult{
Plan: plan,
Warnings: warnings,
}, nil
return contracts.ChunkPlanResult{Plan: plan}, nil
}
func ModuleSpec() pipeline.ModuleSpec {
@@ -159,7 +148,7 @@ func DecodeOptions(options map[string]any) (Options, error) {
return Options{}, nil
}
func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnings []contracts.Warning) (source.ChunkPlan, error) {
func planFromResponse(doc *source.SourceDocument, response chunkResponse) (source.ChunkPlan, error) {
if response.Scenes == nil {
return source.ChunkPlan{}, fmt.Errorf("scenes must be present")
}
@@ -175,21 +164,25 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
previousEnd := -1
for i, scene := range response.Scenes {
normalized, err := normalizeScene(doc, i, scene)
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
if err != nil {
return source.ChunkPlan{}, err
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
}
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
}
startIndex, ok := unitIndexes[normalized.StartUnitID]
startIndex, ok := unitIndexes[startUnitID]
if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, normalized.StartUnitID)
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
}
endIndex, ok := unitIndexes[normalized.EndUnitID]
endIndex, ok := unitIndexes[endUnitID]
if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, normalized.EndUnitID)
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
}
if startIndex > endIndex {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, normalized.StartUnitID, normalized.EndUnitID)
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, startUnitID, endUnitID)
}
if i == 0 && startIndex != 0 {
@@ -205,142 +198,21 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin
}
previousEnd = endIndex
annotation, err := json.Marshal(struct {
ShortTitle string `json:"short_title"`
PrimaryMode string `json:"primary_mode"`
MainParticipants []string `json:"main_participants"`
Summary string `json:"summary"`
BoundaryNote string `json:"boundary_note"`
BoundaryConfidence string `json:"boundary_confidence"`
}{normalized.ShortTitle, normalized.PrimaryMode, normalized.MainParticipants, normalized.Summary, normalized.BoundaryNote, normalized.BoundaryConfidence})
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("encode scene[%d] annotation: %w", i, err)
}
ranges = append(ranges, source.ChunkRange{
StartUnitID: normalized.StartUnitID,
EndUnitID: normalized.EndUnitID,
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
StartUnitID: startUnitID,
EndUnitID: endUnitID,
})
}
if previousEnd != len(doc.Units)-1 {
return source.ChunkPlan{}, fmt.Errorf("final scene must end at final source unit %d", doc.Units[len(doc.Units)-1].ID)
}
caveats := make([]string, 0, len(warnings))
for _, warning := range warnings {
caveats = append(caveats, warning.Message)
}
annotation, err := json.Marshal(struct {
BoundaryCaveats []string `json:"boundary_caveats"`
}{BoundaryCaveats: caveats})
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("encode plan annotation: %w", err)
}
return source.ChunkPlan{
SourceDigest: doc.Digest,
Ranges: ranges,
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
}, nil
}
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
if err != nil {
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
}
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
if err != nil {
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
}
out := normalizedScene{
StartUnitID: startUnitID,
EndUnitID: endUnitID,
ShortTitle: strings.TrimSpace(scene.ShortTitle),
PrimaryMode: strings.TrimSpace(scene.PrimaryMode),
Summary: strings.TrimSpace(scene.Summary),
BoundaryNote: strings.TrimSpace(scene.BoundaryNote),
BoundaryConfidence: strings.TrimSpace(scene.BoundaryConfidence),
}
requiredInts := map[string]int{
"start_unit_id": out.StartUnitID,
"end_unit_id": out.EndUnitID,
}
for field, value := range requiredInts {
if value <= 0 {
return normalizedScene{}, fmt.Errorf("scene[%d] %s must be positive", index, field)
}
}
required := map[string]string{
"short_title": out.ShortTitle,
"primary_mode": out.PrimaryMode,
"summary": out.Summary,
"boundary_note": out.BoundaryNote,
"boundary_confidence": out.BoundaryConfidence,
}
for field, value := range required {
if value == "" {
return normalizedScene{}, fmt.Errorf("scene[%d] %s must not be empty", index, field)
}
}
if !validPrimaryMode(out.PrimaryMode) {
return normalizedScene{}, fmt.Errorf("scene[%d] primary_mode %q is not supported", index, out.PrimaryMode)
}
if !validBoundaryConfidence(out.BoundaryConfidence) {
return normalizedScene{}, fmt.Errorf("scene[%d] boundary_confidence %q is not supported", index, out.BoundaryConfidence)
}
if len(scene.MainParticipants) == 0 {
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants must not be empty", index)
}
out.MainParticipants = make([]string, 0, len(scene.MainParticipants))
for participantIndex, participant := range scene.MainParticipants {
trimmed := strings.TrimSpace(participant)
if trimmed == "" {
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants[%d] must not be empty", index, participantIndex)
}
out.MainParticipants = append(out.MainParticipants, trimmed)
}
return out, nil
}
func validPrimaryMode(value string) bool {
switch value {
case "Recap", "Discussion", "Combat", "Narrative":
return true
default:
return false
}
}
func validBoundaryConfidence(value string) bool {
switch value {
case "High", "Medium", "Low":
return true
default:
return false
}
}
func warningsFromCaveats(caveats []string) ([]contracts.Warning, error) {
if len(caveats) == 0 {
return nil, nil
}
warnings := make([]contracts.Warning, 0, len(caveats))
for i, caveat := range caveats {
trimmed := strings.TrimSpace(caveat)
if trimmed == "" {
return nil, fmt.Errorf("boundary_caveats[%d] must not be empty after trimming", i)
}
warnings = append(warnings, contracts.Warning{
Scope: Key,
ReasonCode: "scene_boundary_caveat",
Message: trimmed,
})
}
return warnings, nil
}
func chunkerErrorf(format string, args ...any) error {
return fmt.Errorf("dnd scenes chunker: "+format, args...)
}

View File

@@ -110,32 +110,13 @@ func wantReferenceSlots() []contracts.ReferenceSlot {
}
}
func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput(t *testing.T) {
func TestPlanReturnsAnnotationFreeSceneRangesFromStructuredOutput(t *testing.T) {
client := &fakeScenesLLMClient{
response: chunkResponse{
Scenes: []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(2),
ShortTitle: " Goblin parley ",
PrimaryMode: "Discussion",
MainParticipants: []string{" Aria ", "Goblin scout"},
Summary: " The party negotiates with a scout. ",
BoundaryNote: " The scene covers the discussion before fighting starts. ",
BoundaryConfidence: "High",
},
{
StartUnitID: shared.UnitRefFromInt(3),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Ambush at the gate",
PrimaryMode: "Combat",
MainParticipants: []string{"Aria", "Goblin ambushers"},
Summary: "The goblins attack at the gate.",
BoundaryNote: "Combat begins and resolves the immediate threat.",
BoundaryConfidence: "Medium",
},
scene(1, 2),
scene(3, 4),
},
BoundaryCaveats: []string{" The transition into combat is gradual. "},
},
}
@@ -185,55 +166,67 @@ func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput(t *testing.T)
t.Fatalf("ranges = %#v, want two", result.Plan.Ranges)
}
for i, want := range wantRanges {
if result.Plan.Ranges[i].StartUnitID != want.StartUnitID || result.Plan.Ranges[i].EndUnitID != want.EndUnitID {
got := result.Plan.Ranges[i]
if got.StartUnitID != want.StartUnitID || got.EndUnitID != want.EndUnitID {
t.Fatalf("range[%d] = %#v, want %#v", i, result.Plan.Ranges[i], want)
}
if len(got.Annotations) != 0 {
t.Fatalf("range[%d] annotations = %#v, want absent", i, got.Annotations)
}
}
var firstAnnotation map[string]any
if err := json.Unmarshal(result.Plan.Ranges[0].Annotations[annotationNamespace], &firstAnnotation); err != nil {
t.Fatalf("decode first scene annotation: %v", err)
if len(result.Plan.Annotations) != 0 {
t.Fatalf("plan annotations = %#v, want absent", result.Plan.Annotations)
}
wantFirst := map[string]any{
"short_title": "Goblin parley", "primary_mode": "Discussion",
"main_participants": []any{"Aria", "Goblin scout"},
"summary": "The party negotiates with a scout.",
"boundary_note": "The scene covers the discussion before fighting starts.",
"boundary_confidence": "High",
if len(result.Warnings) != 0 {
t.Fatalf("warnings = %#v, want absent", result.Warnings)
}
if !reflect.DeepEqual(firstAnnotation, wantFirst) {
t.Fatalf("first scene annotation = %#v, want %#v", firstAnnotation, wantFirst)
}
if len(firstAnnotation) != 6 {
t.Fatalf("first scene annotation keys = %#v, want exact six fields", firstAnnotation)
}
var planAnnotation map[string]any
if err := json.Unmarshal(result.Plan.Annotations[annotationNamespace], &planAnnotation); err != nil {
t.Fatalf("decode plan annotation: %v", err)
}
if !reflect.DeepEqual(planAnnotation, map[string]any{"boundary_caveats": []any{"The transition into combat is gradual."}}) || len(planAnnotation) != 1 {
t.Fatalf("plan annotation = %#v, want normalized caveats only", planAnnotation)
}
if got := result.Warnings; len(got) != 1 ||
got[0].Scope != Key ||
got[0].ReasonCode != "scene_boundary_caveat" ||
got[0].Message != "The transition into combat is gradual." {
t.Fatalf("Warnings = %#v, want boundary caveat warning", got)
}
func TestPlanUsesDocumentOrderForNonconsecutiveUnitIDs(t *testing.T) {
doc := &source.SourceDocument{
ID: "session-nonnumeric",
Kind: "transcript",
Format: "application/vnd.seriatim.minimal+json",
Digest: "sha256:nonnumeric",
Units: []source.SourceUnit{
{ID: 10, Kind: "transcript_segment", Text: "The party arrives.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 10, EndUnitID: 10}},
{ID: 3, Kind: "transcript_segment", Text: "The party explores.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 3, EndUnitID: 3}},
{ID: 20, Kind: "transcript_segment", Text: "The party rests.", Ref: source.SourceRef{SourceID: "session-nonnumeric", StartUnitID: 20, EndUnitID: 20}},
},
}
req := chunkRequest()
req.Source = doc
t.Run("accepts document-ordered ranges", func(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{Scenes: []sceneResponse{
scene(10, 3),
scene(20, 20),
}}}
result, err := newChunker(t, client).Plan(context.Background(), req)
if err != nil {
t.Fatalf("Plan() error = %v, want nil", err)
}
if got := result.Plan.Ranges; !reflect.DeepEqual(got, []source.ChunkRange{{StartUnitID: 10, EndUnitID: 3}, {StartUnitID: 20, EndUnitID: 20}}) {
t.Fatalf("ranges = %#v, want source-document order", got)
}
})
t.Run("rejects reversed document positions", func(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{Scenes: []sceneResponse{
scene(3, 10),
scene(20, 20),
}}}
_, err := newChunker(t, client).Plan(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "appears after") {
t.Fatalf("Plan() error = %v, want document-position reversal", err)
}
})
}
func TestPlanPassesReferencesAsPromptInputs(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{
Scenes: []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Ambush",
PrimaryMode: "Combat",
MainParticipants: []string{"Aria"},
Summary: "The party is ambushed.",
BoundaryNote: "One scene covers the short fixture.",
BoundaryConfidence: "High",
},
scene(1, 4),
},
}}
req := chunkRequest()
@@ -298,51 +291,6 @@ func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
}
}
func TestPlanRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) {
client := &fakeScenesLLMClient{
response: chunkResponse{
Scenes: validSceneResponse().Scenes,
BoundaryCaveats: []string{
" ",
},
},
}
_, err := newChunker(t, client).Plan(context.Background(), chunkRequest())
if err == nil {
t.Fatal("Plan() error = nil, want malformed structured output error")
}
if !strings.Contains(err.Error(), "dnd scenes chunker") || !strings.Contains(err.Error(), "malformed structured output") || !strings.Contains(err.Error(), "boundary_caveats[0]") {
t.Fatalf("Plan() error = %q, want malformed boundary caveat context", err.Error())
}
}
func TestPlanDefensivelyCopiesAnnotationValues(t *testing.T) {
doc := sceneSourceDocument()
client := &fakeScenesLLMClient{response: validSceneResponse()}
result, err := newChunker(t, client).Plan(context.Background(), contracts.ChunkRequest{
Source: doc,
SourceInput: sceneSourceInput(),
SessionID: "session-123",
LLMProfile: "profile-scenes",
})
if err != nil {
t.Fatalf("Plan() error = %v, want nil", err)
}
client.response.Scenes[0].MainParticipants[0] = "mutated"
var annotation struct {
MainParticipants []string `json:"main_participants"`
}
if err := json.Unmarshal(result.Plan.Ranges[0].Annotations[annotationNamespace], &annotation); err != nil {
t.Fatalf("decode annotation: %v", err)
}
if !reflect.DeepEqual(annotation.MainParticipants, []string{"Aria"}) {
t.Fatalf("participants = %#v, want defensive copy", annotation.MainParticipants)
}
}
func TestChunkerManifestMetadataIncludesPromptAndSchemaProvenance(t *testing.T) {
metadata := newChunker(t, &fakeScenesLLMClient{}).ManifestMetadata()
@@ -455,38 +403,6 @@ func TestPlanRejectsMalformedStructuredOutput(t *testing.T) {
}),
want: "final scene",
},
{
name: "empty metadata field",
response: replaceScenes(validSceneResponse(), []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: " ",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"},
Summary: "Summary.",
BoundaryNote: "Note.",
BoundaryConfidence: "High",
},
}),
want: "short_title",
},
{
name: "empty participant",
response: replaceScenes(validSceneResponse(), []sceneResponse{
{
StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Title",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria", " "},
Summary: "Summary.",
BoundaryNote: "Note.",
BoundaryConfidence: "High",
},
}),
want: "main_participants",
},
}
for _, tt := range tests {
@@ -559,7 +475,6 @@ func validSceneResponse() chunkResponse {
Scenes: []sceneResponse{
scene(1, 4),
},
BoundaryCaveats: []string{},
}
}
@@ -570,14 +485,8 @@ func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse
func scene(startUnitID int, endUnitID int) sceneResponse {
return sceneResponse{
StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: shared.UnitRefFromInt(endUnitID),
ShortTitle: "Scene title",
PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"},
Summary: "A compact summary.",
BoundaryNote: "The source units form one coherent scene.",
BoundaryConfidence: "High",
StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: shared.UnitRefFromInt(endUnitID),
}
}

View File

@@ -3,28 +3,10 @@ package scenes
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
type chunkResponse struct {
Scenes []sceneResponse `json:"scenes"`
BoundaryCaveats []string `json:"boundary_caveats"`
Scenes []sceneResponse `json:"scenes"`
}
type sceneResponse struct {
StartUnitID shared.UnitRef `json:"start_unit_id"`
EndUnitID shared.UnitRef `json:"end_unit_id"`
ShortTitle string `json:"short_title"`
PrimaryMode string `json:"primary_mode"`
MainParticipants []string `json:"main_participants"`
Summary string `json:"summary"`
BoundaryNote string `json:"boundary_note"`
BoundaryConfidence string `json:"boundary_confidence"`
}
type normalizedScene struct {
StartUnitID int
EndUnitID int
ShortTitle string
PrimaryMode string
MainParticipants []string
Summary string
BoundaryNote string
BoundaryConfidence string
StartUnitID shared.UnitRef `json:"start_unit_id"`
EndUnitID shared.UnitRef `json:"end_unit_id"`
}

View File

@@ -35,7 +35,7 @@ func TestLoadResponseSchemaForScenes(t *testing.T) {
}
}
func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
func TestResponseSchemaValidatesMinimalSceneResponses(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
@@ -55,11 +55,15 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
mutate func(map[string]any)
}{
{
name: "obsolete segment boundaries",
name: "missing scenes",
mutate: func(response map[string]any) {
scene := response["scenes"].([]any)[0].(map[string]any)
scene["start_segment_id"] = 1
scene["end_segment_id"] = 2
delete(response, "scenes")
},
},
{
name: "empty scenes",
mutate: func(response map[string]any) {
response["scenes"] = []any{}
},
},
{
@@ -75,27 +79,21 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
},
},
{
name: "invalid primary mode",
name: "non-integer endpoint",
mutate: func(response map[string]any) {
response["scenes"].([]any)[0].(map[string]any)["primary_mode"] = "Unknown"
response["scenes"].([]any)[0].(map[string]any)["start_unit_id"] = 1.5
},
},
{
name: "invalid boundary confidence",
name: "unknown top-level field",
mutate: func(response map[string]any) {
response["scenes"].([]any)[0].(map[string]any)["boundary_confidence"] = "Unknown"
response["boundary_caveats"] = []any{}
},
},
{
name: "empty boundary caveat",
name: "unknown scene field",
mutate: func(response map[string]any) {
response["boundary_caveats"] = []any{""}
},
},
{
name: "unknown property",
mutate: func(response map[string]any) {
response["unexpected"] = true
response["scenes"].([]any)[0].(map[string]any)["short_title"] = "Old contract"
},
},
}
@@ -116,21 +114,7 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
}
func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) {
raw := []byte(`{
"scenes": [
{
"start_unit_id": 1,
"end_unit_id": 3,
"short_title": "Ambush",
"primary_mode": "Combat",
"main_participants": ["Aria"],
"summary": "The party fights.",
"boundary_note": "Combat starts and resolves.",
"boundary_confidence": "High"
}
],
"boundary_caveats": []
}`)
raw := []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":3}]}`)
var response chunkResponse
if err := json.Unmarshal(raw, &response); err != nil {
@@ -159,7 +143,7 @@ func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
t.Fatalf("schema JSON was mutated: %s", second.JSONSchema)
}
if len(second.JSONSchema) > 0 && second.JSONSchema[0] == '[' {
t.Fatalf("schema JSON did not use defensive copy")
t.Fatal("schema JSON did not use defensive copy")
}
}
@@ -167,17 +151,10 @@ func validSceneSchemaResponse() map[string]any {
return map[string]any{
"scenes": []any{
map[string]any{
"start_unit_id": 1,
"end_unit_id": 3,
"short_title": "Ambush",
"primary_mode": "Combat",
"main_participants": []any{"Aria"},
"summary": "The party fights.",
"boundary_note": "Combat starts and resolves.",
"boundary_confidence": "High",
"start_unit_id": 1,
"end_unit_id": 3,
},
},
"boundary_caveats": []any{},
}
}

View File

@@ -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
}
}
}
}
}
}
}
}
}

View 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
}
}

View 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
}

View 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}
]
}
]
}

View File

@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.scene_descriptions",
"type": "object",
"additionalProperties": false,
"required": ["scenes"],
"properties": {
"scenes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "source_ref", "kind", "title", "summary"],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"source_ref": {
"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
}
}
},
"kind": {
"type": "string",
"enum": ["combat", "narrative", "recap", "meta"]
},
"title": {
"type": "string",
"minLength": 1
},
"summary": {
"type": "string",
"minLength": 1
}
}
}
}
}
}

View File

@@ -0,0 +1,137 @@
package scenedescriptions
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.scene_descriptions"
SchemaName = "notarius_dnd_scene_descriptions_v1"
SchemaVersion = "v1"
MediaType = "application/json"
)
//go:embed assets/schemas/dnd_scene_descriptions.v1.json
var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil)
type Codec struct{}
func New() *Codec { return &Codec{} }
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.SceneDescriptionListKind }
func (c *Codec) Schema() contracts.ArtifactSchema {
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_scene_descriptions.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.SceneDescriptionList) map[string]any {
return map[string]any{"scene_count": len(value.Scenes)}
}
func (c *Codec) Encode(value dnd.SceneDescriptionList) ([]byte, error) {
if err := validate(value); err != nil {
return nil, fmt.Errorf("encode dnd scene description list: %w", err)
}
return c.EncodeCandidate(value)
}
// EncodeCandidate provides the durable representation before typed validators
// have approved a value.
func (c *Codec) EncodeCandidate(value dnd.SceneDescriptionList) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode dnd scene description list: %w", err)
}
return content, nil
}
func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) {
value, err := c.DecodeCandidate(content)
if err != nil {
return dnd.SceneDescriptionList{}, err
}
if err := validate(value); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: %w", err)
}
return value, nil
}
// DecodeCandidate reads one strict durable JSON value before typed validators
// have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.SceneDescriptionList, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value dnd.SceneDescriptionList
if err := decoder.Decode(&value); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: multiple JSON values")
}
return value, nil
}
func validate(value dnd.SceneDescriptionList) error {
if value.Scenes == nil {
return fmt.Errorf("scenes must be present")
}
if len(value.Scenes) == 0 {
return fmt.Errorf("scenes must not be empty")
}
for index, scene := range value.Scenes {
prefix := fmt.Sprintf("scenes[%d]", index)
if strings.TrimSpace(scene.ID) == "" {
return fmt.Errorf("%s.id must not be empty", prefix)
}
if !validSceneKind(scene.Kind) {
return fmt.Errorf("%s.kind must be supported", prefix)
}
if strings.TrimSpace(scene.Title) == "" {
return fmt.Errorf("%s.title must not be empty", prefix)
}
if strings.TrimSpace(scene.Summary) == "" {
return fmt.Errorf("%s.summary must not be empty", prefix)
}
if strings.TrimSpace(scene.SourceRef.SourceID) == "" {
return fmt.Errorf("%s.source_ref.source_id must not be empty", prefix)
}
if scene.SourceRef.StartUnitID <= 0 {
return fmt.Errorf("%s.source_ref.start_unit_id must be positive", prefix)
}
if scene.SourceRef.EndUnitID <= 0 {
return fmt.Errorf("%s.source_ref.end_unit_id must be positive", prefix)
}
}
return nil
}
func validSceneKind(value dnd.SceneKind) bool {
switch value {
case dnd.SceneKindCombat, dnd.SceneKindNarrative, dnd.SceneKindRecap, dnd.SceneKindMeta:
return true
default:
return false
}
}

View File

@@ -0,0 +1,208 @@
package scenedescriptions
import (
"bytes"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
"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.SceneDescriptionList {
return dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{
ID: "chunk-000001",
SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2},
Kind: dnd.SceneKindNarrative,
Title: "At the city gate",
Summary: "The party enters the city after speaking with its guard.",
}}}
}
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
raw, err := os.ReadFile("testdata/dnd_scene_descriptions.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 TestCodecOwnsDurableSchemaAndMetadata(t *testing.T) {
codec := New()
schema := codec.Schema()
if codec.Kind() != dnd.SceneDescriptionListKind || 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.SceneDescriptionListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
t.Fatalf("registered spec = %#v, %t", spec, ok)
}
schema.JSONSchema[0] = '['
if next := codec.Schema(); !json.Valid(next.JSONSchema) || next.JSONSchema[0] == '[' {
t.Fatal("Schema() returned shared bytes")
}
metadata := codec.Metadata(validList())
metadata["other"] = true
if next := codec.Metadata(validList()); len(next) != 1 || next["scene_count"] != 1 {
t.Fatalf("Metadata() = %#v", next)
}
encoded, err := codec.Encode(validList())
if err != nil {
t.Fatal(err)
}
encoded[0] = '['
if next, err := codec.Encode(validList()); err != nil || !json.Valid(next) || next[0] == '[' {
t.Fatalf("Encode() returned shared bytes: %s, %v", next, err)
}
}
func TestDurableSchemaRequiresAtLeastOneScene(t *testing.T) {
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(New().Schema().JSONSchema))
if err != nil {
t.Fatal(err)
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schemaDocument); err != nil {
t.Fatal(err)
}
compiled, err := compiler.Compile("schema.json")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
content []byte
valid bool
}{
{name: "one scene", content: mustEncode(t, validList()), valid: true},
{name: "empty scenes", content: []byte(`{"scenes":[]}`)},
} {
t.Run(test.name, func(t *testing.T) {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(test.content))
if err != nil {
t.Fatal(err)
}
err = compiled.Validate(instance)
if (err == nil) != test.valid {
t.Fatalf("Validate() error = %v, want valid=%t", err, test.valid)
}
})
}
}
func TestCodecStrictlyRejectsInvalidDurableBoundaries(t *testing.T) {
validJSON := `{"scenes":[{"id":"chunk-000001","source_ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1},"kind":"narrative","title":"Arrival","summary":"The party arrives."}]}`
tests := []struct{ name, raw, want string }{
{"malformed", `{`, "decode dnd scene description list"},
{"unknown top-level", `{"scenes":[],"unexpected":true}`, "unknown field"},
{"unknown scene field", strings.Replace(validJSON, `"kind":"narrative"`, `"kind":"narrative","unexpected":true`, 1), "unknown field"},
{"unknown source field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
{"trailing", `{"scenes":[]} {}`, "multiple JSON values"},
{"missing scenes", `{}`, "scenes must be present"},
{"empty scenes", `{"scenes":[]}`, "scenes must not be empty"},
{"invalid kind", strings.Replace(validJSON, `"kind":"narrative"`, `"kind":"other"`, 1), "kind must be supported"},
{"blank title", strings.Replace(validJSON, `"title":"Arrival"`, `"title":" "`, 1), "title must not be empty"},
{"blank summary", strings.Replace(validJSON, `"summary":"The party arrives."`, `"summary":" "`, 1), "summary must not be empty"},
{"blank ID", strings.Replace(validJSON, `"id":"chunk-000001"`, `"id":" "`, 1), "id must not be empty"},
{"blank source ID", strings.Replace(validJSON, `"source_id":"session"`, `"source_id":" "`, 1), "source_id must not be empty"},
{"invalid source start", strings.Replace(validJSON, `"start_unit_id":1`, `"start_unit_id":0`, 1), "start_unit_id must be positive"},
{"invalid source end", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":0`, 1), "end_unit_id must be positive"},
}
for _, test := range tests {
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 TestCodecCandidatePreservesValidatorOwnedValues(t *testing.T) {
candidates := []dnd.SceneDescriptionList{
{},
{Scenes: []dnd.SceneDescription{}},
{Scenes: []dnd.SceneDescription{{ID: " ", Kind: "unsupported", Title: " ", Summary: " ", SourceRef: source.SourceRef{}}}},
{Scenes: []dnd.SceneDescription{{ID: " ", Kind: "unsupported", Title: " ", Summary: " ", SourceRef: source.SourceRef{StartUnitID: 0, EndUnitID: -1}}}},
}
for _, candidate := range candidates {
content, err := New().EncodeCandidate(candidate)
if err != nil || !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
}
decoded, err := New().DecodeCandidate(content)
if err != nil || !reflect.DeepEqual(decoded, candidate) {
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
}
}
}
func TestCodecRejectsRequiredApprovedValues(t *testing.T) {
tests := []struct {
name string
value dnd.SceneDescriptionList
want string
}{
{"nil scenes", dnd.SceneDescriptionList{}, "scenes must be present"},
{"empty scenes", dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}, "scenes must not be empty"},
{"empty ID", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].ID = " " }), "id must not be empty"},
{"unsupported kind", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].Kind = "unsupported" }), "kind must be supported"},
{"empty title", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].Title = " " }), "title must not be empty"},
{"empty summary", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].Summary = " " }), "summary must not be empty"},
{"empty source ID", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].SourceRef.SourceID = " " }), "source_id must not be empty"},
{"non-positive start", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].SourceRef.StartUnitID = 0 }), "start_unit_id must be positive"},
{"non-positive end", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].SourceRef.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 mustEncode(t *testing.T, value dnd.SceneDescriptionList) []byte {
t.Helper()
content, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return content
}
func mutate(value dnd.SceneDescriptionList, change func(*dnd.SceneDescriptionList)) dnd.SceneDescriptionList {
change(&value)
return value
}

View File

@@ -0,0 +1 @@
{"scenes":[{"id":"chunk-000001","source_ref":{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2},"kind":"narrative","title":"At the city gate","summary":"The party enters the city after speaking with its guard."}]}

View File

@@ -0,0 +1,6 @@
package npcinteractions
import "embed"
//go:embed assets/schemas/dnd_npc_interactions_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md
var embeddedAssets embed.FS

View File

@@ -0,0 +1,49 @@
id: dnd.npc_interactions
version: "v1"
default_profile: gemini-2-flash
inputs:
- name: transcript
required: true
content_type: application/json
- name: players
required: false
content_type: text/plain
- name: party
required: false
content_type: text/plain
- name: glossary
required: false
content_type: text/plain
- name: npcs
required: true
content_type: application/json
messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-extraction-evidence.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-references.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-npcs.md
cache_control:
type: ephemeral
- role: user
content_file: ./task.md
- role: user
content_file: ./instructions.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-transcript.md
output:
format: json
validation_mode: json_schema
schema_path: dnd_npc_interactions_llm.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,18 @@
Return the interactions array even when no interaction is established. Every
record must contain name, kind, and source_refs. Cite transcript ranges that
support both the NPC identity and the interaction kind.
Use exactly one kind per occurrence:
- mentioned: the NPC is referred to but is not established as present or communicating;
- noncombat_presence: the NPC is present and relevant 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; or
- other: the transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds.
When activities overlap, active combat participation outranks dialogue,
presence, and mention; dialogue outranks noncombat presence and mention; and
noncombat presence outranks mention. Other is only for directly evidenced
activity outside those categories. Split an occurrence rather than assigning
both combat alignments.

View File

@@ -0,0 +1,16 @@
Extract Dungeons & Dragons NPC interaction occurrences from the supplied
transcript.
Include an occurrence only when the transcript establishes one supplied NPC,
one interaction kind, and a coherent passage supporting both. Use only names
from the supplied NPC registry. The registry helps ground identity but never
proves that an interaction occurred.
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
or persistent state. Do not identify player characters, anonymous groups, or
invented NPCs. Return an empty interactions array when no supplied NPC has an
evidenced interaction in this transcript passage.
Keep occurrences within this transcript chunk. Split records when an NPC's
interaction kind changes, when combat alignment changes, or when an NPC is
first mentioned and later becomes present.

View File

@@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.npc_interactions.llm",
"type": "object",
"additionalProperties": false,
"required": ["interactions"],
"properties": {
"interactions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "kind", "source_refs"],
"properties": {
"name": {
"type": "string"
},
"kind": {
"type": "string"
},
"source_refs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {
"type": "integer"
},
"end_unit_id": {
"type": "integer"
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,113 @@
package npcinteractions
import (
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
if response == nil {
return
}
for index := range response.Interactions {
canonicalizeInteraction(&response.Interactions[index])
}
sort.SliceStable(response.Interactions, func(i, j int) bool {
left, leftOK := earliestSourcePosition(doc, response.Interactions[i])
right, rightOK := earliestSourcePosition(doc, response.Interactions[j])
if leftOK != rightOK {
return leftOK
}
if !leftOK {
return false
}
return left < right
})
}
func canonicalizeInteraction(interaction *interactionResponse) {
if interaction == nil {
return
}
sort.SliceStable(interaction.SourceRefs, func(i, j int) bool {
left := interaction.SourceRefs[i]
right := interaction.SourceRefs[j]
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
}
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
})
interaction.SourceRefs = dedupeSourceRefs(interaction.SourceRefs)
}
func dedupeSourceRefs(refs []interactionSourceRefResponse) []interactionSourceRefResponse {
if len(refs) < 2 {
return refs
}
out := refs[:0]
var previous interactionSourceRefResponse
for index, ref := range refs {
if index > 0 && previous == ref {
continue
}
out = append(out, ref)
previous = ref
}
return out
}
func earliestSourcePosition(doc *source.SourceDocument, interaction interactionResponse) (int, bool) {
if doc == nil {
return 0, false
}
found := false
earliest := 0
for _, ref := range interaction.SourceRefs {
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
if err := source.ValidateRef(doc, candidate); err != nil {
continue
}
index, ok := source.UnitIndex(doc, candidate.StartUnitID)
if !ok || (found && index >= earliest) {
continue
}
earliest = index
found = true
}
return earliest, found
}
func unitSortValue(value int) int {
if value <= 0 {
return int(^uint(0) >> 1)
}
return value
}
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {
interactions := make([]dnd.NPCInteraction, len(response.Interactions))
for index, interaction := range response.Interactions {
interactions[index] = dnd.NPCInteraction{
Name: interaction.Name,
Kind: dnd.NPCInteractionKind(interaction.Kind),
SourceRefs: canonicalSourceRefs(interaction.SourceRefs, sourceID),
}
}
if response.Interactions == nil {
interactions = nil
}
return dnd.NPCInteractionList{Interactions: interactions}
}
func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) []source.SourceRef {
if refs == nil {
return nil
}
out := make([]source.SourceRef, len(refs))
for index, ref := range refs {
out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return out
}

View File

@@ -0,0 +1,225 @@
package npcinteractions
import (
"context"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const (
Key = "dnd/npc-interactions"
mappingPolicy = "dnd.npc_interactions.extract_mapping.v1"
)
const (
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
NPCRegistryMaxBytes = npcregistry.MaxBytes
)
var requiredCapabilities = []string{
"chunks",
"source.transcript",
}
var providedCapabilities = []string{
"dnd.npc_interactions",
}
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for interaction disambiguation.",
Party: "Optional party roster reference material used only for interaction disambiguation.",
Players: "Optional player list reference material used only for interaction disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.",
}
func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot,
Description: "Required normalized NPC registry used only for interaction identity grounding, never as interaction evidence.",
Required: true,
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes,
})
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })
return slots
}
var _ contracts.Extractor[dnd.NPCInteractionList] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
type Options struct{}
type Extractor struct {
llm contracts.StructuredLLMClient
npcResolver *npcregistry.Resolver
promptSHA string
responseSchemaSHA string
}
func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contracts.ReferenceSet) (*Extractor, error) {
if llmClient == nil {
return nil, extractorErrorf("LLM client must not be nil")
}
if len(references) > 1 {
return nil, extractorErrorf("at most one reference set may be supplied")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
npcResolver, err := npcregistry.NewResolver(referenceSet)
if err != nil {
return nil, extractorErrorf("prepare NPC registry prompt input: %w", err)
}
promptSHA, err := scriptoriumPromptMetadata()
if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err)
}
responseSchema, err := loadResponseSchema()
if err != nil {
return nil, extractorErrorf("load response schema: %w", err)
}
return &Extractor{
llm: llmClient,
npcResolver: npcResolver,
promptSHA: promptSHA,
responseSchemaSHA: responseSchema.SHA256,
}, nil
}
func (e *Extractor) Key() string { return Key }
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
func (e *Extractor) ManifestMetadata() map[string]any {
if e == nil {
return nil
}
metadata := map[string]any{
"prompt_id": PromptID,
"prompt_version": SchemaVersion,
"prompt_sha256": e.promptSHA,
"mapping_policy": mappingPolicy,
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,
"response_schema_version": SchemaVersion,
"response_schema_sha256": e.responseSchemaSHA,
}
seeded := e.npcResolver.Seeded()
if seeded.Bound() {
metadata["npc_registry_digest"] = seeded.Digest()
metadata["npc_count"] = seeded.Count()
}
return metadata
}
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if e == nil {
return nil
}
seeded := e.npcResolver.Seeded()
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy},
{Name: "npc_registry", Value: seeded.ProjectionDigest()},
}
}
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCInteractionList], error) {
if e == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("extractor must not be nil")
}
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
}
sourceInput, err := shared.ChunkPromptMaterial(req)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err)
}
npcRegistry, err := e.npcResolver.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("resolve NPC registry: %w", err)
}
if !npcRegistry.Bound() {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("NPC registry reference is required")
}
var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References)
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,
PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: inputs,
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response, req.Source)
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{Value: canonicalInteractionList(response, req.Source.ID)}, nil
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageExtract,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCInteractionListKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.ExtractorRegistry) error {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCInteractionList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(request.Dependencies.LLM, options, request.References)
})
}
func validateOptions(options map[string]any) error {
_, err := DecodeOptions(options)
return err
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, extractorErrorf("%w", err)
}
return Options{}, nil
}
func extractorErrorf(format string, args ...any) error {
return fmt.Errorf("dnd NPC interactions extractor: "+format, args...)
}

View File

@@ -0,0 +1,370 @@
package npcinteractions
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
{Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
{Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
{Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
{Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
{Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
}}}
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid"}) {
t.Fatalf("interaction order = %#v", got)
}
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCInteractionKind{
dnd.NPCInteractionKindMentioned,
dnd.NPCInteractionKindDialogue,
dnd.NPCInteractionKindNoncombatPresence,
dnd.NPCInteractionKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent,
dnd.NPCInteractionKindOther,
"unsupported",
}) {
t.Fatalf("interaction kinds = %#v", got)
}
if refs := result.Value.Interactions[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("canonical source refs = %#v", refs)
}
if invalid := result.Value.Interactions[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
}
if len(client.requests) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
}
}
func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
t.Fatalf("New(nil) error = %v", err)
}
if _, err := New(&fakeInteractionsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
t.Fatalf("New() error = %v", err)
}
}
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{{
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
}}}}
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
req := extractionRequest()
req.References = references
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err != nil {
t.Fatalf("Extract() error = %v", err)
}
request := client.requests[0]
registry := request.Inputs[NPCRegistryReferenceSlot]
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` {
t.Fatalf("registry prompt input = %#v, want exact names-only projection", registry)
}
for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} {
if strings.Contains(string(registry.Content), forbidden) {
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
}
}
if strings.Contains(string(request.Inputs["transcript"].Content), "other-session") {
t.Fatal("transcript input contains registry evidence")
}
metadata, err := json.Marshal(newExtractor(t, &fakeInteractionsLLMClient{}, references).ManifestMetadata())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(metadata), "Mira Thorn") || strings.Contains(string(metadata), "other-session") {
t.Fatalf("manifest metadata leaked registry content: %s", metadata)
}
}
func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
client := &fakeInteractionsLLMClient{}
if _, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()); err == nil || !strings.Contains(err.Error(), "NPC registry reference is required") {
t.Fatalf("Extract() error = %v, want required registry failure", err)
}
if len(client.requests) != 0 {
t.Fatalf("LLM calls = %d, want no call", len(client.requests))
}
malformed := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: "application/json", Content: []byte(`{"secret":"private source"}`)}},
},
}}
if _, err := New(client, Options{}, malformed); err == nil || !strings.Contains(err.Error(), "prepare NPC registry") || strings.Contains(err.Error(), "private source") {
t.Fatalf("New() error = %v, want content-safe malformed registry error", err)
}
if len(client.requests) != 0 {
t.Fatalf("LLM calls = %d, want no call", len(client.requests))
}
}
func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
references := requiredRegistryReferences(t, "Mira Thorn")
req := extractionRequest()
req.References = references
extractor := newExtractor(t, client)
if _, err := extractor.Extract(context.Background(), req); err != nil {
t.Fatalf("Extract() error = %v", err)
}
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("generated registry prompt input = %#v", input)
}
metadata := extractor.ManifestMetadata()
if _, ok := metadata["npc_registry_digest"]; ok {
t.Fatalf("operation-time registry leaked into static metadata: %#v", metadata)
}
}
func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{}})
if err != nil {
t.Fatal(err)
}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
},
}}
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err != nil || result.Value.Interactions == nil || len(result.Value.Interactions) != 0 {
t.Fatalf("Extract() = %#v, %v; want present empty interactions", result, err)
}
}
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
valid := extractionRequest()
valid.References = references
canceled, cancel := context.WithCancel(context.Background())
cancel()
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
for _, test := range []struct {
name string
ctx context.Context
req contracts.TypedExtractionRequest
want string
}{
{"nil context", nil, valid, "context"},
{"canceled context", canceled, valid, "context"},
{"nil source", context.Background(), contracts.TypedExtractionRequest{Chunk: valid.Chunk, References: references}, "source"},
{"nil chunk", context.Background(), contracts.TypedExtractionRequest{Source: valid.Source, References: references}, "chunk"},
{"empty chunk", context.Background(), emptyChunkRequest(valid), "units"},
{"source input mismatch", context.Background(), mismatchedSourceInputRequest(valid), "must match chunk"},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := extractor.Extract(test.ctx, test.req); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Extract() error = %v, want %q", err, test.want)
}
})
}
if _, err := newExtractor(t, &fakeInteractionsLLMClient{err: errors.New("provider unavailable")}, references).Extract(context.Background(), valid); err == nil || !strings.Contains(err.Error(), "provider unavailable") {
t.Fatalf("provider error = %v", err)
}
}
func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
got := ModuleSpec()
if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCInteractionListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) {
t.Fatalf("ModuleSpec() = %#v", got)
}
var registrySlot contracts.ReferenceSlot
for _, slot := range got.ReferenceSlots {
if slot.Name == NPCRegistryReferenceSlot {
registrySlot = slot
}
}
if !registrySlot.Required || !reflect.DeepEqual(registrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || registrySlot.MaxBytes != NPCRegistryMaxBytes {
t.Fatalf("NPC registry slot = %#v", registrySlot)
}
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
if ModuleSpec().ReferenceSlots[0].AcceptedMediaTypes[0] == "changed" {
t.Fatal("ModuleSpec() returned mutable reference slots")
}
extractorSlots := newExtractor(t, &fakeInteractionsLLMClient{}).ReferenceSlots()
extractorSlots[0].AcceptedMediaTypes[0] = "changed"
if newExtractor(t, &fakeInteractionsLLMClient{}).ReferenceSlots()[0].AcceptedMediaTypes[0] == "changed" {
t.Fatal("ReferenceSlots() returned mutable reference slots")
}
registry := pipeline.NewExtractorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if _, ok := registry.Spec(Key); !ok {
t.Fatalf("registry missing %q", Key)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
references := requiredRegistryReferences(t, "Mira Thorn")
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
metadata := extractor.ManifestMetadata()
for key, want := range map[string]string{
"prompt_id": PromptID, "prompt_version": SchemaVersion, "mapping_policy": mappingPolicy,
"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
} {
if metadata[key] != want {
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
}
}
for _, key := range []string{"prompt_sha256", "response_schema_sha256", "npc_registry_digest"} {
if value, ok := metadata[key].(string); !ok || !strings.HasPrefix(value, "sha256:") {
t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key])
}
}
wantFingerprints := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}, "npc_registry": {}}
for _, fingerprint := range extractor.CheckpointFingerprints() {
delete(wantFingerprints, fingerprint.Name)
}
if len(wantFingerprints) != 0 {
t.Fatalf("missing fingerprints = %#v", wantFingerprints)
}
}
func extractionRequest() contracts.TypedExtractionRequest {
doc := sourceDocument()
chunk := &source.Chunk{
ID: "session-alpha:chunk:0", SourceID: doc.ID, Index: 0,
Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 30},
Content: []byte(`{"units":[10,2,7,5,20,30]}`), MediaType: "application/json",
Units: append([]source.SourceUnit(nil), doc.Units...),
}
return contracts.TypedExtractionRequest{
Source: doc, Chunk: chunk,
SourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:chunk", "file:///session-alpha.json"),
SessionID: "session-123", LLMProfile: "profile-npc-interactions",
}
}
func sourceDocument() *source.SourceDocument {
return &source.SourceDocument{
ID: "session-alpha", Kind: "transcript", Format: "application/vnd.seriatim.minimal+json", Digest: "sha256:source",
Units: []source.SourceUnit{
{ID: 10, Kind: "transcript_segment", Text: "Mira Thorn is mentioned."},
{ID: 2, Kind: "transcript_segment", Text: "Mira Thorn speaks to the party."},
{ID: 7, Kind: "transcript_segment", Text: "Mira Thorn watches nearby."},
{ID: 5, Kind: "transcript_segment", Text: "Mira Thorn joins the party in combat."},
{ID: 20, Kind: "transcript_segment", Text: "Mira Thorn attacks the party."},
{ID: 30, Kind: "transcript_segment", Text: "Mira Thorn performs a ritual."},
},
}
}
func requiredRegistryReferences(t *testing.T, names ...string) contracts.ReferenceSet {
t.Helper()
npcs := make([]dnd.NPC, len(names))
for index, name := range names {
npcs[index] = dnd.NPC{
ID: identity.DeriveID(name), Name: name,
SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}},
}
}
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}},
},
}}
}
func interactionRefs(start, end int) []interactionSourceRefResponse {
return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
}
func interactionNames(value dnd.NPCInteractionList) []string {
names := make([]string, len(value.Interactions))
for index, interaction := range value.Interactions {
names[index] = interaction.Name
}
return names
}
func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
kinds := make([]dnd.NPCInteractionKind, len(value.Interactions))
for index, interaction := range value.Interactions {
kinds[index] = interaction.Kind
}
return kinds
}
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
return req
}
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "")
return req
}
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
t.Helper()
extractor, err := New(client, Options{}, references...)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return extractor
}
type fakeInteractionsLLMClient struct {
response extractionResponse
err error
requests []contracts.StructuredCompletionRequest
}
func (client *fakeInteractionsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
if client.err != nil {
return contracts.StructuredCompletionResponse{}, client.err
}
target, ok := out.(*extractionResponse)
if !ok {
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
}
*target = client.response
content, err := json.Marshal(client.response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
req.Inputs = req.Inputs.Clone()
return req
}

View File

@@ -0,0 +1,16 @@
package npcinteractions
type extractionResponse struct {
Interactions []interactionResponse `json:"interactions"`
}
type interactionResponse struct {
Name string `json:"name"`
Kind string `json:"kind"`
SourceRefs []interactionSourceRefResponse `json:"source_refs"`
}
type interactionSourceRefResponse struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}

View File

@@ -0,0 +1,18 @@
package npcinteractions
import (
"encoding/json"
"testing"
)
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
content := []byte(`{"interactions":[{"name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
var response extractionResponse
if err := json.Unmarshal(content, &response); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
interaction := response.Interactions[0]
if interaction.Name != "" || interaction.Kind != "unsupported" || interaction.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("decoded response = %#v", interaction)
}
}

View File

@@ -0,0 +1,21 @@
package npcinteractions
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const (
PromptID = "dnd.npc_interactions"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_interactions_llm")
ResponseSchemaID = "notarius.dnd.npc_interactions.llm"
ResponseSchemaName = "notarius_dnd_npc_interactions_llm_v1"
SchemaVersion = "v1"
)
func loadResponseSchema() (llm.ResponseSchema, error) {
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: "assets/schemas/dnd_npc_interactions_llm.v1.json",
})
}

View File

@@ -0,0 +1,106 @@
package npcinteractions
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v", schema)
}
valid := validInteractionResponse()
content, err := json.Marshal(valid)
if err != nil {
t.Fatal(err)
}
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
t.Fatalf("valid response rejected: %v", err)
}
semanticCandidate := validInteractionResponse()
interaction := semanticCandidate["interactions"].([]any)[0].(map[string]any)
interaction["name"] = ""
interaction["kind"] = "unsupported"
ref := interaction["source_refs"].([]any)[0].(map[string]any)
ref["start_unit_id"] = 0
ref["end_unit_id"] = -1
content, err = json.Marshal(semanticCandidate)
if err != nil {
t.Fatal(err)
}
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
t.Fatalf("schema rejected validator-owned semantics: %v", err)
}
for _, mutate := range []func(map[string]any){
func(record map[string]any) { delete(record, "name") },
func(record map[string]any) { record["kind"] = 1 },
func(record map[string]any) { record["unexpected"] = true },
func(record map[string]any) {
record["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
},
} {
candidate := validInteractionResponse()
mutate(candidate["interactions"].([]any)[0].(map[string]any))
content, err := json.Marshal(candidate)
if err != nil {
t.Fatal(err)
}
if err := validateJSONSchema(content, schema.JSONSchema); err == nil {
t.Fatal("schema accepted structurally invalid response")
}
}
}
func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
first, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
first.JSONSchema[0] = '['
second, err := loadResponseSchema()
if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
t.Fatalf("second schema = %s, %v", second.JSONSchema, err)
}
if diagnostics := second.DiagnosticsMap(); diagnostics["key"] != ResponseSchemaKey || diagnostics["id"] != ResponseSchemaID {
t.Fatalf("diagnostics = %#v", diagnostics)
} else if _, ok := diagnostics["json_schema"]; ok {
t.Fatalf("diagnostics leak schema content: %#v", diagnostics)
}
}
func validInteractionResponse() map[string]any {
return map[string]any{"interactions": []any{map[string]any{
"name": "Mira Thorn", "kind": "dialogue",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}}
}
func validateJSONSchema(instanceContent, schemaContent []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
if err != nil {
return err
}
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schemaDocument); err != nil {
return err
}
schema, err := compiler.Compile("schema.json")
if err != nil {
return err
}
return schema.Validate(instance)
}

View File

@@ -0,0 +1,53 @@
package npcinteractions
import (
"fmt"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const scriptoriumPromptRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
ModuleDir: "dnd.npc_interactions",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.npc_interactions.yaml", Path: "assets/prompts/dnd.npc_interactions.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-extraction-evidence.md",
"common-dnd-identity.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
"common-dnd-npcs.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil {
return fmt.Errorf("prepare NPC-interaction prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
})
return scriptoriumPromptHash, scriptoriumPromptHashErr
}
var (
scriptoriumPromptHashOnce sync.Once
scriptoriumPromptHash string
scriptoriumPromptHashErr error
)

View File

@@ -0,0 +1,94 @@
package npcinteractions
import (
"context"
"io/fs"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/scriptorium"
)
func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatal(err)
}
schemaFS, err := registry.SchemaFS()
if err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(schemaFS, "dnd_npc_interactions_llm.v1.json"); err != nil {
t.Fatalf("response schema asset: %v", err)
}
options, err := registry.ScriptoriumOptions()
if err != nil {
t.Fatal(err)
}
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "npc-interactions-test-profile", Endpoint: "http://127.0.0.1:1/v1", Model: "npc-interactions-test-model",
})))
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatal(err)
}
transcript := `{"units":[1]}`
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "npc-interactions-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", transcript),
"players": scriptorium.Inline("Dana: Mira"),
"party": scriptorium.Inline("Mira: ranger"),
"glossary": scriptorium.Inline("Greencloak: title"),
"npcs": scriptorium.Inline(`{"npcs":[{"name":"Mira Thorn"}]}`),
},
})
if err != nil {
t.Fatal(err)
}
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" {
t.Fatalf("prepared prompt = %#v", prepared)
}
for _, want := range []string{
"Mira Thorn", "mentioned", "combat_opponent", "Registry content is context, not event evidence", transcript,
} {
found := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, want) {
found = true
break
}
}
if !found {
t.Fatalf("prepared prompt did not include %q", want)
}
}
if last := prepared.Messages[len(prepared.Messages)-1]; !strings.Contains(last.Content, transcript) {
t.Fatalf("last prompt message = %q, want transcript", last.Content)
}
}
func TestPromptMetadataDoesNotExposeAssetContent(t *testing.T) {
hash, err := scriptoriumPromptMetadata()
if err != nil || !strings.HasPrefix(hash, "sha256:") {
t.Fatalf("scriptoriumPromptMetadata() = %q, %v", hash, err)
}
metadata := newExtractor(t, &fakeInteractionsLLMClient{}).ManifestMetadata()
for _, forbidden := range []string{"combat_opponent", "common-dnd-system", "source_refs", "dnd_npc_interactions_llm.v1.json"} {
if strings.Contains(strings.Join(mapValues(metadata), " "), forbidden) {
t.Fatalf("metadata leaked prompt or schema content %q: %#v", forbidden, metadata)
}
}
}
func mapValues(values map[string]any) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if text, ok := value.(string); ok {
out = append(out, text)
}
}
return out
}

View File

@@ -0,0 +1,6 @@
package scenedescriptions
import "embed"
//go:embed assets/schemas/dnd_scene_descriptions_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md
var embeddedAssets embed.FS

View File

@@ -0,0 +1,40 @@
id: dnd.scene_descriptions
version: "v1"
default_profile: gemini-2-flash
inputs:
- name: transcript
required: true
content_type: application/json
- name: players
required: false
content_type: text/plain
- name: party
required: false
content_type: text/plain
- name: glossary
required: false
content_type: text/plain
messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-references.md
cache_control:
type: ephemeral
- role: user
content_file: ./task.md
- role: user
content_file: ./instructions.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-transcript.md
output:
format: json
validation_mode: json_schema
schema_path: dnd_scene_descriptions_llm.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,44 @@
Choose exactly one kind:
- combat: active combat materially organizes the scene, including
initiative-like exchanges or sustained hostile action. Planning a fight or
discussing a completed fight is not combat by itself.
- narrative: current-session in-world play that is not principally active
combat, a prior-session recap, or sustained out-of-character session
discussion. This includes exploration, travel, dialogue, investigation,
in-character planning, and aftermath.
- recap: the scene's organizing purpose is to recount events from a previous
session for the table. An in-world character recounting history during
current play remains narrative.
- meta: the scene's organizing purpose is sustained out-of-character
discussion about the game or session rather than advancing current in-world
play.
Narrative is the default for actual current-session gameplay that does not meet
another definition. When the accepted chunk is mixed:
1. use combat when active combat is a substantive central activity, even with
brief setup, rules clarification, or immediate aftermath;
2. otherwise use recap when recounting a previous session is the chunk's
primary table purpose;
3. otherwise use meta when sustained out-of-character session discussion is
primary and in-world progression is no more than incidental; and
4. use narrative for all remaining current-session in-world play.
Brief table talk, dice resolution, rules clarification, jokes, or
administrative comments do not make a gameplay scene meta. A short recollection
used to orient current action does not make a scene recap.
The title must be a short, distinguishing phrase rather than a sentence,
chapter number, or generic label such as "Scene." It may use names and places
established by the transcript or disambiguated by campaign references, but it
must not invent a proper noun.
The summary must briefly state the main activity and material transition or
outcome established within the accepted chunk. Do not add analysis, inferred
motives, hidden state, future consequences, relationship claims, or facts from
outside the chunk. Campaign references may disambiguate names but never add
events or lore.
Do not return identifiers, source identifiers, source ranges, unit identifiers,
participants, confidence, or any fields besides kind, title, and summary.

View File

@@ -0,0 +1,5 @@
Describe exactly one accepted Dungeons & Dragons scene from the supplied
transcript chunk. The complete chunk is the evidence boundary: do not split it
into multiple scenes or use facts that are not supported by it.
Return one kind, one concise title, and one concise summary.

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.scene_descriptions.llm",
"type": "object",
"additionalProperties": false,
"required": ["kind", "title", "summary"],
"properties": {
"kind": {
"type": "string",
"enum": ["combat", "narrative", "recap", "meta"]
},
"title": {
"type": "string",
"minLength": 1
},
"summary": {
"type": "string",
"minLength": 1
}
}
}

View File

@@ -0,0 +1,195 @@
package scenedescriptions
import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const Key = "dnd/scene-descriptions"
var requiredCapabilities = []string{
"chunks",
"source.transcript",
}
var providedCapabilities = []string{
"dnd.scene_descriptions",
}
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only to disambiguate scene descriptions.",
Party: "Optional party roster reference material used only to disambiguate scene descriptions.",
Players: "Optional player list reference material used only to disambiguate scene descriptions.",
}
func referenceSlots() []contracts.ReferenceSlot {
all := shared.ReferenceSlots(referenceSlotDescriptions)
slots := make([]contracts.ReferenceSlot, 0, 3)
for _, slot := range all {
if slot.Name != "roster" {
slots = append(slots, slot)
}
}
return slots
}
var _ contracts.Extractor[dnd.SceneDescriptionList] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
type Options struct{}
type Extractor struct {
llm contracts.StructuredLLMClient
promptSHA string
responseSchemaSHA string
}
func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contracts.ReferenceSet) (*Extractor, error) {
if llmClient == nil {
return nil, extractorErrorf("LLM client must not be nil")
}
if len(references) > 1 {
return nil, extractorErrorf("at most one reference set may be supplied")
}
promptSHA, err := scriptoriumPromptMetadata()
if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err)
}
responseSchema, err := loadResponseSchema()
if err != nil {
return nil, extractorErrorf("load response schema: %w", err)
}
return &Extractor{
llm: llmClient,
promptSHA: promptSHA,
responseSchemaSHA: responseSchema.SHA256,
}, nil
}
func (e *Extractor) Key() string { return Key }
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
func (e *Extractor) ManifestMetadata() map[string]any {
if e == nil {
return nil
}
return map[string]any{
"prompt_id": PromptID,
"prompt_version": SchemaVersion,
"prompt_sha256": e.promptSHA,
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,
"response_schema_version": SchemaVersion,
"response_schema_sha256": e.responseSchemaSHA,
"mapping_policy": mappingPolicy,
}
}
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if e == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy},
}
}
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SceneDescriptionList], error) {
if e == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("extractor must not be nil")
}
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
}
sourceInput, err := shared.ChunkPromptMaterial(req)
if err != nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("%w", err)
}
var response extractionResponse
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,
PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: shared.PromptInputs(sourceInput, req.References),
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("complete structured output: %w", err)
}
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{Value: mapResponse(response, req.Chunk)}, nil
}
func mapResponse(response extractionResponse, chunk *source.Chunk) dnd.SceneDescriptionList {
return dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{
ID: chunk.ID,
SourceRef: chunk.Ref,
Kind: response.Kind,
Title: strings.TrimSpace(response.Title),
Summary: strings.TrimSpace(response.Summary),
}}}
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageExtract,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.SceneDescriptionListKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.ExtractorRegistry) error {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.SceneDescriptionList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(request.Dependencies.LLM, options, request.References)
})
}
func validateOptions(options map[string]any) error {
_, err := DecodeOptions(options)
return err
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, extractorErrorf("%w", err)
}
return Options{}, nil
}
func extractorErrorf(format string, args ...any) error {
return fmt.Errorf("dnd scene descriptions extractor: "+format, args...)
}

View File

@@ -0,0 +1,126 @@
package scenedescriptions
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestExtractMapsExactlyOneSceneToTheChunk(t *testing.T) {
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
Kind: dnd.SceneKindCombat, Title: " Fight at the Watchtower ", Summary: " Bandits attack the party. ",
}}
request := extractionRequest()
result, err := newExtractor(t, client).Extract(context.Background(), request)
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{
ID: request.Chunk.ID, SourceRef: request.Chunk.Ref, Kind: dnd.SceneKindCombat,
Title: "Fight at the Watchtower", Summary: "Bandits attack the party.",
}}}
if !reflect.DeepEqual(result.Value, want) {
t.Fatalf("Value = %#v, want %#v", result.Value, want)
}
if len(client.requests) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
}
completed := client.requests[0]
if completed.StageName != Key || completed.PromptID != PromptID || completed.PromptVersion != SchemaVersion || completed.ProfileID != "profile-scene-descriptions" || completed.SessionID != "session-123" {
t.Fatalf("LLM request identity = %#v", completed)
}
transcript := completed.Inputs["transcript"]
if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" || string(transcript.Content) != string(request.Chunk.Content) {
t.Fatalf("transcript input = %#v, want chunk-scoped material", transcript)
}
}
func TestExtractPassesOptionalReferencesAndUsesEmptyPlaceholders(t *testing.T) {
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives."}}
req := extractionRequest()
if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
for _, slot := range []string{"players", "party", "glossary"} {
if got := string(client.requests[0].Inputs[slot].Content); got != " " {
t.Fatalf("empty %s input = %q, want explicit placeholder", slot, got)
}
}
req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Mira")}}},
"party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Mira: ranger")}}},
"glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Greencloak: local title")}}},
}}
if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
inputs := client.requests[1].Inputs
if string(inputs["players"].Content) != "Dana: Mira" || string(inputs["party"].Content) != "Mira: ranger" || string(inputs["glossary"].Content) != "Greencloak: local title" {
t.Fatalf("reference inputs = %#v", inputs)
}
if strings.Contains(string(inputs["transcript"].Content), "local title") {
t.Fatal("transcript input contains reference content")
}
}
func TestExtractPreservesTheModelKindWithoutRepair(t *testing.T) {
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
Kind: dnd.SceneKind("unrecognized"), Title: " Untitled scene ", Summary: " Summary ",
}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
scene := result.Value.Scenes[0]
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "Untitled scene" || scene.Summary != "Summary" {
t.Fatalf("scene = %#v, want model kind preserved and textual fields trimmed", scene)
}
}
func TestExtractValidatesRequestsAndSurfacesProviderFailures(t *testing.T) {
request := extractionRequest()
extractor := newExtractor(t, &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindMeta, Title: "Table talk", Summary: "The group discusses rules."}})
for _, test := range []struct {
name string
req contracts.TypedExtractionRequest
}{
{name: "nil source", req: func() contracts.TypedExtractionRequest { r := request; r.Source = nil; return r }()},
{name: "nil chunk", req: func() contracts.TypedExtractionRequest { r := request; r.Chunk = nil; return r }()},
{name: "empty chunk", req: emptyChunkRequest(request)},
{name: "mismatched source input", req: mismatchedSourceInputRequest(request)},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") {
t.Fatalf("Extract() error = %v, want contextual validation error", err)
}
})
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
t.Fatalf("canceled Extract() error = %v, want context error", err)
}
_, err := newExtractor(t, &fakeSceneDescriptionsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
if err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") || !strings.Contains(err.Error(), "provider unavailable") {
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
}
}
func TestMapResponseUsesOnlyChunkIdentityAndRange(t *testing.T) {
chunk := &source.Chunk{ID: "source:chunk:7", Ref: source.SourceRef{SourceID: "source", StartUnitID: 12, EndUnitID: 14}}
got := mapResponse(extractionResponse{Kind: dnd.SceneKindRecap, Title: " Recap ", Summary: " Summary "}, chunk)
want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{
ID: "source:chunk:7", SourceRef: source.SourceRef{SourceID: "source", StartUnitID: 12, EndUnitID: 14},
Kind: dnd.SceneKindRecap, Title: "Recap", Summary: "Summary",
}}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("mapResponse() = %#v, want %#v", got, want)
}
}

View File

@@ -0,0 +1,9 @@
package scenedescriptions
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
type extractionResponse struct {
Kind dnd.SceneKind `json:"kind"`
Title string `json:"title"`
Summary string `json:"summary"`
}

View File

@@ -0,0 +1,92 @@
package scenedescriptions
import (
"reflect"
"strings"
"testing"
"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 TestNewRequiresLLMClientAndRejectsAmbiguousReferences(t *testing.T) {
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
t.Fatalf("New(nil) error = %v, want dependency error", err)
}
if _, err := New(&fakeSceneDescriptionsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
t.Fatalf("New() error = %v, want reference-set error", err)
}
if got := newExtractor(t, &fakeSceneDescriptionsLLMClient{}).Key(); got != Key {
t.Fatalf("Key() = %q, want %q", got, Key)
}
}
func TestModuleSpecAndReferenceSlots(t *testing.T) {
want := pipeline.ModuleSpec{
Key: Key, Stage: pipeline.StageExtract, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.scene_descriptions"}, ArtifactKind: dnd.SceneDescriptionListKind,
ReferenceSlots: []contracts.ReferenceSlot{
{Name: "glossary", Description: "Optional campaign glossary reference material used only to disambiguate scene descriptions.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
{Name: "party", Description: "Optional party roster reference material used only to disambiguate scene descriptions.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
{Name: "players", Description: "Optional player list reference material used only to disambiguate scene descriptions.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
},
}
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
}
got := ModuleSpec()
got.Requires[0] = "changed"
got.Provides[0] = "changed"
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
if !reflect.DeepEqual(ModuleSpec(), want) {
t.Fatal("ModuleSpec() returned mutable shared slices")
}
if slots := newExtractor(t, &fakeSceneDescriptionsLLMClient{}).ReferenceSlots(); !reflect.DeepEqual(slots, want.ReferenceSlots) {
t.Fatalf("ReferenceSlots() = %#v, want %#v", slots, want.ReferenceSlots)
}
}
func TestRegisterStoresTypedModuleSpecAndOptionsAreStrict(t *testing.T) {
registry := pipeline.NewExtractorRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err)
}
if got, ok := registry.Spec(Key); !ok || !reflect.DeepEqual(got, ModuleSpec()) {
t.Fatalf("registry.Spec(%q) = %#v, present = %t", Key, got, ok)
}
if err := Register(nil); err == nil || !strings.Contains(err.Error(), "extractor registry") {
t.Fatalf("Register(nil) error = %v, want registry error", err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("DecodeOptions() error = %v, want strict options error", err)
}
}
func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) {
metadata := newExtractor(t, &fakeSceneDescriptionsLLMClient{}).ManifestMetadata()
for key, want := range map[string]string{
"prompt_id": PromptID, "prompt_version": SchemaVersion,
"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
"mapping_policy": mappingPolicy,
} {
if metadata[key] != want {
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
}
}
for _, key := range []string{"prompt_sha256", "response_schema_sha256"} {
if value, ok := metadata[key].(string); !ok || !strings.HasPrefix(value, "sha256:") {
t.Fatalf("metadata[%q] = %#v, want hash", key, metadata[key])
}
}
want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "mapping_policy": mappingPolicy}
fingerprints := newExtractor(t, &fakeSceneDescriptionsLLMClient{}).CheckpointFingerprints()
if len(fingerprints) != len(want) {
t.Fatalf("CheckpointFingerprints() = %#v, want %d entries", fingerprints, len(want))
}
for _, fingerprint := range fingerprints {
if fingerprint.Value != want[fingerprint.Name] {
t.Fatalf("fingerprint %q = %q, want %#v", fingerprint.Name, fingerprint.Value, want)
}
}
}

View File

@@ -0,0 +1,22 @@
package scenedescriptions
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const (
PromptID = "dnd.scene_descriptions"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_scene_descriptions_llm")
ResponseSchemaID = "notarius.dnd.scene_descriptions.llm"
ResponseSchemaName = "notarius_dnd_scene_descriptions_llm_v1"
SchemaVersion = "v1"
mappingPolicy = "dnd.scene_descriptions.mapping.v1"
)
func loadResponseSchema() (llm.ResponseSchema, error) {
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: "assets/schemas/dnd_scene_descriptions_llm.v1.json",
})
}

View File

@@ -0,0 +1,92 @@
package scenedescriptions
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
}
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Version != SchemaVersion || schema.Name != ResponseSchemaName || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v, want private scene-description schema identity", schema)
}
valid := map[string]any{"kind": "narrative", "title": "Arrival", "summary": "The party enters the tower."}
if err := validateJSONSchema(t, valid, schema.JSONSchema); err != nil {
t.Fatalf("valid private response rejected: %v", err)
}
for _, test := range []struct {
name string
response map[string]any
valid bool
}{
{name: "missing required field", response: map[string]any{"kind": "combat", "title": "Ambush"}},
{name: "unknown framework field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "id": "assigned-later"}},
{name: "unknown application field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "source_ref": map[string]any{}}},
{name: "collection is not allowed", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "scenes": []any{}}},
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}},
{name: "wrong field type", response: map[string]any{"kind": "combat", "title": 7, "summary": "Bandits strike."}},
{name: "blank title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}},
{name: "blank summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}},
} {
t.Run(test.name, func(t *testing.T) {
err := validateJSONSchema(t, test.response, schema.JSONSchema)
if (err == nil) != test.valid {
t.Fatalf("validateJSONSchema() error = %v, want valid=%t", err, test.valid)
}
})
}
if err := validateJSONSchemaContent([]byte(`{"kind":`), schema.JSONSchema); err == nil {
t.Fatal("validateJSONSchemaContent() error = nil, want malformed JSON rejected")
}
}
func TestResponseSchemaIsMutationSafeAndDiagnosticsRedactContent(t *testing.T) {
first, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
first.JSONSchema[0] = '['
second, err := loadResponseSchema()
if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
t.Fatalf("second schema = %s, %v; want defensive copy", second.JSONSchema, err)
}
if diagnostics := second.DiagnosticsMap(); diagnostics["json_schema"] != nil {
t.Fatalf("schema diagnostics included raw content: %#v", diagnostics)
}
}
func validateJSONSchema(t *testing.T, instance map[string]any, schemaContent []byte) error {
t.Helper()
content, err := json.Marshal(instance)
if err != nil {
t.Fatal(err)
}
return validateJSONSchemaContent(content, schemaContent)
}
func validateJSONSchemaContent(instanceContent, schemaContent []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
if err != nil {
return err
}
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schemaDocument); err != nil {
return err
}
compiled, err := compiler.Compile("schema.json")
if err != nil {
return err
}
return compiled.Validate(instance)
}

View File

@@ -0,0 +1,51 @@
package scenedescriptions
import (
"fmt"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const scriptoriumPromptRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
ModuleDir: "dnd.scene_descriptions",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.scene_descriptions.yaml", Path: "assets/prompts/dnd.scene_descriptions.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-identity.md",
"common-dnd-references.md",
"common-dnd-transcript.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil {
return fmt.Errorf("prepare scene-description prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
})
return scriptoriumPromptHash, scriptoriumPromptHashErr
}
var (
scriptoriumPromptHashOnce sync.Once
scriptoriumPromptHash string
scriptoriumPromptHashErr error
)

View File

@@ -0,0 +1,106 @@
package scenedescriptions
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/scriptorium"
)
func TestRegisterPromptAssetsPreparesOrderedSceneDescriptionPrompt(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v, want nil", err)
}
options, err := registry.ScriptoriumOptions()
if err != nil {
t.Fatalf("ScriptoriumOptions() error = %v, want nil", err)
}
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "scene-description-test-profile", Endpoint: "http://127.0.0.1:1/v1", Model: "scene-description-test-model",
})))
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v, want nil", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "scene-description-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", `{"units":[1]}`),
"players": scriptorium.Inline("Dana: Mira"),
"party": scriptorium.Inline("Mira: ranger"),
"glossary": scriptorium.Inline("Greencloak: title"),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_scene_descriptions_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want scene-description prompt identity and schema wiring", prepared)
}
if got, want := messageRoles(prepared.Messages), []string{"system", "user", "user", "user", "user", "user"}; !reflect.DeepEqual(got, want) {
t.Fatalf("message roles = %#v, want %#v", got, want)
}
for _, index := range []int{1, 2, 4} {
if prepared.Messages[index].CacheControl == nil || prepared.Messages[index].CacheControl.Type != scriptorium.CacheControlEphemeral {
t.Fatalf("message %d cache control = %#v, want ephemeral", index, prepared.Messages[index].CacheControl)
}
}
for _, index := range []int{0, 3, 5} {
if prepared.Messages[index].CacheControl != nil {
t.Fatalf("message %d cache control = %#v, want nil", index, prepared.Messages[index].CacheControl)
}
}
if !strings.Contains(prepared.Messages[0].Content, "Dungeons & Dragons") {
t.Fatalf("first message does not use shared D&D system asset: %q", prepared.Messages[0].Content)
}
for index, want := range []string{"Dana: Mira", "Mira: ranger", "Greencloak: title"} {
if !strings.Contains(prepared.Messages[2].Content, want) {
t.Fatalf("reference %d not rendered in shared reference message: %q", index, prepared.Messages[2].Content)
}
}
if strings.Contains(prepared.Messages[1].Content, `{"units":[1]}`) || strings.Contains(prepared.Messages[2].Content, `{"units":[1]}`) {
t.Fatal("transcript rendered before its final message")
}
if !strings.Contains(prepared.Messages[5].Content, `{"units":[1]}`) {
t.Fatalf("final message does not render transcript: %q", prepared.Messages[5].Content)
}
transcriptMessages := 0
for _, message := range prepared.Messages {
if strings.Contains(message.Content, `{"units":[1]}`) {
transcriptMessages++
}
}
if transcriptMessages != 1 {
t.Fatalf("raw transcript rendered in %d messages, want exactly one", transcriptMessages)
}
}
func TestPromptMetadataAndDiagnosticsDoNotContainRawAssets(t *testing.T) {
hash, err := scriptoriumPromptMetadata()
if err != nil || !strings.HasPrefix(hash, "sha256:") {
t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want hash", hash, err)
}
payload, err := json.Marshal(newExtractor(t, &fakeSceneDescriptionsLLMClient{}).ManifestMetadata())
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"Choose exactly one kind", "common-dnd-system", "dnd_scene_descriptions_llm.v1.json"} {
if strings.Contains(string(payload), forbidden) {
t.Fatalf("metadata leaked raw prompt/schema content %q: %s", forbidden, payload)
}
}
}
func messageRoles(messages []scriptorium.RenderedMessage) []string {
roles := make([]string, len(messages))
for i, message := range messages {
roles[i] = string(message.Role)
}
return roles
}

View File

@@ -0,0 +1,102 @@
package scenedescriptions
import (
"context"
"encoding/json"
"errors"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func extractionRequest() contracts.TypedExtractionRequest {
doc := sourceDocument()
chunk := &source.Chunk{
ID: "session-alpha:chunk:0",
SourceID: doc.ID,
Index: 0,
Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3},
Content: []byte(`{"units":[1,2,3]}`),
MediaType: "application/json",
Units: append([]source.SourceUnit(nil), doc.Units...),
Metadata: map[string]any{"ignored": "chunk metadata"},
}
return contracts.TypedExtractionRequest{
Source: doc,
Chunk: chunk,
SourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:chunk", "file:///session-alpha.json"),
SessionID: "session-123",
LLMProfile: "profile-scene-descriptions",
}
}
func sourceDocument() *source.SourceDocument {
return &source.SourceDocument{
ID: "session-alpha",
Kind: "transcript",
Format: "application/vnd.seriatim.minimal+json",
Digest: "sha256:test",
Units: []source.SourceUnit{
{ID: 1, Kind: "transcript_segment", Text: "Mira Thorn enters the ruined watchtower."},
{ID: 2, Kind: "transcript_segment", Text: "Bandits attack from the upper floor."},
{ID: 3, Kind: "transcript_segment", Text: "The party drives them back."},
},
}
}
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
t.Helper()
extractor, err := New(client, Options{}, references...)
if err != nil {
t.Fatalf("New() error = %v, want nil", err)
}
return extractor
}
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
return req
}
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
return req
}
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
req.Inputs = req.Inputs.Clone()
if len(req.Vars) == 0 {
req.Vars = nil
return req
}
vars := make(map[string]any, len(req.Vars))
for key, value := range req.Vars {
vars[key] = value
}
req.Vars = vars
return req
}
type fakeSceneDescriptionsLLMClient struct {
response extractionResponse
err error
requests []contracts.StructuredCompletionRequest
}
func (client *fakeSceneDescriptionsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
if client.err != nil {
return contracts.StructuredCompletionResponse{}, client.err
}
target, ok := out.(*extractionResponse)
if !ok {
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
}
*target = client.response
content, err := json.Marshal(client.response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}

View File

@@ -0,0 +1,307 @@
// Package npcinteractions normalizes merged D&D NPC interaction candidates.
package npcinteractions
import (
"context"
"fmt"
"sort"
"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"
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const (
Key = "dnd/npc-interactions"
normalizationPolicy = "dnd.npc_interactions.normalize.v2"
NormalizationPolicy = normalizationPolicy
ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeInteractionsReordered = "npc_interactions_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed"
ReasonCodeWarningsOmitted = "npc_interaction_normalization_warnings_omitted"
)
const (
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
NPCRegistryMaxBytes = npcregistry.MaxBytes
)
var requiredCapabilities = []string{"merged"}
var providedCapabilities = []string{"normalized"}
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for interaction disambiguation.",
Party: "Optional party roster reference material used only for interaction disambiguation.",
Players: "Optional player list reference material used only for interaction disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.",
}
var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct {
npcResolver *npcregistry.Resolver
}
func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) {
if len(references) > 1 {
return nil, normalizerErrorf("at most one reference set may be supplied")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
resolver, err := npcregistry.NewResolver(referenceSet)
if err != nil {
return nil, normalizerErrorf("prepare NPC registry: %w", err)
}
return &Normalizer{npcResolver: resolver}, nil
}
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil || n.npcResolver == nil {
return nil
}
metadata := map[string]any{
"normalization_policy": normalizationPolicy,
"identity_policy": identity.Policy,
}
seeded := n.npcResolver.Seeded()
if seeded.Bound() {
metadata["npc_registry_digest"] = seeded.Digest()
metadata["npc_count"] = seeded.Count()
}
return metadata
}
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil || n.npcResolver == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "identity_policy", Value: identity.Policy},
{Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()},
}
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCInteractionList]) (contracts.TypedNormalizeResult[dnd.NPCInteractionList], error) {
if n == nil || n.npcResolver == nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("normalizer must not be nil")
}
if ctx == nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context error before normalize: %w", err)
}
registry, err := n.npcResolver.Resolve(req.References)
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("resolve NPC registry: %w", err)
}
if !registry.Bound() {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
}
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, registry)
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
}
type normalizedRecord struct {
interaction dnd.NPCInteraction
inputIndex int
}
type nameCanonicalization struct {
from string
to string
}
func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
if input.Interactions == nil {
return dnd.NPCInteractionList{}, nil
}
records := make([]normalizedRecord, len(input.Interactions))
warnings := make([]contracts.Warning, 0)
for index, inputInteraction := range input.Interactions {
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, doc, registry)
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
if nameChange != nil {
warnings = append(warnings, contracts.Warning{
Scope: interactionScope(index),
ReasonCode: ReasonCodeNameCanonicalized,
Message: fmt.Sprintf("input index %d: NPC name canonicalized from %s to %s",
index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)),
})
}
if refsChanged {
warnings = append(warnings, contracts.Warning{
Scope: interactionScope(index),
ReasonCode: ReasonCodeSourceRefsNormalized,
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
index, len(inputInteraction.SourceRefs), len(interaction.SourceRefs)),
})
}
}
sort.SliceStable(records, func(left, right int) bool {
return interactionmodel.Less(doc, records[left].interaction, records[right].interaction)
})
for position, record := range records {
if position == record.inputIndex {
continue
}
warnings = append(warnings, contracts.Warning{
Scope: interactionScope(record.inputIndex),
ReasonCode: ReasonCodeInteractionsReordered,
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position),
})
}
output, duplicateWarnings := collapseDuplicates(records, doc)
warnings = append(warnings, duplicateWarnings...)
return dnd.NPCInteractionList{Interactions: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
}
func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
output := cloneInteraction(input)
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
output.Name = canonical.Name
}
var nameChange *nameCanonicalization
if input.Name != output.Name {
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
}
output.SourceRefs = interactionmodel.CanonicalizeSourceRefs(doc, input.SourceRefs)
return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
}
func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction {
output := input
if input.SourceRefs != nil {
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
}
return output
}
type duplicateGroup struct {
retainedIndex int
removed []int
}
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.NPCInteraction, []contracts.Warning) {
if len(records) == 0 {
return make([]dnd.NPCInteraction, 0), nil
}
keep := make([]bool, len(records))
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, record := range records {
if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) {
keep[index] = true
continue
}
key := interactionmodel.ExactIdentity(record.interaction)
groupIndex, exists := groupByKey[key]
if !exists {
groupByKey[key] = len(groups)
groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex})
keep[index] = true
continue
}
groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex)
}
output := make([]dnd.NPCInteraction, 0, len(records))
for index, record := range records {
if keep[index] {
output = append(output, cloneInteraction(record.interaction))
}
}
warnings := make([]contracts.Warning, 0)
for _, group := range groups {
if len(group.removed) != 0 {
warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed))
}
}
return output, warnings
}
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
issues := make([]string, len(removed))
for index, removedIndex := range removed {
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
}
return contracts.Warning{
Scope: interactionScope(retainedIndex),
ReasonCode: ReasonCodeDuplicateCollapsed,
Message: diagnostics.Aggregate(
fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues),
}
}
func interactionScope(index int) string { return fmt.Sprintf("interactions[%d]", index) }
func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot,
Description: "Required normalized NPC registry used only for interaction identity grounding, never as interaction evidence.",
Required: true,
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes,
})
sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name })
return slots
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageNormalize,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCInteractionListKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.NormalizerRegistry) error {
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCInteractionList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options, request.References)
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, normalizerErrorf("%w", err)
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func normalizerErrorf(format string, args ...any) error {
return fmt.Errorf("dnd NPC interactions normalizer: "+format, args...)
}

View File

@@ -0,0 +1,199 @@
package npcinteractions
import (
"context"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
func TestNormalizeCanonicalizesAndClones(t *testing.T) {
doc := testDocument()
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatalf("New() error = %v", err)
}
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
}}}
original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...)
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
got := result.Value.Interactions[0]
if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}) {
t.Fatalf("normalized interaction = %#v", got)
}
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
t.Fatalf("warnings = %#v", result.Warnings)
}
if !reflect.DeepEqual(input.Interactions[0].SourceRefs, original) {
t.Fatalf("Normalize() mutated input: %#v", input)
}
second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: result.Value}})
if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 {
t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err)
}
result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999
if input.Interactions[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("normalized source refs share input storage")
}
}
func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *testing.T) {
normalizer, err := New(Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{}); err == nil {
t.Fatal("Normalize() accepted an unbound NPC registry")
}
for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} {
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{
MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t),
})
if err != nil || (result.Value.Interactions == nil) != (input.Interactions == nil) {
t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input)
}
}
if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("operation registry leaked into metadata: %#v", metadata)
}
}
func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) {
doc := testDocument()
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatal(err)
}
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) {
t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err)
}
}
func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}}
ref := func(unit int) source.SourceRef {
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
}
first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50))
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)),
first,
first,
interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)),
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)),
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)),
}}
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatal(err)
}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
if err != nil {
t.Fatal(err)
}
got := result.Value.Interactions
if len(got) != 5 {
t.Fatalf("interaction count = %d, want 5: %#v", len(got), got)
}
if got[0].Kind != dnd.NPCInteractionKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 {
t.Fatalf("canonical order = %#v", got)
}
if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
t.Fatalf("warnings = %#v", result.Warnings)
}
}
func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatal(err)
}
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCInteractionListKind || len(spec.ReferenceSlots) == 0 {
t.Fatalf("ModuleSpec() = %#v", spec)
}
if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 {
t.Fatalf("metadata = %#v", metadata)
}
if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 3 || fingerprints[2].Name != "npc_registry" || fingerprints[2].Value == "" {
t.Fatalf("fingerprints = %#v", fingerprints)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted an unknown option")
}
}
func TestNormalizeBoundsWarnings(t *testing.T) {
count := diagnostics.MaxWarnings + 5
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
input := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, count)}
for index := range doc.Units {
doc.Units[index].ID = index + 1
unitID := count - index
input.Interactions[index] = interaction(
"Ária",
dnd.NPCInteractionKindDialogue,
source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID},
)
}
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatal(err)
}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{
Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input},
})
if err != nil {
t.Fatal(err)
}
if len(result.Warnings) != diagnostics.MaxWarnings ||
result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted {
t.Fatalf("warnings = %#v", result.Warnings)
}
}
func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction {
return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
}
func testDocument() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
}
func npcReferences(t *testing.T) contracts.ReferenceSet {
t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Ária"), Name: "Ária", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}},
{ID: identity.DeriveID("Borin"), Name: "Borin", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}},
}}
content, err := npccodec.New().Encode(value)
if err != nil {
t.Fatalf("encode registry: %v", err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, Required: true, AcceptedMediaTypes: []string{npccodec.MediaType}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, MaxBytes: NPCRegistryMaxBytes},
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
}}}
}
func hasWarning(warnings []contracts.Warning, reason string) bool {
for _, warning := range warnings {
if warning.ReasonCode == reason {
return true
}
}
return false
}

View File

@@ -0,0 +1,173 @@
// Package scenedescriptions normalizes merged D&D scene descriptions.
package scenedescriptions
import (
"context"
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape"
)
const (
Key = "dnd/scene-descriptions"
normalizerPolicy = "dnd.scene_descriptions.normalizer.v1"
)
var requiredCapabilities = []string{"merged"}
var providedCapabilities = []string{"normalized"}
var _ contracts.Normalizer[dnd.SceneDescriptionList] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct{}
func New(Options) *Normalizer { return &Normalizer{} }
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil {
return nil
}
return map[string]any{"normalizer_policy": normalizerPolicy}
}
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil {
return nil
}
return []pipeline.CheckpointFingerprint{{Name: "normalizer_policy", Value: normalizerPolicy}}
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SceneDescriptionList]) (contracts.TypedNormalizeResult[dnd.SceneDescriptionList], error) {
if n == nil {
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalizer must not be nil")
}
if ctx == nil {
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context error before normalize: %w", err)
}
value, err := normalizeList(req.MergeOutput.Value, req.Source)
if err != nil {
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalize scenes: %w", err)
}
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{Value: value}, nil
}
func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (dnd.SceneDescriptionList, error) {
if doc == nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("source document must not be nil")
}
if input.Scenes == nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must be present")
}
if len(input.Scenes) == 0 {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty")
}
unitPositions := make(map[int]int, len(doc.Units))
for index, unit := range doc.Units {
unitPositions[unit.ID] = index
}
output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))}
for index, scene := range input.Scenes {
scene.Title = strings.TrimSpace(scene.Title)
scene.Summary = strings.TrimSpace(scene.Summary)
if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", index, err)
}
if err := source.ValidateRef(doc, scene.SourceRef); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", index, diagnostics.Truncate(err.Error()))
}
output.Scenes[index] = scene
}
sort.SliceStable(output.Scenes, func(left, right int) bool {
leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID]
rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID]
if leftStart != rightStart {
return leftStart < rightStart
}
return output.Scenes[left].ID < output.Scenes[right].ID
})
unique := make([]dnd.SceneDescription, 0, len(output.Scenes))
byID := make(map[string]dnd.SceneDescription, len(output.Scenes))
byRange := make(map[source.SourceRef]dnd.SceneDescription, len(output.Scenes))
seen := make(map[dnd.SceneDescription]struct{}, len(output.Scenes))
for _, scene := range output.Scenes {
if previous, ok := byID[scene.ID]; ok && !identical(previous, scene) {
return dnd.SceneDescriptionList{}, fmt.Errorf("scene ID %s has conflicting records", diagnostics.Quote(scene.ID))
}
if previous, ok := byRange[scene.SourceRef]; ok && !sameModelContent(previous, scene) {
return dnd.SceneDescriptionList{}, fmt.Errorf("source range %s has conflicting records", sourceRefLabel(scene.SourceRef))
}
if _, ok := seen[scene]; ok {
continue
}
byID[scene.ID] = scene
byRange[scene.SourceRef] = scene
seen[scene] = struct{}{}
unique = append(unique, scene)
}
output.Scenes = unique
return output, nil
}
func identical(left, right dnd.SceneDescription) bool {
return left == right
}
func sameModelContent(left, right dnd.SceneDescription) bool {
return left.Kind == right.Kind && left.Title == right.Title && left.Summary == right.Summary
}
func sourceRefLabel(ref source.SourceRef) string {
return fmt.Sprintf("%s:%d-%d", diagnostics.Quote(ref.SourceID), ref.StartUnitID, ref.EndUnitID)
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageNormalize,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.SceneDescriptionListKind,
}
}
func Register(registry *pipeline.NormalizerRegistry) error {
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.SceneDescriptionList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options), nil
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, normalizerErrorf("%w", err)
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func normalizerErrorf(format string, args ...any) error {
return fmt.Errorf("dnd scene descriptions normalizer: "+format, args...)
}

View File

@@ -0,0 +1,135 @@
package scenedescriptions
import (
"context"
"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 TestNormalizeTrimsOrdersDeduplicatesAndOwnsOutput(t *testing.T) {
doc := testDocument()
input := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{
scene("later", 3, 3, dnd.SceneKindNarrative, " Return ", " The party returns. "),
scene("first-b", 1, 1, dnd.SceneKindMeta, " Rules ", " The table checks rules. "),
scene("first-a", 1, 1, dnd.SceneKindMeta, " Rules ", " The table checks rules. "),
scene("later", 3, 3, dnd.SceneKindNarrative, " Return ", " The party returns. "),
}}
before := cloneList(input)
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input, doc))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
want := []dnd.SceneDescription{
scene("first-a", 1, 1, dnd.SceneKindMeta, "Rules", "The table checks rules."),
scene("first-b", 1, 1, dnd.SceneKindMeta, "Rules", "The table checks rules."),
scene("later", 3, 3, dnd.SceneKindNarrative, "Return", "The party returns."),
}
if !reflect.DeepEqual(result.Value.Scenes, want) {
t.Fatalf("scenes = %#v, want %#v", result.Value.Scenes, want)
}
if !reflect.DeepEqual(input, before) {
t.Fatalf("Normalize() mutated input: %#v", input)
}
result.Value.Scenes[0].Title = "changed"
if input.Scenes[0].Title == "changed" {
t.Fatal("normalized output aliases input storage")
}
}
func TestNormalizeRejectsInvalidCandidatesAndConflicts(t *testing.T) {
doc := testDocument()
valid := scene("one", 1, 1, dnd.SceneKindNarrative, "Arrival", "The party arrives.")
for _, test := range []struct {
name string
input dnd.SceneDescriptionList
}{
{name: "nil list", input: dnd.SceneDescriptionList{}},
{name: "empty list", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}},
{name: "blank ID", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives.", SourceRef: valid.SourceRef}}}},
{name: "invalid kind", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: "one", Kind: "other", Title: "Arrival", Summary: "The party arrives.", SourceRef: valid.SourceRef}}}},
{name: "invalid range", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: "one", Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives.", SourceRef: source.SourceRef{SourceID: doc.ID, StartUnitID: 9, EndUnitID: 9}}}}},
{name: "same ID conflict", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{valid, scene("one", 2, 2, dnd.SceneKindNarrative, "Departure", "The party leaves.")}}},
{name: "same range conflict", input: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{valid, scene("two", 1, 1, dnd.SceneKindCombat, "Ambush", "Bandits strike.")}}},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := New(Options{}).Normalize(context.Background(), normalizeRequest(test.input, doc)); err == nil {
t.Fatal("Normalize() error = nil, want rejection")
}
})
}
}
func TestNormalizeUsesDocumentOrderRatherThanNumericUnitID(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 20, Text: "The party arrives."},
{ID: 10, Text: "The party departs."},
}}
input := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{
scene("later", 10, 10, dnd.SceneKindNarrative, "Departure", "The party departs."),
scene("first", 20, 20, dnd.SceneKindNarrative, "Arrival", "The party arrives."),
}}
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input, doc))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
if got, want := []string{result.Value.Scenes[0].ID, result.Value.Scenes[1].ID}, []string{"first", "later"}; !reflect.DeepEqual(got, want) {
t.Fatalf("scene order = %#v, want %#v", got, want)
}
}
func TestNormalizerContractAndCancellation(t *testing.T) {
if _, err := DecodeOptions(nil); err != nil {
t.Fatalf("DecodeOptions(nil) error = %v", err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown options")
}
want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.SceneDescriptionListKind}
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
}
registry := pipeline.NewNormalizerRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v", err)
}
normalizer := New(Options{})
if metadata := normalizer.ManifestMetadata(); metadata["normalizer_policy"] != normalizerPolicy {
t.Fatalf("metadata = %#v", metadata)
}
if got, want := normalizer.CheckpointFingerprints(), []pipeline.CheckpointFingerprint{{Name: "normalizer_policy", Value: normalizerPolicy}}; !reflect.DeepEqual(got, want) {
t.Fatalf("fingerprints = %#v, want %#v", got, want)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.SceneDescriptionList{}, testDocument())); err == nil || !strings.Contains(err.Error(), "context") {
t.Fatalf("canceled Normalize() error = %v", err)
}
if _, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene("one", 1, 1, dnd.SceneKindNarrative, "Arrival", "The party arrives.")}}, nil)); err == nil || !strings.Contains(err.Error(), "source document") {
t.Fatalf("nil source Normalize() error = %v", err)
}
}
func normalizeRequest(value dnd.SceneDescriptionList, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.SceneDescriptionList] {
return contracts.TypedNormalizeRequest[dnd.SceneDescriptionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.SceneDescriptionList]{Value: value}}
}
func testDocument() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Rules are discussed."}, {ID: 2, Text: "The party travels."}, {ID: 3, Text: "The party returns."}}}
}
func scene(id string, start, end int, kind dnd.SceneKind, title, summary string) dnd.SceneDescription {
return dnd.SceneDescription{ID: id, SourceRef: source.SourceRef{SourceID: "session", StartUnitID: start, EndUnitID: end}, Kind: kind, Title: title, Summary: summary}
}
func cloneList(value dnd.SceneDescriptionList) dnd.SceneDescriptionList {
if value.Scenes != nil {
value.Scenes = append([]dnd.SceneDescription(nil), value.Scenes...)
}
return value
}

View File

@@ -0,0 +1,166 @@
// Package npcinteractions owns canonical ordering and exact-identity rules for
// D&D NPC interaction artifacts.
package npcinteractions
import (
"sort"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
// CanonicalizeSourceRefs returns a cloned, document-ordered, de-duplicated
// source-reference list.
func CanonicalizeSourceRefs(doc *source.SourceDocument, input []source.SourceRef) []source.SourceRef {
if input == nil {
return nil
}
canonical := append([]source.SourceRef(nil), input...)
sort.SliceStable(canonical, func(left, right int) bool {
return SourceRefLess(doc, canonical[left], canonical[right])
})
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique
}
// SourceRefsEqual reports whether two source-reference lists have identical
// representations and values.
func SourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
// SourceRefLess orders references by source identity and then by the source
// document positions of their endpoints. Invalid endpoints sort after valid
// endpoints and fall back to their literal IDs for deterministic diagnostics.
func SourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID)
rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID)
if leftStartOK != rightStartOK {
return leftStartOK
}
if leftStartOK && leftStart != rightStart {
return leftStart < rightStart
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID)
rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID)
if leftEndOK != rightEndOK {
return leftEndOK
}
if leftEndOK && leftEnd != rightEnd {
return leftEnd < rightEnd
}
return left.EndUnitID < right.EndUnitID
}
// Less defines the canonical order for NPC interaction occurrences.
func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
leftPosition, leftHasEvidence := EarliestSourcePosition(doc, left)
rightPosition, rightHasEvidence := EarliestSourcePosition(doc, right)
if leftHasEvidence != rightHasEvidence {
return leftHasEvidence
}
if leftHasEvidence && leftPosition != rightPosition {
return leftPosition < rightPosition
}
leftKey := identity.ComparisonKey(left.Name)
rightKey := identity.ComparisonKey(right.Name)
if leftKey != rightKey {
return leftKey < rightKey
}
if left.Name != right.Name {
return left.Name < right.Name
}
if left.Kind != right.Kind {
return left.Kind < right.Kind
}
return sourceRefsLess(doc, left.SourceRefs, right.SourceRefs)
}
// EarliestSourcePosition returns the earliest valid cited position.
func EarliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) {
found := false
earliest := 0
for _, ref := range interaction.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
position, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && position >= earliest) {
continue
}
earliest = position
found = true
}
return earliest, found
}
// ValidSourceRefs reports whether an interaction has non-empty, valid
// current-document evidence.
func ValidSourceRefs(doc *source.SourceDocument, refs []source.SourceRef) bool {
if len(refs) == 0 {
return false
}
for _, ref := range refs {
if source.ValidateRef(doc, ref) != nil {
return false
}
}
return true
}
// ExactIdentity returns a collision-safe key over every durable interaction
// field. Callers decide whether the record is eligible for duplicate handling.
func ExactIdentity(interaction dnd.NPCInteraction) string {
var key strings.Builder
writeKeyString(&key, interaction.Name)
writeKeyString(&key, string(interaction.Kind))
for _, ref := range interaction.SourceRefs {
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)
writeKeyInt(&key, ref.EndUnitID)
}
return key.String()
}
func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool {
for index := 0; index < len(left) && index < len(right); index++ {
if left[index] == right[index] {
continue
}
return SourceRefLess(doc, left[index], right[index])
}
return len(left) < len(right)
}
func writeKeyString(builder *strings.Builder, value string) {
builder.WriteString(strconv.Itoa(len(value)))
builder.WriteByte(':')
builder.WriteString(value)
}
func writeKeyInt(builder *strings.Builder, value int) {
builder.WriteString(strconv.Itoa(value))
builder.WriteByte(';')
}

View File

@@ -3,19 +3,32 @@ package register
import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants"
interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
interactionrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_refs"
interactionrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_relatedness"
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
sceneinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/invariants"
sceneshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape"
scenerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_refs"
scenerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_relatedness"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
@@ -108,5 +121,61 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
},
})
}},
{name: "npc interactions validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: interactionextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(interactionshape.Key),
pipeline.Binding(interactionregistry.Key),
pipeline.Binding(interactionrefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(interactionrelatedness.Key),
},
})
}},
{name: "npc interactions normalize validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: interactionnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(interactionshape.Key),
pipeline.Binding(interactionregistry.Key),
pipeline.Binding(interactioninvariants.Key),
pipeline.Binding(interactionrefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(interactionrelatedness.Key),
},
})
}},
{name: "scene descriptions validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: scenedescriptionextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(sceneshape.Key),
pipeline.Binding(scenerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(scenerelatedness.Key),
},
})
}},
{name: "scene descriptions normalize validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: scenedescriptionnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(sceneshape.Key),
pipeline.Binding(sceneinvariants.Key),
pipeline.Binding(scenerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(scenerelatedness.Key),
},
})
}},
})
}

View File

@@ -57,6 +57,46 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
return combined, nil
}
func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) {
count := 0
present := false
for _, value := range values {
if value.Interactions != nil {
present = true
}
count += len(value.Interactions)
}
if !present {
return dnd.NPCInteractionList{}, nil
}
combined := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 0, count)}
for _, value := range values {
for _, interaction := range value.Interactions {
combined.Interactions = append(combined.Interactions, cloneNPCInteraction(interaction))
}
}
return combined, nil
}
func appendSceneDescriptionLists(values []dnd.SceneDescriptionList) (dnd.SceneDescriptionList, error) {
count := 0
present := false
for _, value := range values {
if value.Scenes != nil {
present = true
}
count += len(value.Scenes)
}
if !present {
return dnd.SceneDescriptionList{}, nil
}
combined := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, 0, count)}
for _, value := range values {
combined.Scenes = append(combined.Scenes, value.Scenes...)
}
return combined, nil
}
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
clone := value
if value.SourceRefs != nil {
@@ -64,3 +104,11 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
}
return clone
}
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
clone := value
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
return clone
}

View File

@@ -6,13 +6,19 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
@@ -24,10 +30,16 @@ func registerModules(registries pipeline.Registries) error {
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
{name: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }},
{name: "scene descriptions codec", register: func() error {
return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, scenedescriptioncodec.New())
}},
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
{name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }},
{name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }},
{name: "spell-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
}},
@@ -37,9 +49,17 @@ func registerModules(registries pipeline.Registries) error {
{name: "combat-turn-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
}},
{name: "npc-interaction-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists)
}},
{name: "scene-description-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists)
}},
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
{name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }},
{name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }},
{name: "spell-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind)
}},
@@ -49,6 +69,12 @@ func registerModules(registries pipeline.Registries) error {
{name: "combat-turn-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
}},
{name: "npc-interaction-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind)
}},
{name: "scene-description-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind)
}},
})
}
@@ -58,5 +84,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error {
{name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }},
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
{name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }},
{name: "scene descriptions prompt assets", register: func() error { return scenedescriptionextract.RegisterPromptAssets(assets) }},
})
}

View File

@@ -13,10 +13,14 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
)
@@ -27,13 +31,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
t.Fatalf("Register() error = %v, want nil", err)
}
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key})
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, pipeline.DefaultNormalizeModule})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, interactionextract.Key, scenedescriptionextract.Key})
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/npcs/shape",
"extract/dnd/npcs/source_refs",
@@ -47,6 +53,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"extract/dnd/combat-turns/source_refs",
"extract/dnd/combat-turns/source_relatedness",
"normalize/dnd/combat-turns/invariants",
"extract/dnd/npc-interactions/shape",
"extract/dnd/npc-interactions/registry",
"extract/dnd/npc-interactions/source_refs",
"extract/dnd/npc-interactions/source_relatedness",
"normalize/dnd/npc-interactions/invariants",
"extract/dnd/scene-descriptions/shape",
"extract/dnd/scene-descriptions/source_refs",
"extract/dnd/scene-descriptions/source_relatedness",
"normalize/dnd/scene-descriptions/invariants",
"generic/always_accept",
"generic/always_reject",
})
@@ -106,9 +121,56 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
}
interactionExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/npc-interactions/shape"),
pipeline.Binding("extract/dnd/npc-interactions/registry"),
pipeline.Binding("extract/dnd/npc-interactions/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"),
}
interactionNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/npc-interactions/shape"),
pipeline.Binding("extract/dnd/npc-interactions/registry"),
pipeline.Binding("normalize/dnd/npc-interactions/invariants"),
pipeline.Binding("extract/dnd/npc-interactions/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, interactionextract.Key); !reflect.DeepEqual(got, interactionExtractChain) {
t.Fatalf("NPC interaction extract validator chain = %#v, want %#v", got, interactionExtractChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, interactionnormalize.Key); !reflect.DeepEqual(got, interactionNormalizeChain) {
t.Fatalf("NPC interaction normalize validator chain = %#v, want %#v", got, interactionNormalizeChain)
}
sceneExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/scene-descriptions/shape"),
pipeline.Binding("extract/dnd/scene-descriptions/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, scenedescriptionextract.Key); !reflect.DeepEqual(got, sceneExtractChain) {
t.Fatalf("scene description extract validator chain = %#v, want %#v", got, sceneExtractChain)
}
sceneNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/scene-descriptions/shape"),
pipeline.Binding("normalize/dnd/scene-descriptions/invariants"),
pipeline.Binding("extract/dnd/scene-descriptions/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, scenedescriptionnormalize.Key); !reflect.DeepEqual(got, sceneNormalizeChain) {
t.Fatalf("scene description normalize validator chain = %#v, want %#v", got, sceneNormalizeChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil {
t.Fatalf("NPC merge validator chain = %#v, want absent", got)
}
if got := registries.ValidatorChains.Validators(pipeline.StageMerge, interactionextract.Key); got != nil {
t.Fatalf("NPC interaction merge validator chain = %#v, want absent", got)
}
assertAssetNamesContain(t, assets.PromptFS, []string{
"dnd.scenes/dnd.scenes.yaml",
"dnd.scenes/instructions.md",
@@ -134,12 +196,25 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd.combat_turns/sharedassets/common-dnd-system.md",
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
"dnd.combat_turns/task.md",
"dnd.npc_interactions/dnd.npc_interactions.yaml",
"dnd.npc_interactions/instructions.md",
"dnd.npc_interactions/sharedassets/common-dnd-npcs.md",
"dnd.npc_interactions/task.md",
"dnd.scene_descriptions/dnd.scene_descriptions.yaml",
"dnd.scene_descriptions/instructions.md",
"dnd.scene_descriptions/sharedassets/common-dnd-identity.md",
"dnd.scene_descriptions/sharedassets/common-dnd-references.md",
"dnd.scene_descriptions/sharedassets/common-dnd-system.md",
"dnd.scene_descriptions/sharedassets/common-dnd-transcript.md",
"dnd.scene_descriptions/task.md",
})
assertAssetNamesContain(t, assets.SchemaFS, []string{
"dnd_scenes.v1.json",
"dnd_spells_llm.v1.json",
"dnd_npcs_llm.v1.json",
"dnd_combat_turns_llm.v1.json",
"dnd_npc_interactions_llm.v1.json",
"dnd_scene_descriptions_llm.v1.json",
})
if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" {
t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok)
@@ -162,6 +237,33 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize {
t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok)
}
interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key)
interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key)
if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize {
t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK)
}
sceneExtractSpec, sceneExtractOK := registries.Extractors.Spec(scenedescriptionextract.Key)
sceneNormalizeSpec, sceneNormalizeOK := registries.Normalizers.Spec(scenedescriptionnormalize.Key)
if !sceneExtractOK || sceneExtractSpec.ArtifactKind != dnd.SceneDescriptionListKind || !sceneNormalizeOK || sceneNormalizeSpec.ArtifactKind != dnd.SceneDescriptionListKind || sceneNormalizeSpec.Stage != pipeline.StageNormalize {
t.Fatalf("scene description specs = %#v / %#v, present = %t / %t", sceneExtractSpec, sceneNormalizeSpec, sceneExtractOK, sceneNormalizeOK)
}
if len(sceneExtractSpec.ReferenceSlots) != 3 || len(sceneNormalizeSpec.ReferenceSlots) != 0 {
t.Fatalf("scene description reference slots = %#v / %#v, want extractor campaign slots only", sceneExtractSpec.ReferenceSlots, sceneNormalizeSpec.ReferenceSlots)
}
extractRegistrySlot := referenceSlot(interactionExtractSpec.ReferenceSlots, "npcs")
normalizeRegistrySlot := referenceSlot(interactionNormalizeSpec.ReferenceSlots, "npcs")
if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || !reflect.DeepEqual(extractRegistrySlot, normalizeRegistrySlot) {
t.Fatalf("NPC interaction registry slots disagree: %#v / %#v", interactionExtractSpec.ReferenceSlots, interactionNormalizeSpec.ReferenceSlots)
}
}
func referenceSlot(slots []contracts.ReferenceSlot, name string) contracts.ReferenceSlot {
for _, slot := range slots {
if slot.Name == name {
return slot
}
}
return contracts.ReferenceSlot{}
}
func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
@@ -185,6 +287,52 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
}
}
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
input := []dnd.NPCInteractionList{
{},
{Interactions: []dnd.NPCInteraction{}},
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}},
{Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
}
got, err := appendNPCInteractionLists(input)
if err != nil {
t.Fatalf("appendNPCInteractionLists() error = %v", err)
}
if got.Interactions == nil || !reflect.DeepEqual([]string{got.Interactions[0].Name, got.Interactions[1].Name}, []string{"Aria", "Borin"}) {
t.Fatalf("combined interactions = %#v", got)
}
got.Interactions[0].SourceRefs[0].StartUnitID = 999
if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("merged interactions share source reference storage")
}
for _, values := range [][]dnd.NPCInteractionList{nil, []dnd.NPCInteractionList{{}, {}}} {
result, err := appendNPCInteractionLists(values)
if err != nil || result.Interactions != nil {
t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err)
}
}
}
func TestAppendSceneDescriptionListsPreservesOrderAndArrayPresence(t *testing.T) {
input := []dnd.SceneDescriptionList{
{},
{Scenes: []dnd.SceneDescription{}},
{Scenes: []dnd.SceneDescription{{ID: "first"}}},
{Scenes: []dnd.SceneDescription{{ID: "second"}}},
}
got, err := appendSceneDescriptionLists(input)
if err != nil || !reflect.DeepEqual(got.Scenes, []dnd.SceneDescription{{ID: "first"}, {ID: "second"}}) {
t.Fatalf("appendSceneDescriptionLists() = %#v, error = %v", got, err)
}
for _, values := range [][]dnd.SceneDescriptionList{nil, []dnd.SceneDescriptionList{{}, {}}} {
result, err := appendSceneDescriptionLists(values)
if err != nil || result.Scenes != nil {
t.Fatalf("nil-only merge = %#v, %v; want nil scenes", result, err)
}
}
}
func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
input := []dnd.CombatTurnList{

View File

@@ -7,10 +7,19 @@ import (
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants"
interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
interactionrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_refs"
interactionrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_relatedness"
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
sceneinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/invariants"
sceneshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape"
scenerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_refs"
scenerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_relatedness"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
@@ -33,6 +42,15 @@ func registerValidators(registries pipeline.Registries) error {
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
{name: "npc interaction shape validator", register: func() error { return interactionshape.Register(registries.Validators) }},
{name: "npc interaction registry validator", register: func() error { return interactionregistry.Register(registries.Validators) }},
{name: "npc interaction source references validator", register: func() error { return interactionrefs.Register(registries.Validators) }},
{name: "npc interaction source relatedness validator", register: func() error { return interactionrelatedness.Register(registries.Validators) }},
{name: "npc interaction normalized invariants validator", register: func() error { return interactioninvariants.Register(registries.Validators) }},
{name: "scene description shape validator", register: func() error { return sceneshape.Register(registries.Validators) }},
{name: "scene description source references validator", register: func() error { return scenerefs.Register(registries.Validators) }},
{name: "scene description source relatedness validator", register: func() error { return scenerelatedness.Register(registries.Validators) }},
{name: "scene description normalized invariants validator", register: func() error { return sceneinvariants.Register(registries.Validators) }},
{name: "spell-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
}},
@@ -51,5 +69,17 @@ func registerValidators(registries pipeline.Registries) error {
{name: "combat-turn-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
}},
{name: "npc-interaction-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind)
}},
{name: "npc-interaction-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind)
}},
{name: "scene-description-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind)
}},
{name: "scene-description-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind)
}},
})
}

View File

@@ -23,12 +23,12 @@ type PromptAssetManifest struct {
}
var sharedPromptPaths = map[string]string{
"common-dnd-system.md": "assets/prompts/common-dnd-system.md",
"common-dnd-extraction-evidence.md": "assets/prompts/common-dnd-extraction-evidence.md",
"common-dnd-identity.md": "assets/prompts/common-dnd-identity.md",
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md",
"common-dnd-references.md": "assets/prompts/common-dnd-references.md",
"common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md",
"common-dnd-system.md": "assets/prompts/common-dnd-system.md",
"common-dnd-extraction-evidence.md": "assets/prompts/common-dnd-extraction-evidence.md",
"common-dnd-identity.md": "assets/prompts/common-dnd-identity.md",
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md",
"common-dnd-references.md": "assets/prompts/common-dnd-references.md",
"common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md",
}
func (manifest PromptAssetManifest) PromptFS(moduleFS fs.FS) (fs.FS, error) {

View File

@@ -1,6 +1,6 @@
An optional normalized Dungeons & Dragons NPC registry is provided below as
grounding material. Use it only to prefer exact canonical participant names
and recognize their aliases when the transcript identifies a participant.
A normalized Dungeons & Dragons NPC registry is provided below as grounding
material. It may be empty. Use it only to prefer exact canonical participant
names when the transcript identifies a participant.
Registry content is context, not event evidence. Do not extract events,
participants, effects, or source references from the registry. Registry source

View File

@@ -6,10 +6,13 @@ import (
"fmt"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const (
MaxIssues = 20
MaxWarnings = 20
MaxDisplayedRunes = 128
MaxMessageBytes = 4096
)
@@ -37,6 +40,28 @@ func Aggregate(prefix string, issues []string) string {
return aggregateMessage(prefix, displayed, len(issues)-len(displayed))
}
// LimitWarnings returns at most MaxWarnings warnings, reserving the final
// position for a deterministic omission summary when truncation is required.
func LimitWarnings(warnings []contracts.Warning, scope, reasonCode string) []contracts.Warning {
if warnings == nil {
return nil
}
if len(warnings) <= MaxWarnings {
bounded := make([]contracts.Warning, len(warnings))
copy(bounded, warnings)
return bounded
}
displayed := MaxWarnings - 1
bounded := make([]contracts.Warning, displayed, MaxWarnings)
copy(bounded, warnings[:displayed])
bounded = append(bounded, contracts.Warning{
Scope: scope,
ReasonCode: reasonCode,
Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed),
})
return bounded
}
func aggregateMessage(prefix string, issues []string, omitted int) string {
message := prefix + ": " + strings.Join(issues, ", ")
if omitted > 0 {

View File

@@ -2,9 +2,12 @@ package diagnostics
import (
"fmt"
"reflect"
"strings"
"testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
@@ -26,3 +29,25 @@ func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
t.Fatalf("Aggregate() = %q, want %q", message, wantOmitted)
}
}
func TestLimitWarningsBoundsOutputAndReportsOmissions(t *testing.T) {
warnings := make([]contracts.Warning, MaxWarnings+3)
for index := range warnings {
warnings[index] = contracts.Warning{ReasonCode: fmt.Sprintf("warning-%d", index)}
}
before := append([]contracts.Warning(nil), warnings...)
got := LimitWarnings(warnings, "records", "warnings_omitted")
if len(got) != MaxWarnings {
t.Fatalf("LimitWarnings() count = %d, want %d", len(got), MaxWarnings)
}
summary := got[len(got)-1]
wantOmitted := len(warnings) - (MaxWarnings - 1)
if summary.Scope != "records" || summary.ReasonCode != "warnings_omitted" ||
summary.Message != fmt.Sprintf("%d additional warning(s) omitted", wantOmitted) {
t.Fatalf("summary = %#v", summary)
}
if !reflect.DeepEqual(warnings, before) {
t.Fatal("LimitWarnings() mutated its input")
}
}

View File

@@ -12,6 +12,10 @@ const NPCListKind contracts.ArtifactKind = "dnd/npc-list"
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
type SpellList struct {
SpellCasts []SpellCast `json:"spell_casts"`
}
@@ -51,3 +55,45 @@ type CombatTurn struct {
TurnKind CombatTurnKind `json:"turn_kind"`
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"`
}
type SceneKind string
const (
SceneKindCombat SceneKind = "combat"
SceneKindNarrative SceneKind = "narrative"
SceneKindRecap SceneKind = "recap"
SceneKindMeta SceneKind = "meta"
)
type SceneDescriptionList struct {
Scenes []SceneDescription `json:"scenes"`
}
type SceneDescription struct {
ID string `json:"id"`
SourceRef source.SourceRef `json:"source_ref"`
Kind SceneKind `json:"kind"`
Title string `json:"title"`
Summary string `json:"summary"`
}

View File

@@ -0,0 +1,168 @@
// Package invariants validates normalized D&D NPC interaction artifacts.
package invariants
import (
"context"
"fmt"
"sort"
"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"
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
)
const (
Key = "normalize/dnd/npc-interactions/invariants"
ReasonCode = "invalid_npc_interaction_normalization"
policy = "dnd.npc_interactions.validator.normalized.v1"
)
type Options struct{}
type Validator struct {
npcResolver *npcregistry.Resolver
}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
if len(references) > 1 {
return nil, fmt.Errorf("NPC interaction invariants validator accepts at most one reference set")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
resolver, err := npcregistry.NewResolver(referenceSet)
if err != nil {
return nil, fmt.Errorf("prepare NPC registry: %w", err)
}
return &Validator{npcResolver: resolver}, nil
}
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) ManifestMetadata() map[string]any {
if v == nil || v.npcResolver == nil {
return nil
}
metadata := map[string]any{"policy": policy}
seeded := v.npcResolver.Seeded()
if seeded.Bound() {
metadata["npc_registry_digest"] = seeded.Digest()
metadata["npc_count"] = seeded.Count()
}
return metadata
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if v == nil || v.npcResolver == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()},
}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil || !allSourceRefsValid(req.Source, req.Value) {
return contracts.ValidationResult{Approved: true}, nil
}
if v == nil || v.npcResolver == nil {
return contracts.ValidationResult{}, fmt.Errorf("NPC interaction invariants validator must not be nil")
}
npcRegistry, err := v.npcResolver.Resolve(req.References)
if err != nil {
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
}
if !npcRegistry.Bound() {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC interaction normalization: NPC registry reference is required"}, nil
}
issues := issuesFor(req.Source, req.Value, npcRegistry)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC interaction normalization", issues),
}, nil
}
func allSourceRefsValid(doc *source.SourceDocument, value dnd.NPCInteractionList) bool {
for _, interaction := range value.Interactions {
if !interactionmodel.ValidSourceRefs(doc, interaction.SourceRefs) {
return false
}
}
return true
}
func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string {
issues := make([]string, 0)
for index, interaction := range value.Interactions {
prefix := fmt.Sprintf("interactions[%d]", index)
if canonical, ok := npcRegistry.Lookup(interaction.Name); ok && interaction.Name != canonical.Name {
issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(interaction.Name))
}
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ {
previous := interaction.SourceRefs[refIndex-1]
current := interaction.SourceRefs[refIndex]
if interactionmodel.SourceRefLess(doc, current, previous) {
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
} else if current == previous {
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
}
}
}
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool {
return interactionmodel.Less(doc, value.Interactions[left], value.Interactions[right])
}) {
issues = append(issues, "interactions are not in canonical order")
}
seen := make(map[string]int)
for index, interaction := range value.Interactions {
key := interactionmodel.ExactIdentity(interaction)
if previous, ok := seen[key]; ok {
issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous))
continue
}
seen[key] = index
}
return issues
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options, request.References)
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,169 @@
package invariants
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
)
func TestValidatorApprovesCanonicalNormalizedInteractions(t *testing.T) {
references := registryReferences(t, "Aria", "Borin")
result, err := newValidator(t, references).Validate(context.Background(), request(references, normalizedList()))
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(t *testing.T) {
references := registryReferences(t, "Aria", "Borin")
for _, test := range []struct {
name string
mutate func(*dnd.NPCInteractionList)
want string
}{
{"canonical name", func(value *dnd.NPCInteractionList) { value.Interactions[0].Name = " aria " }, "canonical NPC display name"},
{"reference order", func(value *dnd.NPCInteractionList) {
value.Interactions[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, "not in canonical order"},
{"duplicate reference", func(value *dnd.NPCInteractionList) {
value.Interactions[0].SourceRefs = append(value.Interactions[0].SourceRefs, value.Interactions[0].SourceRefs[0])
}, "duplicates the previous reference"},
{"list order", func(value *dnd.NPCInteractionList) {
value.Interactions[0], value.Interactions[1] = value.Interactions[1], value.Interactions[0]
}, "interactions are not in canonical order"},
{"name tie breaker", func(value *dnd.NPCInteractionList) {
value.Interactions[0] = dnd.NPCInteraction{Name: "Borin", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
value.Interactions[1] = dnd.NPCInteraction{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
}, "interactions are not in canonical order"},
{"duplicate record", func(value *dnd.NPCInteractionList) {
value.Interactions = append(value.Interactions, value.Interactions[0])
}, "duplicates interaction"},
} {
t.Run(test.name, func(t *testing.T) {
value := normalizedList()
test.mutate(&value)
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
}
})
}
}
func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
references := registryReferences(t, "Aria", "Borin")
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
}}
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) {
references := registryReferences(t, "Aria", "Borin")
for _, value := range []dnd.NPCInteractionList{
{Interactions: []dnd.NPCInteraction{{Name: "Aria"}}},
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
} {
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
t.Fatalf("deferral = %#v, %v", result, err)
}
}
result, err := newValidator(t).Validate(context.Background(), request(contracts.ReferenceSet{}, normalizedList()))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "required") {
t.Fatalf("unbound registry = %#v, %v", result, err)
}
}
func TestValidatorResolvesGeneratedRegistryAndKeepsMetadataAndInputsImmutable(t *testing.T) {
references := registryReferences(t, "Aria", "Borin")
validator := newValidator(t)
value := normalizedList()
before := cloneList(value)
result, err := validator.Validate(context.Background(), request(references, value))
if err != nil || !result.Approved || !reflect.DeepEqual(value, before) {
t.Fatalf("generated validation = %#v, %v; value=%#v", result, err, value)
}
metadata, err := json.Marshal(newValidator(t, references).ManifestMetadata())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(metadata), "Aria") || strings.Contains(string(metadata), "other-session") {
t.Fatalf("metadata leaked registry content: %s", metadata)
}
if got := newValidator(t, references).CheckpointFingerprints(); len(got) != 2 || got[0].Value != policy || !strings.HasPrefix(got[1].Value, "sha256:") {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
}
func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator {
t.Helper()
validator, err := New(Options{}, references...)
if err != nil {
t.Fatal(err)
}
return validator
}
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: document(), References: references, Value: value}
}
func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
}
func normalizedList() dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
}}
}
func cloneList(value dnd.NPCInteractionList) dnd.NPCInteractionList {
copyValue := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, len(value.Interactions))}
for index, interaction := range value.Interactions {
copyValue.Interactions[index] = interaction
copyValue.Interactions[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...)
}
return copyValue
}
func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet {
t.Helper()
npcs := make([]dnd.NPC, len(names))
for index, name := range names {
npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}}}
}
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
npcregistry.ReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}},
},
}}
}

View File

@@ -0,0 +1,130 @@
// Package registry validates D&D NPC interaction names against NPC grounding.
package registry
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
)
const (
Key = "extract/dnd/npc-interactions/registry"
ReasonCode = "invalid_npc_interaction_registry"
policy = "dnd.npc_interactions.validator.registry.v1"
)
type Options struct{}
type Validator struct {
npcResolver *npcregistry.Resolver
}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
if len(references) > 1 {
return nil, fmt.Errorf("NPC interaction registry validator accepts at most one reference set")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
resolver, err := npcregistry.NewResolver(referenceSet)
if err != nil {
return nil, fmt.Errorf("prepare NPC registry: %w", err)
}
return &Validator{npcResolver: resolver}, nil
}
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) ManifestMetadata() map[string]any {
if v == nil || v.npcResolver == nil {
return nil
}
metadata := map[string]any{"policy": policy}
seeded := v.npcResolver.Seeded()
if seeded.Bound() {
metadata["npc_registry_digest"] = seeded.Digest()
metadata["npc_count"] = seeded.Count()
}
return metadata
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if v == nil || v.npcResolver == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()},
}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
if v == nil || v.npcResolver == nil {
return contracts.ValidationResult{}, fmt.Errorf("NPC interaction registry validator must not be nil")
}
npcRegistry, err := v.npcResolver.Resolve(req.References)
if err != nil {
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
}
if !npcRegistry.Bound() {
return rejection([]string{"NPC registry reference is required"}), nil
}
issues := make([]string, 0)
for index, interaction := range req.Value.Interactions {
if _, ok := npcRegistry.Lookup(interaction.Name); !ok {
issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name)))
}
}
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return rejection(issues), nil
}
func rejection(issues []string) contracts.ValidationResult {
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC interaction registry", issues),
}
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options, request.References)
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,137 @@
package registry
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
)
func TestValidatorRecognizesRegistryNamesAndRejectsUnknownNames(t *testing.T) {
references := registryReferences(t, "Mira Thorn")
validator := newValidator(t, references)
value := validList(" mira thorn ")
result, err := validator.Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
t.Fatalf("recognized result = %#v, %v", result, err)
}
value.Interactions[0].Name = "Unknown NPC"
result, err = validator.Validate(context.Background(), request(references, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].name") {
t.Fatalf("unknown result = %#v, %v", result, err)
}
}
func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) {
validator := newValidator(t)
value := validList("Mira Thorn")
result, err := validator.Validate(context.Background(), request(contracts.ReferenceSet{}, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "required") {
t.Fatalf("unbound result = %#v, %v", result, err)
}
references := registryReferences(t, "Mira Thorn")
result, err = validator.Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
t.Fatalf("generated result = %#v, %v", result, err)
}
empty := registryReferences(t)
result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}))
if err != nil || !result.Approved {
t.Fatalf("empty registry result = %#v, %v", result, err)
}
}
func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t *testing.T) {
malformed := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
npcregistry.ReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: "application/json", Content: []byte(`{"secret":"private source"}`)}},
},
}}
if _, err := New(Options{}, malformed); err == nil || strings.Contains(err.Error(), "private source") {
t.Fatalf("New() error = %v", err)
}
references := registryReferences(t, "Mira Thorn")
validator := newValidator(t, references)
metadata, err := json.Marshal(validator.ManifestMetadata())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(metadata), "Mira Thorn") || strings.Contains(string(metadata), "other-session") {
t.Fatalf("metadata leaked registry content: %s", metadata)
}
if got := validator.CheckpointFingerprints(); len(got) != 2 || got[0].Value != policy || !strings.HasPrefix(got[1].Value, "sha256:") {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
}
func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) {
references := registryReferences(t, "Mira Thorn")
validator := newValidator(t, references)
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}
result, err := validator.Validate(context.Background(), request(references, malformed))
if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err)
}
value := validList("Mira Thorn")
before := value
_, err = validator.Validate(context.Background(), request(references, value))
if err != nil || !reflect.DeepEqual(value, before) {
t.Fatalf("Validate() mutated value: %#v", value)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
}
func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator {
t.Helper()
validator, err := New(Options{}, references...)
if err != nil {
t.Fatal(err)
}
return validator
}
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value}
}
func validList(name string) dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: name, Kind: dnd.NPCInteractionKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}
func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet {
t.Helper()
npcs := make([]dnd.NPC, len(names))
for index, name := range names {
npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}}}
}
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
npcregistry.ReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}},
},
}}
}

Some files were not shown because too many files have changed in this diff Show More