Compare commits
26 Commits
250c5c22b8
...
6dc9d522b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dc9d522b1 | |||
| 8adcf6840d | |||
| f5ed30e455 | |||
| cacf3f24e7 | |||
| f08ca4ddfa | |||
| e1c2f3c202 | |||
| 9614eb540d | |||
| 1b46596a39 | |||
| e043d61a99 | |||
| ad89782c9b | |||
| cd29265d5d | |||
| 2f36b7c3b6 | |||
| b8b3f3abfa | |||
| b490297cde | |||
| 06148074a2 | |||
| 16a998055c | |||
| 97c9a8e5ce | |||
| 66415fd1fa | |||
| bfe25609a7 | |||
| 36e0512454 | |||
| b02f667107 | |||
| ed2b6f4580 | |||
| cb7f145c76 | |||
| 2b9d2eaeaa | |||
| 61016671ab | |||
| b2c076946b |
@@ -31,4 +31,5 @@ Useful references:
|
|||||||
- [Internal implementation docs](docs/internal/overview.md)
|
- [Internal implementation docs](docs/internal/overview.md)
|
||||||
- [Maintained example config](examples/dnd-spells.config.yml)
|
- [Maintained example config](examples/dnd-spells.config.yml)
|
||||||
- [NPC-grounded example config](examples/dnd-npc-grounded.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)
|
- [Maintained example input](examples/seriatim-minimal-transcript.json)
|
||||||
|
|||||||
@@ -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 NPC configuration](../examples/dnd-npcs.config.yml)
|
||||||
- [D&D combat-turn configuration](../examples/dnd-combat-turns.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-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
|
All are complete version 3 files. The fragments below illustrate individual
|
||||||
fields and are not alternate complete configurations.
|
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
|
Production input, chunk, and output bindings reject unknown or invalid options
|
||||||
with the affected binding context.
|
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
|
Validator bindings use the same shorthand or object module-binding form, but
|
||||||
only these fields are supported:
|
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/spells` | Extracts typed D&D spell-list artifacts. |
|
||||||
| extract | `dnd/npcs` | Extracts typed D&D NPC-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/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. |
|
| merge | `appendorder` | Combines typed artifacts in chunk order. |
|
||||||
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
|
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
|
||||||
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
|
| 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/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. |
|
| 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
|
## 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_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. |
|
| `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. |
|
| `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
|
The production default chain for `dnd/spells` is used for both its extract and
|
||||||
normalize stages:
|
normalize stages:
|
||||||
@@ -436,6 +470,48 @@ normalize:
|
|||||||
- extract/dnd/combat-turns/source_relatedness
|
- 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.
|
Empty chains approve output by default.
|
||||||
|
|
||||||
The `generic` chunker accepts:
|
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
|
accepted normalized output to the later spell and combat targets through an
|
||||||
explicit ordered step.
|
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
|
The `dnd/combat-turns` extractor declares the optional campaign slots and the
|
||||||
structured `npcs` slot. Campaign references guide only the LLM extraction
|
structured `npcs` slot. Campaign references guide only the LLM extraction
|
||||||
stage. The deterministic normalizer declares only `npcs`, whose operation-time
|
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
|
absent, the combat prompt receives the exact empty registry value
|
||||||
`{"npcs":[]}` with its projection digest and no registry provenance.
|
`{"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
|
## State Surfaces
|
||||||
|
|
||||||
The `output`, `cache`, and `debug` top-level fields select independent physical
|
The `output`, `cache`, and `debug` top-level fields select independent physical
|
||||||
|
|||||||
83
docs/integrations/chunk-map.md
Normal file
83
docs/integrations/chunk-map.md
Normal 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).
|
||||||
@@ -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
|
passed explicitly to the spell extractor as an optional caster-name registry
|
||||||
or to the combat extractor and normalizer as an actor registry. It
|
or to the combat extractor and normalizer as an actor registry. It
|
||||||
remains a reference, not spell or combat evidence.
|
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
|
## Identity
|
||||||
|
|
||||||
|
|||||||
148
docs/integrations/dnd-npc-interaction-artifacts.md
Normal file
148
docs/integrations/dnd-npc-interaction-artifacts.md
Normal 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).
|
||||||
120
docs/integrations/dnd-scene-description-artifacts.md
Normal file
120
docs/integrations/dnd-scene-description-artifacts.md
Normal 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).
|
||||||
@@ -18,6 +18,8 @@ The encoder writes:
|
|||||||
- `lanes/<lane-id>.json`, one file per normalized serialized artifact
|
- `lanes/<lane-id>.json`, one file per normalized serialized artifact
|
||||||
- `rejected.json`
|
- `rejected.json`
|
||||||
- `warnings.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.
|
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 `..`.
|
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
|
response `schema_id`, `schema_name`, and `schema_version` when those values are
|
||||||
available.
|
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`
|
||||||
|
|
||||||
`manifest.json` contains a run manifest. This abridged example shows its core
|
`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
|
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),
|
current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md),
|
||||||
[D&D NPC Artifact](dnd-npc-artifacts.md), and
|
[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`
|
## `rejected.json`
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ without exposing Scriptorium types through stage contracts.
|
|||||||
7. injecting that one shared client into complete pipeline preparation before
|
7. injecting that one shared client into complete pipeline preparation before
|
||||||
the source file is read or the runner is invoked.
|
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
|
injected client and use it for every structured completion. Operation requests
|
||||||
do not carry an LLM client.
|
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
|
filesystem composition helper used to combine module-owned files with shared
|
||||||
domain prompt fragments.
|
domain prompt fragments.
|
||||||
|
|
||||||
The D&D scene chunker and spell, NPC, and combat-turn extractors each declare an
|
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
|
ordered prompt asset manifest. The manifest lists the package-owned YAML and
|
||||||
Markdown files, then the exact shared fragments rendered by that prompt; the
|
Markdown files, then the exact shared fragments rendered by that prompt; the
|
||||||
same ordered list drives both filesystem mounting and the prompt fingerprint.
|
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
|
sequence and content to be exactly identical; semantic similarity is not
|
||||||
sufficient. Cache boundaries belong at the ends of reusable stable tiers,
|
sufficient. Cache boundaries belong at the ends of reusable stable tiers,
|
||||||
subject to the provider's cache-boundary limit. The shared identity and
|
subject to the provider's cache-boundary limit. The shared identity and
|
||||||
campaign-reference messages form the first two extraction boundaries. Spell
|
campaign-reference messages form the first two extraction boundaries. Spell,
|
||||||
and combat prompts add a boundary at the shared NPC registry. Each extraction
|
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
|
prompt places its final boundary on its lane-specific instructions, immediately
|
||||||
before the transcript. The transcript does not carry cache control because no
|
before the transcript. The transcript does not carry cache control because no
|
||||||
reusable content follows it.
|
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
|
extraction evidence, identity, and campaign references. The NPC prompt then
|
||||||
renders task, instructions, and transcript. Spell renders the NPC registry,
|
renders task, instructions, and transcript. Spell renders the NPC registry,
|
||||||
catalog, task, instructions, and transcript. Combat 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,
|
scene chunker is not an extraction lane: it retains its separate system,
|
||||||
transcript, campaign-reference, task, and instruction order and marks its
|
transcript, campaign-reference, task, and instruction order and marks its
|
||||||
transcript and campaign-reference messages ephemeral.
|
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
|
Shared wording belongs in the canonical assets under
|
||||||
`internal/modules/dnd/shared`; extraction packages reference those assets in
|
`internal/modules/dnd/shared`; extraction packages reference those assets in
|
||||||
their manifests instead of copying similar text into package-local files.
|
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
|
The small framework registry contains only generic test schemas; production
|
||||||
schemas remain package-owned.
|
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
|
structured JSON inputs and private response schemas. Each private response
|
||||||
schema remains separate from its durable artifact codec schema; this work does
|
schema remains separate from its durable artifact codec schema; this work does
|
||||||
not use shared schema fragments or schema generation. Those private schemas own
|
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
|
the catalog input from its prepared
|
||||||
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
||||||
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
||||||
after the campaign reference message for 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
|
registry is bound, the
|
||||||
domain registry boundary strictly decodes and identity-validates one durable
|
domain registry boundary strictly decodes and identity-validates one durable
|
||||||
artifact, re-encodes canonical JSON for provenance, and separately generates a
|
artifact, re-encodes canonical JSON for provenance, and separately generates a
|
||||||
names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
|
names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
|
||||||
Prompt input and component-local checkpoint digests cover the projected bytes;
|
Prompt input and component-local checkpoint digests cover the projected bytes;
|
||||||
manifests retain the optional full registry digest/count rather than names,
|
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
|
response-schema, mapping, normalization, identity, and registry-projection
|
||||||
fingerprints remain separate semantic inputs to checkpoint identity.
|
fingerprints remain separate semantic inputs to checkpoint identity.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
into the production catalog and own family-level policy such as default
|
||||||
validator chains and prompt asset collection.
|
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
|
register strict option decoders and run-local builders. Preparation decodes their options into
|
||||||
implementation-owned values and injects dependencies plus the materialized
|
implementation-owned values and injects dependencies plus the materialized
|
||||||
reference set for the selected target. Each builder receives an isolated clone
|
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
|
of that set; input and output builders receive no references. The spell, NPC,
|
||||||
combat extractors are typed over the canonical D&D model. D&D validators, merge,
|
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
|
and normalize use typed variants; JSON representation validators use serialized
|
||||||
requests; and unconditional validators expose separate chunk and typed
|
requests; and unconditional validators expose separate chunk and typed
|
||||||
variants. The D&D production registrar registers the canonical typed spell,
|
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.
|
normalize behavior.
|
||||||
|
|
||||||
For D&D artifact defaults, generic JSON syntax validation runs first. Rejecting
|
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
|
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
||||||
declarations, prompt-input assembly, and source-unit/citation helpers belong in
|
declarations, prompt-input assembly, and source-unit/citation helpers belong in
|
||||||
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
|
`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
|
package-local prompt manifests for both rendering and prompt fingerprinting, so
|
||||||
only the shared fragments each prompt actually renders participate in either
|
only the shared fragments each prompt actually renders participate in either
|
||||||
operation. Extraction prompts place stable shared and lane-specific context
|
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.
|
public types.
|
||||||
|
|
||||||
The shared `ChunkPromptMaterial` helper owns common transcript material
|
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,
|
source metadata, falls back to the materialized chunk when content is absent,
|
||||||
checks that content remains chunk-identical, and fills only the common default
|
checks that content remains chunk-identical, and fills only the common default
|
||||||
fields. Extractors retain their request validation and wrap helper errors with
|
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
|
is registered by the production D&D family registrar for the selectable combat
|
||||||
lane.
|
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
|
## Input Adapter
|
||||||
|
|
||||||
### `internal/modules/seriatim/input/transcript`
|
### `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
|
The scene chunker prepares a structured Scriptorium request from the full
|
||||||
transcript, session, and optional D&D reference inputs. It validates the model's
|
transcript, session, and optional D&D reference inputs. It validates the model's
|
||||||
scene boundaries against source-unit IDs and converts them into deterministic
|
inclusive source-unit endpoints against document position and converts them
|
||||||
plan ranges with optional scene annotations. Preparation injects the shared
|
into deterministic plan ranges. Preparation injects the shared structured LLM
|
||||||
structured LLM client into the chunker; `Plan`
|
client into the chunker; `Plan` supplies only the run-specific profile, session,
|
||||||
supplies only the run-specific profile, session, source, references, and
|
source, references, and metadata.
|
||||||
metadata.
|
|
||||||
|
|
||||||
Scene validation requires sequential, contiguous, non-overlapping coverage from
|
Scene validation requires sequential, contiguous, non-overlapping coverage from
|
||||||
the first source unit through the last. Scene descriptions, boundaries,
|
the first source unit through the last. Its private response contains only the
|
||||||
confidence, and participants are module-owned annotations. Boundary caveats
|
boundary endpoints; the accepted plan has no D&D-specific annotations and
|
||||||
become warnings. Malformed
|
produces no boundary warnings. Malformed structured output is returned as an
|
||||||
structured output is returned as an error; there is no fallback chunker.
|
error; there is no fallback chunker.
|
||||||
|
|
||||||
The package embeds its prompt and response schema and reports their non-secret
|
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
|
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
|
evidence, and valid source ranges are rejected by the deterministic shape and
|
||||||
source-reference validators.
|
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`
|
### `internal/modules/dnd/extract/combatturns`
|
||||||
|
|
||||||
The combat extractor prepares one structured request per supplied chunk using
|
The combat extractor prepares one structured request per supplied chunk using
|
||||||
@@ -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
|
Campaign references remain extractor-only LLM context and are not materialized
|
||||||
for deterministic normalization.
|
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`
|
### `internal/modules/dnd/normalize/npcs`
|
||||||
|
|
||||||
The NPC normalizer performs deterministic identity-aware consolidation in
|
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
|
and dependency fingerprints. The normalizer is included in the production D&D
|
||||||
registrar with the default combat normalization chain.
|
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
|
## Output Encoder
|
||||||
|
|
||||||
### `internal/modules/generic/output/json`
|
### `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
|
manifest, rejected-result, warning, and lane files. Invalid JSON, unsupported
|
||||||
media types, unsafe names, and sanitized-name collisions are errors.
|
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
|
The encoder returns logical files only. The CLI places them on disk, and the
|
||||||
[JSON output contract](../integrations/json-output.md) defines their external
|
[JSON output contract](../integrations/json-output.md) defines their external
|
||||||
paths and schemas.
|
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
|
first; combat shape, normalized invariants when applicable, and source-reference
|
||||||
validation precede JSON Schema validation; warning-only relatedness runs last.
|
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 Registration
|
||||||
|
|
||||||
Production composition occurs through family registrars. The CLI allocates one
|
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
|
`internal/modules/seriatim/register`, and `internal/modules/dnd/register` in
|
||||||
that order, then exposes the matching catalog for resolution. The generic and
|
that order, then exposes the matching catalog for resolution. The generic and
|
||||||
Seriatim registrars own their production leaf registrations. The D&D registrar
|
Seriatim registrars own their production leaf registrations. The D&D registrar
|
||||||
owns D&D leaf registrations, typed spell, NPC, and combat default-validator
|
owns D&D leaf registrations, typed spell, NPC, combat, interaction, and scene-description default-validator
|
||||||
chains, typed append-order specializations, and D&D prompt/schema asset
|
chains, typed append-order specializations, and D&D prompt/schema asset
|
||||||
collection. Its registration helpers group module, validator, prompt-asset, and
|
collection. Its registration helpers group module, validator, prompt-asset, and
|
||||||
chain composition while retaining artifact-specific merge and clone behavior in
|
chain composition while retaining artifact-specific merge and clone behavior in
|
||||||
|
|||||||
@@ -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/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/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/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. |
|
| `internal/framework/debug` | Root-based framework and LLM debug recording. |
|
||||||
|
|
||||||
Framework contracts provide typed artifact, provenance-wrapper, chunk-validator,
|
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
|
constructs input, chunk, validators, ordered lanes, and output before source
|
||||||
parsing. Production modules use strict construction-time option decoding, and
|
parsing. Production modules use strict construction-time option decoding, and
|
||||||
LLM-backed modules retain the injected shared client. The D&D family registers
|
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`
|
the canonical `dnd/spell-list`, `dnd/npc-list`, `dnd/combat-turn-list`,
|
||||||
codecs, typed spell, NPC, and combat extractors and normalizers, validators,
|
`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
|
plus kind-specific generic merge strategies; generic JSON validators use the
|
||||||
serialized-validation contract. The runner executes lanes through
|
serialized-validation contract. The runner executes lanes through
|
||||||
private exact-type-checked closures, coordinates extract results independently
|
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/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/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/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/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/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/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/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/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/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/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/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/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/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. |
|
||||||
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
|
| `internal/modules/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/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/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/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,
|
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
|
||||||
reference declarations, prompt input assembly, source-unit reference helpers,
|
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
|
External references are strictly decoded and identity-validated during
|
||||||
preparation; generated references are decoded and identity-validated at the
|
preparation; generated references are decoded and identity-validated at the
|
||||||
ordered step handoff. Both paths retain canonical registry JSON for provenance
|
ordered step handoff. Both paths retain canonical registry JSON for provenance
|
||||||
and emit a names-only projection to operation-time spell and combat prompts.
|
and emit a names-only projection to operation-time spell, combat, and
|
||||||
Combat normalization uses the canonical registry for exact actor lookup. The
|
interaction prompts. Combat and interaction normalization use the canonical
|
||||||
|
registry for exact name lookup. The
|
||||||
framework records generated identity and bounded producer provenance, while
|
framework records generated identity and bounded producer provenance, while
|
||||||
the raw external reference remains independently tracked by pipeline
|
the raw external reference remains independently tracked by pipeline
|
||||||
provenance. An absent registry is represented only by the empty prompt value
|
provenance. An absent registry is represented only by the empty prompt value
|
||||||
`{"npcs":[]}`. Spell
|
`{"npcs":[]}`. Spell
|
||||||
and combat consumers use this shared boundary without changing their public
|
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
|
Generic validators under `internal/modules/generic/validate` provide
|
||||||
unconditional test decisions, JSON syntax validation, and JSON Schema
|
unconditional test decisions, JSON syntax validation, and JSON Schema
|
||||||
|
|||||||
@@ -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
|
different capabilities and reference slots. Kind-neutral registry inspection
|
||||||
selects the first registered artifact kind in sorted order.
|
selects the first registered artifact kind in sorted order.
|
||||||
|
|
||||||
Production composition registers the D&D spell-list codec and typed extractor,
|
Production composition registers the D&D spell-list, NPC-list, combat-turn-list,
|
||||||
matching typed merge, normalize, and semantic-validator variants, and
|
NPC-interaction-list, and scene-description-list codecs and typed lane
|
||||||
serialized JSON validators. Every artifact lane resolves through the typed
|
variants, plus serialized JSON validators. Every artifact lane resolves through
|
||||||
registries and a matching codec.
|
the typed registries and a matching codec.
|
||||||
|
|
||||||
A `ModuleSpec` declares its stage plus required and provided capabilities.
|
A `ModuleSpec` declares its stage plus required and provided capabilities.
|
||||||
Chunk, extract, merge, and normalize specs may also declare reference slots.
|
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;
|
3. selects a stored plan or executes the configured chunker's `Plan` operation;
|
||||||
4. canonicalizes and materializes the plan, then validates the resulting
|
4. canonicalizes and materializes the plan, then validates the resulting
|
||||||
chunks;
|
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
|
dispatches extract jobs in source-chunk then resolved-lane order, starts a
|
||||||
bounded lane continuation when all extracts for that lane are terminal, and
|
bounded lane continuation when all extracts for that lane are terminal, and
|
||||||
waits for every lane to become terminal;
|
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;
|
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;
|
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,
|
Within each artifact lane, it reuses the prepared extractor, merger, normalizer,
|
||||||
and validators while performing these transitions:
|
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
|
paths are configured and operated outside the runner; see
|
||||||
[Configuration](../config.md#state-surfaces) and [Operations](../operations.md).
|
[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
|
The extract job channel has the same capacity as the effective extract worker
|
||||||
count, so dispatch applies backpressure. A fixed continuation executor prevents
|
count, so dispatch applies backpressure. A fixed continuation executor prevents
|
||||||
ready or checkpoint-reused lanes from creating one goroutine each. Workers and
|
ready or checkpoint-reused lanes from creating one goroutine each. Workers and
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ debug roots.
|
|||||||
The pipeline runner returns logical output files. After validating every
|
The pipeline runner returns logical output files. After validating every
|
||||||
logical name, the CLI exclusively creates the run directory beneath the
|
logical name, the CLI exclusively creates the run directory beneath the
|
||||||
selected output root and performs confined, atomic file writes within it.
|
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,
|
`internal/framework/chunkplan` owns source-addressed plan storage, validation,
|
||||||
and atomic publication. Its store is constructed only when the selected mode is
|
and atomic publication. Its store is constructed only when the selected mode is
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ automatically removes output. The
|
|||||||
[JSON output contract](integrations/json-output.md) owns the logical file
|
[JSON output contract](integrations/json-output.md) owns the logical file
|
||||||
names, schemas, and media types inside a run directory.
|
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
|
Remove an output run directory only after its consumer data is no longer
|
||||||
needed. This is data deletion, not cache cleanup.
|
needed. This is data deletion, not cache cleanup.
|
||||||
|
|
||||||
|
|||||||
766
docs/roadmap/audit.md
Normal file
766
docs/roadmap/audit.md
Normal 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.
|
||||||
200
docs/roadmap/dnd-npc-interactions.md
Normal file
200
docs/roadmap/dnd-npc-interactions.md
Normal 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.
|
||||||
@@ -16,28 +16,18 @@ not as committed release dates.
|
|||||||
validator, and normalizer development. Treat model-quality review as an
|
validator, and normalizer development. Treat model-quality review as an
|
||||||
iterative human evaluation aid, not a deterministic correctness gate.
|
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
|
- Allow the combat extractor to no-op for chunks classified as non-combat only
|
||||||
encountered when that output proves useful beyond the dedicated NPC artifact.
|
after the scene-description artifact can be supplied through an explicit
|
||||||
- Use ordered pipeline steps when a later artifact needs an accepted earlier
|
ordered dependency. Do not make generic chunk materialization depend on a D&D
|
||||||
artifact as context. Keep independent lanes in the same step and do not
|
classification.
|
||||||
introduce a general DAG or concurrent cross-lane reconciliation model.
|
- Use ordered pipeline steps whenever a later artifact needs an accepted
|
||||||
|
earlier artifact as context. Keep independent lanes in the same step and do
|
||||||
### Improve D&D Scene Classification
|
not introduce a general DAG or concurrent cross-lane reconciliation model.
|
||||||
|
|
||||||
- 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.
|
|
||||||
- Reassess whether one shared scene plan provides enough context for NPC,
|
- Reassess whether one shared scene plan provides enough context for NPC,
|
||||||
spell, combat, and narrative pipelines after these extractors have real-world
|
spell, combat, interaction, and scene-description lanes after real-world use.
|
||||||
usage. Add more complex chunking only in response to demonstrated failures.
|
Add more complex chunking only in response to demonstrated failures.
|
||||||
|
|
||||||
## Shared Normalization And Quality Work
|
## Shared Normalization And Quality Work
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
The word "implementation" in this filename refers to implementing the audit
|
||||||
[Minimal D&D Extraction Contracts](minimal-dnd-extraction-contracts.md) and
|
strategy, not implementing refactors.
|
||||||
[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.
|
|
||||||
|
|
||||||
This plan deliberately starts with NPCs because the NPC registry is a generated
|
## Global Instructions
|
||||||
reference consumed by spell and combat extraction. Spell and combat then change
|
|
||||||
independently, followed by one repository-wide contract and documentation
|
|
||||||
pass.
|
|
||||||
|
|
||||||
## 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
|
## Working Results Structure
|
||||||
`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.
|
|
||||||
|
|
||||||
## 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
|
```markdown
|
||||||
depend.
|
## 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`,
|
### Baseline And Module Matrix
|
||||||
and `SourceRefs`. Delete `NPCRelationship` and every alias, description, and
|
### Divergence Register
|
||||||
relationship field or helper that becomes unused.
|
### Prompt, Schema, And LLM Review
|
||||||
- Rewrite the existing strict durable schema `dnd_npcs.v1.json` with required
|
### Extraction And Canonicalization Review
|
||||||
top-level `npcs`, and records containing only required `id`, `name`, and
|
### Duplication Register
|
||||||
`source_refs`. Keep the NPC codec at schema version `v1`, name
|
### Contextual Architecture And Ownership Review
|
||||||
`notarius_dnd_npcs_v1`, and its existing schema ID and media type.
|
### Candidate Decisions
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 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
|
Each divergence-register entry must have:
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
Each duplication-register entry must have:
|
||||||
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.
|
|
||||||
|
|
||||||
### 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,
|
Do not assign final severity until Stage 5 has traced ownership and evaluated
|
||||||
registration, pipeline-integration, and CLI fixtures to the new shape. Adapt
|
impact.
|
||||||
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.
|
|
||||||
|
|
||||||
### Stage 1 completion criteria
|
## Stage 1: Inventory And Convention Matrix
|
||||||
|
|
||||||
- NPC durable and private schemas expose no removed enrichment fields.
|
### Objective
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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`.
|
For every extractor, inspect:
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 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
|
Trace registration into production composition far enough to confirm that the
|
||||||
presence, nonblank caster/spell, and nonempty source references.
|
declared contract is the one actually selected. Inspect neighboring packages
|
||||||
- Keep catalog validation, source-reference validation, and source-relatedness
|
only as required to verify an identity or ownership fact.
|
||||||
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.
|
|
||||||
|
|
||||||
### 2.3 Update owners and tests
|
### Write to `audit.md`
|
||||||
|
|
||||||
- Update spell codec, private schema, extractor, validators, normalizer, merge
|
Create `## Audit Results`, mark it `Status: In progress`, and add:
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
Also add a short baseline-validation note recording:
|
||||||
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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gofmt -w <changed Go files>
|
go test -count=1 ./...
|
||||||
go test ./...
|
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build ./cmd/notarius
|
go build ./cmd/notarius
|
||||||
go test -race ./internal/modules/dnd/...
|
gofmt -l .
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
If the repository-wide race command exposes an unrelated, pre-existing failure,
|
If a command fails, record the exact command, affected package, and concise
|
||||||
document it with the narrower affected package result; do not weaken or skip
|
failure classification. Do not fix it during the audit.
|
||||||
ordinary tests for the changed D&D packages.
|
|
||||||
|
|
||||||
### Stage 4 completion criteria
|
### Completion gate
|
||||||
|
|
||||||
- All three lanes use only their minimal v1 contracts from model response
|
Stage 1 is complete when every matrix cell is populated or marked not
|
||||||
through durable output.
|
applicable, every observed baseline divergence has a stable register entry, and
|
||||||
- No compatibility shim, removed-field policy, unintended v2 asset, or stale
|
the repository's starting validation state is recorded.
|
||||||
current-behavior documentation remains.
|
|
||||||
- Generated NPC references are name-only for LLM input and remain full-fidelity
|
## Stage 2: Prompts, Schemas, And LLM Boundaries
|
||||||
for durable provenance.
|
|
||||||
- Repository tests, vet, build, race checks for the changed domain, and diff
|
### Objective
|
||||||
hygiene checks pass.
|
|
||||||
|
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
|
## Open Questions
|
||||||
|
|
||||||
None. The feature roadmap, ADR, and fixed decisions above define the cutover,
|
None. The audit stages, working-document structure, decision rules, and final
|
||||||
pre-release schema, evidence, projection, validation, testing, and documentation
|
deliverable are fully specified.
|
||||||
policies needed to implement each stage without further product decisions.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
34
examples/dnd-npc-interactions.config.yml
Normal file
34
examples/dnd-npc-interactions.config.yml
Normal 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
|
||||||
23
examples/dnd-scene-chunk-map.config.yml
Normal file
23
examples/dnd-scene-chunk-map.config.yml
Normal 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
|
||||||
20
examples/dnd-scene-descriptions.config.yml
Normal file
20
examples/dnd-scene-descriptions.config.yml
Normal 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
|
||||||
135
internal/cli/dnd_interactions_contract_test.go
Normal file
135
internal/cli/dnd_interactions_contract_test.go
Normal 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
|
||||||
|
}
|
||||||
111
internal/cli/dnd_scene_descriptions_contract_test.go
Normal file
111
internal/cli/dnd_scene_descriptions_contract_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
"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/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
@@ -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)
|
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")
|
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||||
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
|
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
|
||||||
fake := &productionFakeLLMClient{}
|
fake := &productionFakeLLMClient{}
|
||||||
@@ -481,11 +482,40 @@ func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
|
|||||||
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
|
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
|
||||||
t.Fatalf("chunk producer schema metadata = %#v, want %q", 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 := readProductionJSON[struct {
|
||||||
Warnings []contracts.Warning `json:"warnings"`
|
Warnings []contracts.Warning `json:"warnings"`
|
||||||
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
|
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
|
||||||
if len(warnings.Warnings) != 1 || warnings.Warnings[0].ReasonCode != "scene_boundary_caveat" {
|
if len(warnings.Warnings) != 0 {
|
||||||
t.Fatalf("warnings = %#v, want one scene boundary warning", warnings.Warnings)
|
t.Fatalf("warnings = %#v, want none", warnings.Warnings)
|
||||||
}
|
}
|
||||||
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 {
|
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())
|
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: "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: "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-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:
|
dnd-session:
|
||||||
input: seriatim
|
input: seriatim
|
||||||
chunk: %s
|
chunk: %s
|
||||||
|
output:
|
||||||
|
module: json
|
||||||
|
options:
|
||||||
|
include_chunk_map: true
|
||||||
artifacts:
|
artifacts:
|
||||||
spells:
|
spells:
|
||||||
extract: dnd/spells
|
extract: dnd/spells
|
||||||
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
|
`, 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 {
|
func writeProductionContractConfig(t *testing.T, content string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "config.yml")
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
@@ -667,7 +718,7 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
|
|||||||
var content []byte
|
var content []byte
|
||||||
switch req.PromptID {
|
switch req.PromptID {
|
||||||
case scenes.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:
|
case spells.PromptID:
|
||||||
if client.spellResponse != "" {
|
if client.spellResponse != "" {
|
||||||
content = []byte(client.spellResponse)
|
content = []byte(client.spellResponse)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
)
|
)
|
||||||
|
|
||||||
const stateTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
const stateTestDigest = "sha256:e511d8906649b78eb639b11215fa57a9652a1a64f4aefa3ed68320dbda46f439"
|
||||||
|
|
||||||
func TestRunStateSurfaceMatrix(t *testing.T) {
|
func TestRunStateSurfaceMatrix(t *testing.T) {
|
||||||
for _, debug := range []bool{false, true} {
|
for _, debug := range []bool{false, true} {
|
||||||
@@ -857,7 +857,13 @@ type stateTestInput struct{}
|
|||||||
|
|
||||||
func (stateTestInput) Key() string { return "test/input" }
|
func (stateTestInput) Key() string { return "test/input" }
|
||||||
func (stateTestInput) Parse(_ context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
|
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 }
|
type stateTestChunker struct{ harness *stateTestHarness }
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
|
|||||||
func TestRunManifestChunkPlanIsAdditiveAndOmitsPlanContent(t *testing.T) {
|
func TestRunManifestChunkPlanIsAdditiveAndOmitsPlanContent(t *testing.T) {
|
||||||
manifest := RunManifest{ChunkPlan: &ChunkPlanManifest{
|
manifest := RunManifest{ChunkPlan: &ChunkPlanManifest{
|
||||||
Mode: "auto", Action: "reused", SourceDigest: "sha256:source", PlanDigest: "sha256:plan",
|
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",
|
ProducerInputModule: "input/original", ProducerModule: "chunk/original",
|
||||||
}}
|
}}
|
||||||
encoded, err := json.Marshal(manifest)
|
encoded, err := json.Marshal(manifest)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
400
internal/framework/chunkmap/codec.go
Normal file
400
internal/framework/chunkmap/codec.go
Normal 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
|
||||||
|
}
|
||||||
263
internal/framework/chunkmap/codec_test.go
Normal file
263
internal/framework/chunkmap/codec_test.go
Normal 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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
51
internal/framework/chunkmap/model.go
Normal file
51
internal/framework/chunkmap/model.go
Normal 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
|
||||||
|
}
|
||||||
1
internal/framework/chunkmap/testdata/source_chunk_map.v1.json
vendored
Normal file
1
internal/framework/chunkmap/testdata/source_chunk_map.v1.json
vendored
Normal 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":{}}]}
|
||||||
@@ -281,7 +281,7 @@ func TestFilesystemStoreReportsInvalidRecordsAsRecoverable(t *testing.T) {
|
|||||||
return bytes.Replace(data, []byte(`{"schema_version"`), []byte(`{"SENTINEL_UNKNOWN_FIELD":true,"schema_version"`), 1)
|
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: "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: "source mismatch", mutate: replaceJSON(testSourceDigest, "sha256:"+strings.Repeat("b", 64))},
|
||||||
{name: "plan digest mismatch", mutate: func(data []byte) []byte {
|
{name: "plan digest mismatch", mutate: func(data []byte) []byte {
|
||||||
prefix := []byte(`"plan_digest":"sha256:`)
|
prefix := []byte(`"plan_digest":"sha256:`)
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ func CloneSerializedArtifact(artifact SerializedArtifact) SerializedArtifact {
|
|||||||
return artifact
|
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 {
|
func CloneSerializedOutput(output SerializedOutput) SerializedOutput {
|
||||||
output.Artifact = CloneSerializedArtifact(output.Artifact)
|
output.Artifact = CloneSerializedArtifact(output.Artifact)
|
||||||
return output
|
return output
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ type OutputRequest struct {
|
|||||||
Warnings []Warning `json:"warnings,omitempty"`
|
Warnings []Warning `json:"warnings,omitempty"`
|
||||||
LLMProfile string `json:"llm_profile,omitempty"`
|
LLMProfile string `json:"llm_profile,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
ChunkMap *SerializedArtifact `json:"chunk_map,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutputFile struct {
|
type OutputFile struct {
|
||||||
|
|||||||
@@ -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) {
|
func TestCloneReferenceSlotsPreservesFields(t *testing.T) {
|
||||||
slots := []ReferenceSlot{
|
slots := []ReferenceSlot{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ChunkPlanSchemaVersion = "notarius.chunk-plan.v1"
|
const ChunkPlanSchemaVersion = "notarius.chunk-plan.v2"
|
||||||
|
|
||||||
type ChunkPlanProducer struct {
|
type ChunkPlanProducer struct {
|
||||||
InputModule string `json:"input_module"`
|
InputModule string `json:"input_module"`
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"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)
|
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 chunkResult.accepted {
|
||||||
if err := r.runPreparedSteps(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, chunkResult.chunks, &output); err != nil {
|
if err := r.runPreparedSteps(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, chunkResult.chunks, &output); err != nil {
|
||||||
return failOutput(output), err
|
return failOutput(output), err
|
||||||
@@ -270,18 +293,22 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
|||||||
return failOutput(output), err
|
return failOutput(output), err
|
||||||
}
|
}
|
||||||
outputStarted := time.Now().UTC()
|
outputStarted := time.Now().UTC()
|
||||||
if err := writeDebugTimed(debugRecorder, "output/input.json", debugTimedEnvelope{
|
outputDebugPayload := map[string]any{
|
||||||
Stage: string(StageOutput),
|
|
||||||
ModuleKey: encoder.Key(),
|
|
||||||
StartedAt: outputStarted,
|
|
||||||
Payload: map[string]any{
|
|
||||||
"manifest": output.Manifest,
|
"manifest": output.Manifest,
|
||||||
"normalize_outputs": debugSerializedOutputEnvelopes(output.NormalizeOutputs),
|
"normalize_outputs": debugSerializedOutputEnvelopes(output.NormalizeOutputs),
|
||||||
"rejected": debugRejectedOutputEnvelopes(output.Rejected),
|
"rejected": debugRejectedOutputEnvelopes(output.Rejected),
|
||||||
"warnings": output.Warnings,
|
"warnings": output.Warnings,
|
||||||
"options": redactSensitiveMap(input.pipeline.Output.Options),
|
"options": redactSensitiveMap(input.pipeline.Output.Options),
|
||||||
"metadata": redactSensitiveMap(input.Metadata),
|
"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: outputDebugPayload,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
|
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,
|
Warnings: output.Warnings,
|
||||||
LLMProfile: input.pipeline.Output.LLMProfile,
|
LLMProfile: input.pipeline.Output.LLMProfile,
|
||||||
Metadata: outputMetadata,
|
Metadata: outputMetadata,
|
||||||
|
ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap),
|
||||||
})
|
})
|
||||||
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +41,17 @@ type countingChunkValidator struct {
|
|||||||
err error
|
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) Name() string { return "test/counting-chunks" }
|
||||||
func (*countingChunkValidator) ExecutionClass() contracts.ExecutionClass {
|
func (*countingChunkValidator) ExecutionClass() contracts.ExecutionClass {
|
||||||
return contracts.ExecutionClassDeterministic
|
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) {
|
func TestRunnerChunkPlanManifestRetainsCandidateOnRejection(t *testing.T) {
|
||||||
prepared, _ := preparedTerminalDebugPipeline(t)
|
prepared, _ := preparedTerminalDebugPipeline(t)
|
||||||
validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: false, ReasonCode: "policy", Message: "no"}}
|
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)
|
prepared, plan := preparedTerminalDebugPipeline(t)
|
||||||
plan.Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"boundary_caveats":["uncertain"]}`)}
|
plan.Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"label":"fixture"}`)}
|
||||||
plan.Ranges[0].Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Opening"}`)}
|
plan.Ranges[0].Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"category":"sample"}`)}
|
||||||
record := chunkPlanRecord(t, prepared, plan)
|
record := chunkPlanRecord(t, prepared, plan)
|
||||||
record.Producer.ChunkModule = "dnd/scenes"
|
record.Producer.ChunkModule = "chunk/producer"
|
||||||
store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
|
store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
|
||||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
|
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if output.Manifest.ChunkPlan.ProducerModule != "dnd/scenes" || output.Manifest.ChunkPlan.Action != "reused" {
|
if output.Manifest.ChunkPlan.ProducerModule != "chunk/producer" || output.Manifest.ChunkPlan.Action != "reused" {
|
||||||
t.Fatalf("cross-domain annotation plan was not reused: %#v", output.Manifest.ChunkPlan)
|
t.Fatalf("different-chunker annotation plan was not reused: %#v", output.Manifest.ChunkPlan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,8 @@
|
|||||||
Good reasons to start a new scene include:
|
Cover the complete provided transcript from its first source unit to its last
|
||||||
- the party moves to a new location;
|
source unit. Return scenes in source-unit order with no gaps or overlaps. Use
|
||||||
- a combat encounter begins or ends;
|
only positive integer source-unit IDs from the transcript, and give every scene
|
||||||
- combat changes into a substantially different phase;
|
one inclusive `start_unit_id` and one inclusive `end_unit_id`.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Do not start a new scene merely because:
|
Return exactly one JSON object and no explanatory text. The object must contain
|
||||||
- the speaker changes;
|
only a non-empty `scenes` array. Each scene object must contain only
|
||||||
- a new combat round begins;
|
`start_unit_id` and `end_unit_id`.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
Divide the provided transcript into coherent Dungeons & Dragons scenes for the
|
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
|
A scene is a coherent unit of play. Start a new scene when the transcript
|
||||||
change in location, objective, threat, activity, encounter, or mode of play.
|
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.
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$id": "notarius.dnd.scenes",
|
"$id": "notarius.dnd.scenes",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
"required": ["scenes"],
|
||||||
"scenes",
|
|
||||||
"boundary_caveats"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"scenes": {
|
"scenes": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -14,16 +11,7 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
"start_unit_id",
|
|
||||||
"end_unit_id",
|
|
||||||
"short_title",
|
|
||||||
"primary_mode",
|
|
||||||
"main_participants",
|
|
||||||
"summary",
|
|
||||||
"boundary_note",
|
|
||||||
"boundary_confidence"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"start_unit_id": {
|
"start_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -32,53 +20,9 @@
|
|||||||
"end_unit_id": {
|
"end_unit_id": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1
|
"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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package scenes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
@@ -22,8 +20,6 @@ var providedCapabilities = []string{
|
|||||||
"chunks",
|
"chunks",
|
||||||
}
|
}
|
||||||
|
|
||||||
const annotationNamespace = "dnd/scenes"
|
|
||||||
|
|
||||||
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
||||||
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
|
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
|
||||||
Party: "Optional party roster 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)
|
return contracts.ChunkPlanResult{}, chunkerErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
warnings, err := warningsFromCaveats(response.BoundaryCaveats)
|
plan, err := planFromResponse(req.Source, response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
|
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
|
||||||
}
|
}
|
||||||
plan, err := planFromResponse(req.Source, response, warnings)
|
return contracts.ChunkPlanResult{Plan: plan}, nil
|
||||||
if err != nil {
|
|
||||||
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
|
|
||||||
}
|
|
||||||
return contracts.ChunkPlanResult{
|
|
||||||
Plan: plan,
|
|
||||||
Warnings: warnings,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
@@ -159,7 +148,7 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
return Options{}, nil
|
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 {
|
if response.Scenes == nil {
|
||||||
return source.ChunkPlan{}, fmt.Errorf("scenes must be present")
|
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))
|
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
|
||||||
previousEnd := -1
|
previousEnd := -1
|
||||||
for i, scene := range response.Scenes {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
if i == 0 && startIndex != 0 {
|
||||||
@@ -205,142 +198,21 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin
|
|||||||
}
|
}
|
||||||
previousEnd = endIndex
|
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{
|
ranges = append(ranges, source.ChunkRange{
|
||||||
StartUnitID: normalized.StartUnitID,
|
StartUnitID: startUnitID,
|
||||||
EndUnitID: normalized.EndUnitID,
|
EndUnitID: endUnitID,
|
||||||
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if previousEnd != len(doc.Units)-1 {
|
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)
|
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{
|
return source.ChunkPlan{
|
||||||
SourceDigest: doc.Digest,
|
SourceDigest: doc.Digest,
|
||||||
Ranges: ranges,
|
Ranges: ranges,
|
||||||
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
|
|
||||||
}, nil
|
}, 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 {
|
func chunkerErrorf(format string, args ...any) error {
|
||||||
return fmt.Errorf("dnd scenes chunker: "+format, args...)
|
return fmt.Errorf("dnd scenes chunker: "+format, args...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,32 +110,13 @@ func wantReferenceSlots() []contracts.ReferenceSlot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput(t *testing.T) {
|
func TestPlanReturnsAnnotationFreeSceneRangesFromStructuredOutput(t *testing.T) {
|
||||||
client := &fakeScenesLLMClient{
|
client := &fakeScenesLLMClient{
|
||||||
response: chunkResponse{
|
response: chunkResponse{
|
||||||
Scenes: []sceneResponse{
|
Scenes: []sceneResponse{
|
||||||
{
|
scene(1, 2),
|
||||||
StartUnitID: shared.UnitRefFromInt(1),
|
scene(3, 4),
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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)
|
t.Fatalf("ranges = %#v, want two", result.Plan.Ranges)
|
||||||
}
|
}
|
||||||
for i, want := range wantRanges {
|
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)
|
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)
|
|
||||||
}
|
}
|
||||||
wantFirst := map[string]any{
|
if len(result.Plan.Annotations) != 0 {
|
||||||
"short_title": "Goblin parley", "primary_mode": "Discussion",
|
t.Fatalf("plan annotations = %#v, want absent", result.Plan.Annotations)
|
||||||
"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 !reflect.DeepEqual(firstAnnotation, wantFirst) {
|
if len(result.Warnings) != 0 {
|
||||||
t.Fatalf("first scene annotation = %#v, want %#v", firstAnnotation, wantFirst)
|
t.Fatalf("warnings = %#v, want absent", result.Warnings)
|
||||||
}
|
}
|
||||||
if len(firstAnnotation) != 6 {
|
}
|
||||||
t.Fatalf("first scene annotation keys = %#v, want exact six fields", firstAnnotation)
|
|
||||||
|
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}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
var planAnnotation map[string]any
|
req := chunkRequest()
|
||||||
if err := json.Unmarshal(result.Plan.Annotations[annotationNamespace], &planAnnotation); err != nil {
|
req.Source = doc
|
||||||
t.Fatalf("decode plan annotation: %v", err)
|
|
||||||
|
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 !reflect.DeepEqual(planAnnotation, map[string]any{"boundary_caveats": []any{"The transition into combat is gradual."}}) || len(planAnnotation) != 1 {
|
if got := result.Plan.Ranges; !reflect.DeepEqual(got, []source.ChunkRange{{StartUnitID: 10, EndUnitID: 3}, {StartUnitID: 20, EndUnitID: 20}}) {
|
||||||
t.Fatalf("plan annotation = %#v, want normalized caveats only", planAnnotation)
|
t.Fatalf("ranges = %#v, want source-document order", got)
|
||||||
}
|
}
|
||||||
if got := result.Warnings; len(got) != 1 ||
|
})
|
||||||
got[0].Scope != Key ||
|
|
||||||
got[0].ReasonCode != "scene_boundary_caveat" ||
|
t.Run("rejects reversed document positions", func(t *testing.T) {
|
||||||
got[0].Message != "The transition into combat is gradual." {
|
client := &fakeScenesLLMClient{response: chunkResponse{Scenes: []sceneResponse{
|
||||||
t.Fatalf("Warnings = %#v, want boundary caveat warning", got)
|
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) {
|
func TestPlanPassesReferencesAsPromptInputs(t *testing.T) {
|
||||||
client := &fakeScenesLLMClient{response: chunkResponse{
|
client := &fakeScenesLLMClient{response: chunkResponse{
|
||||||
Scenes: []sceneResponse{
|
Scenes: []sceneResponse{
|
||||||
{
|
scene(1, 4),
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
req := chunkRequest()
|
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) {
|
func TestChunkerManifestMetadataIncludesPromptAndSchemaProvenance(t *testing.T) {
|
||||||
metadata := newChunker(t, &fakeScenesLLMClient{}).ManifestMetadata()
|
metadata := newChunker(t, &fakeScenesLLMClient{}).ManifestMetadata()
|
||||||
|
|
||||||
@@ -455,38 +403,6 @@ func TestPlanRejectsMalformedStructuredOutput(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
want: "final scene",
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -559,7 +475,6 @@ func validSceneResponse() chunkResponse {
|
|||||||
Scenes: []sceneResponse{
|
Scenes: []sceneResponse{
|
||||||
scene(1, 4),
|
scene(1, 4),
|
||||||
},
|
},
|
||||||
BoundaryCaveats: []string{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,12 +487,6 @@ func scene(startUnitID int, endUnitID int) sceneResponse {
|
|||||||
return sceneResponse{
|
return sceneResponse{
|
||||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,27 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|||||||
|
|
||||||
type chunkResponse struct {
|
type chunkResponse struct {
|
||||||
Scenes []sceneResponse `json:"scenes"`
|
Scenes []sceneResponse `json:"scenes"`
|
||||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type sceneResponse struct {
|
type sceneResponse struct {
|
||||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
||||||
EndUnitID shared.UnitRef `json:"end_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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestLoadResponseSchemaForScenes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
|
func TestResponseSchemaValidatesMinimalSceneResponses(t *testing.T) {
|
||||||
schema, err := loadResponseSchema()
|
schema, err := loadResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
||||||
@@ -55,11 +55,15 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
|
|||||||
mutate func(map[string]any)
|
mutate func(map[string]any)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "obsolete segment boundaries",
|
name: "missing scenes",
|
||||||
mutate: func(response map[string]any) {
|
mutate: func(response map[string]any) {
|
||||||
scene := response["scenes"].([]any)[0].(map[string]any)
|
delete(response, "scenes")
|
||||||
scene["start_segment_id"] = 1
|
},
|
||||||
scene["end_segment_id"] = 2
|
},
|
||||||
|
{
|
||||||
|
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) {
|
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) {
|
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) {
|
mutate: func(response map[string]any) {
|
||||||
response["boundary_caveats"] = []any{""}
|
response["scenes"].([]any)[0].(map[string]any)["short_title"] = "Old contract"
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown property",
|
|
||||||
mutate: func(response map[string]any) {
|
|
||||||
response["unexpected"] = true
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -116,21 +114,7 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) {
|
func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) {
|
||||||
raw := []byte(`{
|
raw := []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":3}]}`)
|
||||||
"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": []
|
|
||||||
}`)
|
|
||||||
|
|
||||||
var response chunkResponse
|
var response chunkResponse
|
||||||
if err := json.Unmarshal(raw, &response); err != nil {
|
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)
|
t.Fatalf("schema JSON was mutated: %s", second.JSONSchema)
|
||||||
}
|
}
|
||||||
if len(second.JSONSchema) > 0 && second.JSONSchema[0] == '[' {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,15 +153,8 @@ func validSceneSchemaResponse() map[string]any {
|
|||||||
map[string]any{
|
map[string]any{
|
||||||
"start_unit_id": 1,
|
"start_unit_id": 1,
|
||||||
"end_unit_id": 3,
|
"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",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"boundary_caveats": []any{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "notarius.dnd.npc_interactions",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["interactions"],
|
||||||
|
"properties": {
|
||||||
|
"interactions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "kind", "source_refs"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["mentioned", "noncombat_presence", "dialogue", "combat_ally", "combat_opponent", "other"]
|
||||||
|
},
|
||||||
|
"source_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["source_id", "start_unit_id", "end_unit_id"],
|
||||||
|
"properties": {
|
||||||
|
"source_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"start_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"end_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/modules/dnd/codec/npcinteractions/codec.go
Normal file
140
internal/modules/dnd/codec/npcinteractions/codec.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Package npcinteractions encodes durable D&D NPC interaction artifacts.
|
||||||
|
package npcinteractions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchemaID = "notarius.dnd.npc_interactions"
|
||||||
|
SchemaName = "notarius_dnd_npc_interactions_v1"
|
||||||
|
SchemaVersion = "v1"
|
||||||
|
MediaType = "application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/schemas/dnd_npc_interactions.v1.json
|
||||||
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
|
var _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil)
|
||||||
|
|
||||||
|
type Codec struct{}
|
||||||
|
|
||||||
|
func New() *Codec { return &Codec{} }
|
||||||
|
|
||||||
|
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCInteractionListKind }
|
||||||
|
|
||||||
|
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||||
|
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_interactions.v1.json")
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ArtifactSchema{}
|
||||||
|
}
|
||||||
|
return contracts.ArtifactSchema{
|
||||||
|
ID: SchemaID,
|
||||||
|
Name: SchemaName,
|
||||||
|
Version: SchemaVersion,
|
||||||
|
JSONSchema: append([]byte(nil), raw...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) MediaType() string { return MediaType }
|
||||||
|
|
||||||
|
func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any {
|
||||||
|
return map[string]any{"interaction_count": len(value.Interactions)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) {
|
||||||
|
if err := validate(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
return c.EncodeCandidate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeCandidate provides the durable representation before semantic
|
||||||
|
// validators have approved a value.
|
||||||
|
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
|
||||||
|
content, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
|
||||||
|
value, err := c.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
return dnd.NPCInteractionList{}, err
|
||||||
|
}
|
||||||
|
if err := validate(value); err != nil {
|
||||||
|
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||||
|
// validators have approved it.
|
||||||
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var value dnd.NPCInteractionList
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: multiple JSON values")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(value dnd.NPCInteractionList) error {
|
||||||
|
if value.Interactions == nil {
|
||||||
|
return fmt.Errorf("interactions must be present")
|
||||||
|
}
|
||||||
|
for index, interaction := range value.Interactions {
|
||||||
|
prefix := fmt.Sprintf("interactions[%d]", index)
|
||||||
|
if strings.TrimSpace(interaction.Name) == "" {
|
||||||
|
return fmt.Errorf("%s.name must not be empty", prefix)
|
||||||
|
}
|
||||||
|
if !validInteractionKind(interaction.Kind) {
|
||||||
|
return fmt.Errorf("%s.kind must be supported", prefix)
|
||||||
|
}
|
||||||
|
if len(interaction.SourceRefs) == 0 {
|
||||||
|
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
|
||||||
|
}
|
||||||
|
for refIndex, ref := range interaction.SourceRefs {
|
||||||
|
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
||||||
|
if strings.TrimSpace(ref.SourceID) == "" {
|
||||||
|
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||||
|
}
|
||||||
|
if ref.StartUnitID <= 0 {
|
||||||
|
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
|
||||||
|
}
|
||||||
|
if ref.EndUnitID <= 0 {
|
||||||
|
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInteractionKind(value dnd.NPCInteractionKind) bool {
|
||||||
|
switch value {
|
||||||
|
case dnd.NPCInteractionKindMentioned,
|
||||||
|
dnd.NPCInteractionKindNoncombatPresence,
|
||||||
|
dnd.NPCInteractionKindDialogue,
|
||||||
|
dnd.NPCInteractionKindCombatAlly,
|
||||||
|
dnd.NPCInteractionKindCombatOpponent,
|
||||||
|
dnd.NPCInteractionKindOther:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
183
internal/modules/dnd/codec/npcinteractions/codec_test.go
Normal file
183
internal/modules/dnd/codec/npcinteractions/codec_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package npcinteractions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validList() dnd.NPCInteractionList {
|
||||||
|
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||||
|
{
|
||||||
|
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Hooded Guard", Kind: dnd.NPCInteractionKindCombatOpponent,
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/dnd_npc_interactions.v1.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
codec := New()
|
||||||
|
value, err := codec.Decode(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v", err)
|
||||||
|
}
|
||||||
|
if want := validList(); !reflect.DeepEqual(value, want) {
|
||||||
|
t.Fatalf("Decode() = %#v, want %#v", value, want)
|
||||||
|
}
|
||||||
|
encoded, err := codec.Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
var compact bytes.Buffer
|
||||||
|
if err := json.Compact(&compact, raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(encoded, compact.Bytes()) {
|
||||||
|
t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
schema := codec.Schema()
|
||||||
|
if codec.Kind() != dnd.NPCInteractionListKind || codec.MediaType() != MediaType {
|
||||||
|
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
|
||||||
|
}
|
||||||
|
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
||||||
|
t.Fatalf("schema = %#v", schema)
|
||||||
|
}
|
||||||
|
registry := pipeline.NewArtifactCodecRegistry()
|
||||||
|
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec, ok := registry.Spec(dnd.NPCInteractionListKind)
|
||||||
|
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||||
|
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||||
|
}
|
||||||
|
if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCList{}); err == nil {
|
||||||
|
t.Fatal("Encode() error = nil, want exact type rejection")
|
||||||
|
} else {
|
||||||
|
var typeErr *pipeline.ArtifactCodecTypeError
|
||||||
|
if !errors.As(err, &typeErr) {
|
||||||
|
t.Fatalf("Encode() error = %T, want ArtifactCodecTypeError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}
|
||||||
|
content, err := codec.Encode(empty)
|
||||||
|
if err != nil || string(content) != `{"interactions":[]}` {
|
||||||
|
t.Fatalf("Encode() = %s, %v", content, err)
|
||||||
|
}
|
||||||
|
for _, candidate := range []dnd.NPCInteractionList{
|
||||||
|
{},
|
||||||
|
empty,
|
||||||
|
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}},
|
||||||
|
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
|
||||||
|
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
|
||||||
|
} {
|
||||||
|
content, err := codec.EncodeCandidate(candidate)
|
||||||
|
if err != nil || !json.Valid(content) {
|
||||||
|
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
|
||||||
|
}
|
||||||
|
decoded, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||||
|
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
||||||
|
validJSON := `{"interactions":[{"name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
|
for _, test := range []struct{ name, raw, want string }{
|
||||||
|
{"malformed", `{`, "decode dnd npc interaction list"},
|
||||||
|
{"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"},
|
||||||
|
{"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"},
|
||||||
|
{"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
||||||
|
{"trailing", `{"interactions":[]} {}`, "multiple JSON values"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value dnd.NPCInteractionList
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"},
|
||||||
|
{"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"},
|
||||||
|
{"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"},
|
||||||
|
{"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"},
|
||||||
|
{"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
||||||
|
{"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
|
||||||
|
{"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := New().Encode(test.value); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Encode() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
|
||||||
|
for _, kind := range []dnd.NPCInteractionKind{
|
||||||
|
dnd.NPCInteractionKindMentioned,
|
||||||
|
dnd.NPCInteractionKindNoncombatPresence,
|
||||||
|
dnd.NPCInteractionKindDialogue,
|
||||||
|
dnd.NPCInteractionKindCombatAlly,
|
||||||
|
dnd.NPCInteractionKindCombatOpponent,
|
||||||
|
dnd.NPCInteractionKindOther,
|
||||||
|
} {
|
||||||
|
value := validList()
|
||||||
|
value.Interactions[0].Kind = kind
|
||||||
|
if _, err := New().Encode(value); err != nil {
|
||||||
|
t.Fatalf("Encode(%q) error = %v", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
first := codec.Schema()
|
||||||
|
first.JSONSchema[0] = '['
|
||||||
|
if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
|
||||||
|
t.Fatal("Schema() returned shared bytes")
|
||||||
|
}
|
||||||
|
metadata := codec.Metadata(validList())
|
||||||
|
metadata["other"] = true
|
||||||
|
if next := codec.Metadata(validList()); len(next) != 1 || next["interaction_count"] != 2 {
|
||||||
|
t.Fatalf("Metadata() = %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList {
|
||||||
|
change(&value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
18
internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json
vendored
Normal file
18
internal/modules/dnd/codec/npcinteractions/testdata/dnd_npc_interactions.v1.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"name": "Mira Thorn",
|
||||||
|
"kind": "dialogue",
|
||||||
|
"source_refs": [
|
||||||
|
{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hooded Guard",
|
||||||
|
"kind": "combat_opponent",
|
||||||
|
"source_refs": [
|
||||||
|
{"source_id": "session-alpha", "start_unit_id": 3, "end_unit_id": 3}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
137
internal/modules/dnd/codec/scenedescriptions/codec.go
Normal file
137
internal/modules/dnd/codec/scenedescriptions/codec.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
208
internal/modules/dnd/codec/scenedescriptions/codec_test.go
Normal file
208
internal/modules/dnd/codec/scenedescriptions/codec_test.go
Normal 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
|
||||||
|
}
|
||||||
1
internal/modules/dnd/codec/scenedescriptions/testdata/dnd_scene_descriptions.v1.json
vendored
Normal file
1
internal/modules/dnd/codec/scenedescriptions/testdata/dnd_scene_descriptions.v1.json
vendored
Normal 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."}]}
|
||||||
6
internal/modules/dnd/extract/npcinteractions/assets.go
Normal file
6
internal/modules/dnd/extract/npcinteractions/assets.go
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
internal/modules/dnd/extract/npcinteractions/canonicalize.go
Normal file
113
internal/modules/dnd/extract/npcinteractions/canonicalize.go
Normal 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
|
||||||
|
}
|
||||||
225
internal/modules/dnd/extract/npcinteractions/extractor.go
Normal file
225
internal/modules/dnd/extract/npcinteractions/extractor.go
Normal 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...)
|
||||||
|
}
|
||||||
370
internal/modules/dnd/extract/npcinteractions/extractor_test.go
Normal file
370
internal/modules/dnd/extract/npcinteractions/extractor_test.go
Normal 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
|
||||||
|
}
|
||||||
16
internal/modules/dnd/extract/npcinteractions/model.go
Normal file
16
internal/modules/dnd/extract/npcinteractions/model.go
Normal 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"`
|
||||||
|
}
|
||||||
18
internal/modules/dnd/extract/npcinteractions/model_test.go
Normal file
18
internal/modules/dnd/extract/npcinteractions/model_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
internal/modules/dnd/extract/npcinteractions/schema.go
Normal file
21
internal/modules/dnd/extract/npcinteractions/schema.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
106
internal/modules/dnd/extract/npcinteractions/schema_test.go
Normal file
106
internal/modules/dnd/extract/npcinteractions/schema_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
6
internal/modules/dnd/extract/scenedescriptions/assets.go
Normal file
6
internal/modules/dnd/extract/scenedescriptions/assets.go
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
internal/modules/dnd/extract/scenedescriptions/extractor.go
Normal file
195
internal/modules/dnd/extract/scenedescriptions/extractor.go
Normal 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...)
|
||||||
|
}
|
||||||
126
internal/modules/dnd/extract/scenedescriptions/extractor_test.go
Normal file
126
internal/modules/dnd/extract/scenedescriptions/extractor_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/modules/dnd/extract/scenedescriptions/model.go
Normal file
9
internal/modules/dnd/extract/scenedescriptions/model.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/modules/dnd/extract/scenedescriptions/schema.go
Normal file
22
internal/modules/dnd/extract/scenedescriptions/schema.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
307
internal/modules/dnd/normalize/npcinteractions/normalizer.go
Normal file
307
internal/modules/dnd/normalize/npcinteractions/normalizer.go
Normal 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...)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
173
internal/modules/dnd/normalize/scenedescriptions/normalizer.go
Normal file
173
internal/modules/dnd/normalize/scenedescriptions/normalizer.go
Normal 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...)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
166
internal/modules/dnd/npcinteractions/canonical.go
Normal file
166
internal/modules/dnd/npcinteractions/canonical.go
Normal 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(';')
|
||||||
|
}
|
||||||
@@ -3,19 +3,32 @@ package register
|
|||||||
import (
|
import (
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
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"
|
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"
|
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
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"
|
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"
|
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||||
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
|
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
|
||||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
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"
|
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"
|
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"
|
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
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"
|
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"
|
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"
|
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
|
||||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
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"
|
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),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,46 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
|
|||||||
return combined, nil
|
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 {
|
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||||
clone := value
|
clone := value
|
||||||
if value.SourceRefs != nil {
|
if value.SourceRefs != nil {
|
||||||
@@ -64,3 +104,11 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
|||||||
}
|
}
|
||||||
return clone
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
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"
|
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"
|
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
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"
|
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"
|
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
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"
|
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"
|
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/merge/appendorder"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
"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: "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: "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: "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: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
|
||||||
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
|
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
|
||||||
{name: "npcs extractor", register: func() error { return npcextract.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: "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 {
|
{name: "spell-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
|
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 {
|
{name: "combat-turn-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
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: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "npcs normalizer", register: func() error { return npcnormalize.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: "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 {
|
{name: "spell-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind)
|
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 {
|
{name: "combat-turn-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
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: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }},
|
||||||
{name: "npcs prompt assets", register: func() error { return npcextract.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: "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) }},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
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"
|
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"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
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"
|
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"
|
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)
|
t.Fatalf("Register() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
||||||
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key})
|
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, pipeline.DefaultNormalizeModule})
|
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})
|
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})
|
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})
|
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(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
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{
|
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||||
"extract/dnd/npcs/shape",
|
"extract/dnd/npcs/shape",
|
||||||
"extract/dnd/npcs/source_refs",
|
"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_refs",
|
||||||
"extract/dnd/combat-turns/source_relatedness",
|
"extract/dnd/combat-turns/source_relatedness",
|
||||||
"normalize/dnd/combat-turns/invariants",
|
"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_accept",
|
||||||
"generic/always_reject",
|
"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) {
|
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
|
||||||
t.Fatalf("combat normalize validator chain = %#v, want %#v", 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 {
|
if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil {
|
||||||
t.Fatalf("NPC merge validator chain = %#v, want absent", got)
|
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{
|
assertAssetNamesContain(t, assets.PromptFS, []string{
|
||||||
"dnd.scenes/dnd.scenes.yaml",
|
"dnd.scenes/dnd.scenes.yaml",
|
||||||
"dnd.scenes/instructions.md",
|
"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-system.md",
|
||||||
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||||
"dnd.combat_turns/task.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{
|
assertAssetNamesContain(t, assets.SchemaFS, []string{
|
||||||
"dnd_scenes.v1.json",
|
"dnd_scenes.v1.json",
|
||||||
"dnd_spells_llm.v1.json",
|
"dnd_spells_llm.v1.json",
|
||||||
"dnd_npcs_llm.v1.json",
|
"dnd_npcs_llm.v1.json",
|
||||||
"dnd_combat_turns_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" {
|
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)
|
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 {
|
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)
|
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) {
|
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) {
|
func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
||||||
input := []dnd.CombatTurnList{
|
input := []dnd.CombatTurnList{
|
||||||
|
|||||||
@@ -7,10 +7,19 @@ import (
|
|||||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
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"
|
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"
|
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"
|
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
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"
|
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"
|
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"
|
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
|
||||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
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"
|
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 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 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: "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 {
|
{name: "spell-list always accept validator", register: func() error {
|
||||||
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
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 {
|
{name: "combat-turn-list always reject validator", register: func() error {
|
||||||
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
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)
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
An optional normalized Dungeons & Dragons NPC registry is provided below as
|
A normalized Dungeons & Dragons NPC registry is provided below as grounding
|
||||||
grounding material. Use it only to prefer exact canonical participant names
|
material. It may be empty. Use it only to prefer exact canonical participant
|
||||||
and recognize their aliases when the transcript identifies a participant.
|
names when the transcript identifies a participant.
|
||||||
|
|
||||||
Registry content is context, not event evidence. Do not extract events,
|
Registry content is context, not event evidence. Do not extract events,
|
||||||
participants, effects, or source references from the registry. Registry source
|
participants, effects, or source references from the registry. Registry source
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MaxIssues = 20
|
MaxIssues = 20
|
||||||
|
MaxWarnings = 20
|
||||||
MaxDisplayedRunes = 128
|
MaxDisplayedRunes = 128
|
||||||
MaxMessageBytes = 4096
|
MaxMessageBytes = 4096
|
||||||
)
|
)
|
||||||
@@ -37,6 +40,28 @@ func Aggregate(prefix string, issues []string) string {
|
|||||||
return aggregateMessage(prefix, displayed, len(issues)-len(displayed))
|
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 {
|
func aggregateMessage(prefix string, issues []string, omitted int) string {
|
||||||
message := prefix + ": " + strings.Join(issues, ", ")
|
message := prefix + ": " + strings.Join(issues, ", ")
|
||||||
if omitted > 0 {
|
if omitted > 0 {
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package diagnostics
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
||||||
@@ -26,3 +29,25 @@ func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
|||||||
t.Fatalf("Aggregate() = %q, want %q", message, wantOmitted)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const NPCListKind contracts.ArtifactKind = "dnd/npc-list"
|
|||||||
|
|
||||||
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
||||||
|
|
||||||
|
const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
|
||||||
|
|
||||||
|
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
||||||
|
|
||||||
type SpellList struct {
|
type SpellList struct {
|
||||||
SpellCasts []SpellCast `json:"spell_casts"`
|
SpellCasts []SpellCast `json:"spell_casts"`
|
||||||
}
|
}
|
||||||
@@ -51,3 +55,45 @@ type CombatTurn struct {
|
|||||||
TurnKind CombatTurnKind `json:"turn_kind"`
|
TurnKind CombatTurnKind `json:"turn_kind"`
|
||||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NPCInteractionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NPCInteractionKindMentioned NPCInteractionKind = "mentioned"
|
||||||
|
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence"
|
||||||
|
NPCInteractionKindDialogue NPCInteractionKind = "dialogue"
|
||||||
|
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally"
|
||||||
|
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent"
|
||||||
|
NPCInteractionKindOther NPCInteractionKind = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NPCInteractionList struct {
|
||||||
|
Interactions []NPCInteraction `json:"interactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NPCInteraction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind NPCInteractionKind `json:"kind"`
|
||||||
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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"}}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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"}}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
106
internal/modules/dnd/validate/npcinteractions/shape/validator.go
Normal file
106
internal/modules/dnd/validate/npcinteractions/shape/validator.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Package shape validates required D&D NPC interaction candidate fields.
|
||||||
|
package shape
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Key = "extract/dnd/npc-interactions/shape"
|
||||||
|
ReasonCode = "invalid_npc_interaction_shape"
|
||||||
|
policy = "dnd.npc_interactions.validator.shape.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
type Validator struct{}
|
||||||
|
|
||||||
|
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
||||||
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
|
func New(Options) *Validator { return &Validator{} }
|
||||||
|
func (v *Validator) Name() string { return Key }
|
||||||
|
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||||
|
return contracts.ExecutionClassDeterministic
|
||||||
|
}
|
||||||
|
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
|
||||||
|
if err := Validate(req.Value); err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Validate(value dnd.NPCInteractionList) error {
|
||||||
|
issues := issuesFor(value)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues))
|
||||||
|
}
|
||||||
|
|
||||||
|
func issuesFor(value dnd.NPCInteractionList) []string {
|
||||||
|
if value.Interactions == nil {
|
||||||
|
return []string{"interactions must be present"}
|
||||||
|
}
|
||||||
|
issues := make([]string, 0)
|
||||||
|
for index, interaction := range value.Interactions {
|
||||||
|
prefix := fmt.Sprintf("interactions[%d]", index)
|
||||||
|
if strings.TrimSpace(interaction.Name) == "" {
|
||||||
|
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(interaction.Name))
|
||||||
|
}
|
||||||
|
if !validKind(interaction.Kind) {
|
||||||
|
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(interaction.Kind)))
|
||||||
|
}
|
||||||
|
if len(interaction.SourceRefs) == 0 {
|
||||||
|
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
func validKind(value dnd.NPCInteractionKind) bool {
|
||||||
|
switch value {
|
||||||
|
case dnd.NPCInteractionKindMentioned,
|
||||||
|
dnd.NPCInteractionKindNoncombatPresence,
|
||||||
|
dnd.NPCInteractionKindDialogue,
|
||||||
|
dnd.NPCInteractionKindCombatAlly,
|
||||||
|
dnd.NPCInteractionKindCombatOpponent,
|
||||||
|
dnd.NPCInteractionKindOther:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user