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)
|
||||
- [Maintained example config](examples/dnd-spells.config.yml)
|
||||
- [NPC-grounded example config](examples/dnd-npc-grounded.config.yml)
|
||||
- [NPC interaction example config](examples/dnd-npc-interactions.config.yml)
|
||||
- [Maintained example input](examples/seriatim-minimal-transcript.json)
|
||||
|
||||
@@ -24,6 +24,8 @@ The explicit-path option is defined in the [CLI reference](cli.md).
|
||||
- [D&D NPC configuration](../examples/dnd-npcs.config.yml)
|
||||
- [D&D combat-turn configuration](../examples/dnd-combat-turns.config.yml)
|
||||
- [D&D NPC-grounded spell and combat configuration](../examples/dnd-npc-grounded.config.yml)
|
||||
- [D&D NPC interaction configuration](../examples/dnd-npc-interactions.config.yml)
|
||||
- [D&D scene chunk-map configuration](../examples/dnd-scene-chunk-map.config.yml)
|
||||
|
||||
All are complete version 3 files. The fragments below illustrate individual
|
||||
fields and are not alternate complete configurations.
|
||||
@@ -330,6 +332,25 @@ During resolution, each selected module's registered option validator runs.
|
||||
Production input, chunk, and output bindings reject unknown or invalid options
|
||||
with the affected binding context.
|
||||
|
||||
### JSON Output Options
|
||||
|
||||
The `json` output module accepts only `include_chunk_map`, a boolean that
|
||||
defaults to `false`. When `true`, it adds the accepted pipeline-wide chunk map
|
||||
to the logical output bundle when one exists. It does not create a CLI flag or
|
||||
change output placement.
|
||||
|
||||
```yaml
|
||||
output:
|
||||
module: json
|
||||
options:
|
||||
include_chunk_map: true
|
||||
```
|
||||
|
||||
The payload and index descriptor are defined by the
|
||||
[Accepted Chunk Map contract](integrations/chunk-map.md). See the complete
|
||||
[D&D scene chunk-map configuration](../examples/dnd-scene-chunk-map.config.yml)
|
||||
for a copyable pipeline.
|
||||
|
||||
Validator bindings use the same shorthand or object module-binding form, but
|
||||
only these fields are supported:
|
||||
|
||||
@@ -355,12 +376,16 @@ production validators do not call the LLM and must not set `llm_profile`.
|
||||
| extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. |
|
||||
| extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. |
|
||||
| extract | `dnd/combat-turns` | Extracts typed D&D combat-turn-list artifacts. |
|
||||
| extract | `dnd/npc-interactions` | Extracts typed D&D NPC-interaction-list artifacts from a supplied NPC registry. |
|
||||
| extract | `dnd/scene-descriptions` | Extracts one typed D&D scene description for each accepted chunk. |
|
||||
| merge | `appendorder` | Combines typed artifacts in chunk order. |
|
||||
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
|
||||
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
|
||||
| normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical name and unions exact evidence. |
|
||||
| normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. |
|
||||
| output | `json` | Produces JSON output files for normalized `application/json` lanes. |
|
||||
| normalize | `dnd/npc-interactions` | Canonicalizes registry NPC names, orders interaction occurrences, and removes only exact duplicates. |
|
||||
| normalize | `dnd/scene-descriptions` | Validates, orders, trims, and de-duplicates typed D&D scene-description artifacts. |
|
||||
| output | `json` | Produces JSON output files for normalized `application/json` lanes and can opt in to an accepted chunk map. |
|
||||
|
||||
## Implemented Production Validators
|
||||
|
||||
@@ -382,6 +407,15 @@ production validators do not call the LLM and must not set `llm_profile`.
|
||||
| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. |
|
||||
| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor is not found near cited source text. |
|
||||
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. |
|
||||
| `extract/dnd/npc-interactions/shape` | deterministic | Rejects malformed D&D NPC-interaction-list artifacts. |
|
||||
| `extract/dnd/npc-interactions/registry` | deterministic | Rejects interaction names absent from the supplied NPC registry. |
|
||||
| `extract/dnd/npc-interactions/source_refs` | deterministic | Rejects missing, invalid, or extract-chunk-external D&D interaction source references. |
|
||||
| `extract/dnd/npc-interactions/source_relatedness` | deterministic | Emits bounded warnings when an interaction name is not found in its cited source text. |
|
||||
| `normalize/dnd/npc-interactions/invariants` | deterministic | Rejects normalized interaction identity, evidence-order, and chronology violations. |
|
||||
| `extract/dnd/scene-descriptions/shape` | deterministic | Rejects malformed D&D scene-description-list artifacts. |
|
||||
| `extract/dnd/scene-descriptions/source_refs` | deterministic | Rejects invalid current-source references and extract records not exactly attached to their chunk. |
|
||||
| `extract/dnd/scene-descriptions/source_relatedness` | deterministic | Emits bounded advisory warnings for title or summary text not grounded in its cited transcript range. |
|
||||
| `normalize/dnd/scene-descriptions/invariants` | deterministic | Rejects non-normalized scene order, exact duplicates, and ID or range conflicts. |
|
||||
|
||||
The production default chain for `dnd/spells` is used for both its extract and
|
||||
normalize stages:
|
||||
@@ -436,6 +470,48 @@ normalize:
|
||||
- extract/dnd/combat-turns/source_relatedness
|
||||
```
|
||||
|
||||
The production default chains for `dnd/npc-interactions` are:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/npc-interactions/shape
|
||||
- extract/dnd/npc-interactions/registry
|
||||
- extract/dnd/npc-interactions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/npc-interactions/source_relatedness
|
||||
normalize:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/npc-interactions/shape
|
||||
- extract/dnd/npc-interactions/registry
|
||||
- normalize/dnd/npc-interactions/invariants
|
||||
- extract/dnd/npc-interactions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/npc-interactions/source_relatedness
|
||||
```
|
||||
|
||||
The production default chains for `dnd/scene-descriptions` are:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/scene-descriptions/shape
|
||||
- extract/dnd/scene-descriptions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/scene-descriptions/source_relatedness
|
||||
normalize:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/scene-descriptions/shape
|
||||
- normalize/dnd/scene-descriptions/invariants
|
||||
- extract/dnd/scene-descriptions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/scene-descriptions/source_relatedness
|
||||
```
|
||||
|
||||
Empty chains approve output by default.
|
||||
|
||||
The `generic` chunker accepts:
|
||||
@@ -492,6 +568,13 @@ accepts no references. The maintained
|
||||
accepted normalized output to the later spell and combat targets through an
|
||||
explicit ordered step.
|
||||
|
||||
The `dnd/scene-descriptions` extractor accepts only the optional `players`,
|
||||
`party`, and `glossary` campaign slots. They can disambiguate terms in the
|
||||
model-facing title and summary but are never source evidence. Its normalizer
|
||||
accepts no references. No NPC registry or generated artifact is required. See
|
||||
the [D&D scene-description artifact contract](integrations/dnd-scene-description-artifacts.md)
|
||||
and the copyable [scene-description configuration](../examples/dnd-scene-descriptions.config.yml).
|
||||
|
||||
The `dnd/combat-turns` extractor declares the optional campaign slots and the
|
||||
structured `npcs` slot. Campaign references guide only the LLM extraction
|
||||
stage. The deterministic normalizer declares only `npcs`, whose operation-time
|
||||
@@ -516,6 +599,16 @@ identity; names, content, and paths are not recorded there. When
|
||||
absent, the combat prompt receives the exact empty registry value
|
||||
`{"npcs":[]}` with its projection digest and no registry provenance.
|
||||
|
||||
Both `dnd/npc-interactions` stages require the structured `npcs` slot. Bind one
|
||||
accepted normalized NPC artifact from an earlier ordered step; the interaction
|
||||
extractor receives only its names-only projection, while the normalizer uses
|
||||
the immutable registry for exact canonical-name lookup. A missing, rejected,
|
||||
or incompatible producer prevents the interaction step from running. Registry
|
||||
source references remain identity provenance and never supply interaction
|
||||
evidence: every interaction must cite current transcript units. The complete
|
||||
durable contract and two-step configuration are defined in the
|
||||
[D&D NPC interaction artifact contract](integrations/dnd-npc-interaction-artifacts.md).
|
||||
|
||||
## State Surfaces
|
||||
|
||||
The `output`, `cache`, and `debug` top-level fields select independent physical
|
||||
|
||||
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
|
||||
or to the combat extractor and normalizer as an actor registry. It
|
||||
remains a reference, not spell or combat evidence.
|
||||
The NPC interaction extractor and normalizer also consume this registry for
|
||||
canonical identity; registry source references remain provenance and never
|
||||
become interaction evidence. Their occurrence contract is defined in the
|
||||
[D&D NPC interaction artifact](dnd-npc-interaction-artifacts.md).
|
||||
|
||||
## Identity
|
||||
|
||||
|
||||
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
|
||||
- `rejected.json`
|
||||
- `warnings.json`
|
||||
- `chunk-map.json`, only when the JSON output binding enables
|
||||
`include_chunk_map` and the run has an accepted chunk map
|
||||
|
||||
Files are pretty-printed JSON with a trailing newline when the payload is JSON.
|
||||
Logical file paths are relative, slash-separated, and may not contain `..`.
|
||||
@@ -60,6 +62,13 @@ contains the normalized payload `media_type`, normalizer `module_key`, and
|
||||
response `schema_id`, `schema_name`, and `schema_version` when those values are
|
||||
available.
|
||||
|
||||
When present, the top-level optional `chunk_map` descriptor contains exactly
|
||||
`artifact_kind`, `file`, `media_type`, `schema_id`, `schema_name`, and
|
||||
`schema_version`. It identifies the pipeline-wide `chunk-map.json`; it is not
|
||||
a lane output and never appears in `output_files`. The descriptor and file are
|
||||
both absent when export is disabled or no chunk plan was accepted. Its payload
|
||||
contract is defined by [Accepted Chunk Map](chunk-map.md).
|
||||
|
||||
## `manifest.json`
|
||||
|
||||
`manifest.json` contains a run manifest. This abridged example shows its core
|
||||
@@ -153,7 +162,8 @@ contains the codec-owned JSON bytes pretty-printed.
|
||||
The schema of each lane payload is owned by that artifact contract. For the
|
||||
current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md),
|
||||
[D&D NPC Artifact](dnd-npc-artifacts.md), and
|
||||
[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md).
|
||||
[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md), and
|
||||
[D&D Scene Description Artifact](dnd-scene-description-artifacts.md).
|
||||
|
||||
## `rejected.json`
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ without exposing Scriptorium types through stage contracts.
|
||||
7. injecting that one shared client into complete pipeline preparation before
|
||||
the source file is read or the runner is invoked.
|
||||
|
||||
The D&D scene chunker and spell, NPC, and combat extractors retain this
|
||||
The D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and
|
||||
scene-description extractors retain this
|
||||
injected client and use it for every structured completion. Operation requests
|
||||
do not carry an LLM client.
|
||||
|
||||
@@ -98,7 +99,8 @@ D&D prompt content. `internal/framework/promptfs` provides the domain-neutral
|
||||
filesystem composition helper used to combine module-owned files with shared
|
||||
domain prompt fragments.
|
||||
|
||||
The D&D scene chunker and spell, NPC, and combat-turn extractors each declare an
|
||||
The D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and
|
||||
scene-description extractors each declare an
|
||||
ordered prompt asset manifest. The manifest lists the package-owned YAML and
|
||||
Markdown files, then the exact shared fragments rendered by that prompt; the
|
||||
same ordered list drives both filesystem mounting and the prompt fingerprint.
|
||||
@@ -126,21 +128,31 @@ per-chunk transcript changes. Cache reuse requires the preceding message
|
||||
sequence and content to be exactly identical; semantic similarity is not
|
||||
sufficient. Cache boundaries belong at the ends of reusable stable tiers,
|
||||
subject to the provider's cache-boundary limit. The shared identity and
|
||||
campaign-reference messages form the first two extraction boundaries. Spell
|
||||
and combat prompts add a boundary at the shared NPC registry. Each extraction
|
||||
campaign-reference messages form the first two extraction boundaries. Spell,
|
||||
combat, and interaction prompts add a boundary at the shared NPC registry. Each extraction
|
||||
prompt places its final boundary on its lane-specific instructions, immediately
|
||||
before the transcript. The transcript does not carry cache control because no
|
||||
reusable content follows it.
|
||||
|
||||
Accordingly, the common prefix of all three extraction prompts is system,
|
||||
Accordingly, the common prefix of the spell, NPC, combat, and interaction
|
||||
extraction prompts is system,
|
||||
extraction evidence, identity, and campaign references. The NPC prompt then
|
||||
renders task, instructions, and transcript. Spell renders the NPC registry,
|
||||
catalog, task, instructions, and transcript. Combat renders the NPC registry,
|
||||
task, instructions, and transcript. The
|
||||
task, instructions, and transcript. NPC interaction renders the names-only NPC
|
||||
registry, task, instructions, and transcript. The
|
||||
scene chunker is not an extraction lane: it retains its separate system,
|
||||
transcript, campaign-reference, task, and instruction order and marks its
|
||||
transcript and campaign-reference messages ephemeral.
|
||||
|
||||
The scene-description extractor deliberately omits the citation-oriented
|
||||
`common-dnd-extraction-evidence.md` asset because Notarius attaches the whole
|
||||
accepted chunk range itself. Its manifest is system, shared identity, shared
|
||||
campaign references, lane task, lane instructions, then the transcript. The
|
||||
identity, campaign-reference, and instruction messages are ephemeral cache
|
||||
boundaries; the transcript is last and has no cache control. Compatible shared
|
||||
messages remain canonical shared assets rather than copied package text.
|
||||
|
||||
Shared wording belongs in the canonical assets under
|
||||
`internal/modules/dnd/shared`; extraction packages reference those assets in
|
||||
their manifests instead of copying similar text into package-local files.
|
||||
@@ -154,7 +166,8 @@ return defensive copies, and expose a diagnostics map that omits schema bytes.
|
||||
The small framework registry contains only generic test schemas; production
|
||||
schemas remain package-owned.
|
||||
|
||||
The spell, NPC, and combat extractors' package-owned prompts declare their
|
||||
The spell, NPC, combat, NPC-interaction, and scene-description extractors'
|
||||
package-owned prompts declare their
|
||||
structured JSON inputs and private response schemas. Each private response
|
||||
schema remains separate from its durable artifact codec schema; this work does
|
||||
not use shared schema fragments or schema generation. Those private schemas own
|
||||
@@ -167,14 +180,14 @@ numbers. The spell extractor's prompt declares a required
|
||||
the catalog input from its prepared
|
||||
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
||||
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
||||
after the campaign reference message for spell and combat prompts. When an NPC
|
||||
after the campaign reference message for spell, combat, and interaction prompts. When an NPC
|
||||
registry is bound, the
|
||||
domain registry boundary strictly decodes and identity-validates one durable
|
||||
artifact, re-encodes canonical JSON for provenance, and separately generates a
|
||||
names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
|
||||
Prompt input and component-local checkpoint digests cover the projected bytes;
|
||||
manifests retain the optional full registry digest/count rather than names,
|
||||
overlay bytes, registry paths, or source metadata. Combat prompt,
|
||||
overlay bytes, registry paths, or source metadata. Combat and interaction prompt,
|
||||
response-schema, mapping, normalization, identity, and registry-projection
|
||||
fingerprints remain separate semantic inputs to checkpoint identity.
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ validator registry. Package-family registrars compose those leaf registrations
|
||||
into the production catalog and own family-level policy such as default
|
||||
validator chains and prompt asset collection.
|
||||
|
||||
Production input, chunk, output, and D&D spell- and combat-extract packages
|
||||
Production input, chunk, output, and D&D spell-, NPC-, combat-, interaction-, and scene-description-extract packages
|
||||
register strict option decoders and run-local builders. Preparation decodes their options into
|
||||
implementation-owned values and injects dependencies plus the materialized
|
||||
reference set for the selected target. Each builder receives an isolated clone
|
||||
of that set; input and output builders receive no references. The spell and
|
||||
combat extractors are typed over the canonical D&D model. D&D validators, merge,
|
||||
of that set; input and output builders receive no references. The spell, NPC,
|
||||
combat, interaction, and scene-description extractors are typed over the canonical D&D model. D&D validators, merge,
|
||||
and normalize use typed variants; JSON representation validators use serialized
|
||||
requests; and unconditional validators expose separate chunk and typed
|
||||
variants. The D&D production registrar registers the canonical typed spell,
|
||||
NPC, and combat implementations, including their kind-specific merge and
|
||||
NPC, combat, interaction, and scene-description implementations, including their kind-specific merge and
|
||||
normalize behavior.
|
||||
|
||||
For D&D artifact defaults, generic JSON syntax validation runs first. Rejecting
|
||||
@@ -54,7 +54,7 @@ package-local embedded assets. Shared filesystem composition belongs in
|
||||
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
||||
declarations, prompt-input assembly, and source-unit/citation helpers belong in
|
||||
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
|
||||
D&D scene chunker and spell, NPC, and combat-turn extractors use ordered
|
||||
D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and scene-description extractors use ordered
|
||||
package-local prompt manifests for both rendering and prompt fingerprinting, so
|
||||
only the shared fragments each prompt actually renders participate in either
|
||||
operation. Extraction prompts place stable shared and lane-specific context
|
||||
@@ -65,7 +65,7 @@ contracts expose only Notarius structured-completion types, not Scriptorium
|
||||
public types.
|
||||
|
||||
The shared `ChunkPromptMaterial` helper owns common transcript material
|
||||
preparation for the spell, NPC, and combat-turn extractors. It clones supplied
|
||||
preparation for the spell, NPC, combat-turn, and NPC-interaction extractors. It clones supplied
|
||||
source metadata, falls back to the materialized chunk when content is absent,
|
||||
checks that content remains chunk-identical, and fills only the common default
|
||||
fields. Extractors retain their request validation and wrap helper errors with
|
||||
@@ -122,6 +122,18 @@ The `internal/modules/dnd/codec/combatturns` package owns the durable
|
||||
is registered by the production D&D family registrar for the selectable combat
|
||||
lane.
|
||||
|
||||
The `internal/modules/dnd/codec/npcinteractions` package owns the durable
|
||||
`dnd/npc-interaction-list` schema and candidate versus approved JSON boundary.
|
||||
It is registered by the production D&D family registrar for the selectable
|
||||
interaction lane. Its external contract is documented in the
|
||||
[D&D NPC interaction artifact contract](../integrations/dnd-npc-interaction-artifacts.md).
|
||||
|
||||
The `internal/modules/dnd/codec/scenedescriptions` package owns the durable
|
||||
`dnd/scene-description-list` schema and candidate versus approved JSON boundary.
|
||||
It is registered by the production D&D family registrar. Its external contract
|
||||
is documented in the
|
||||
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
|
||||
|
||||
## Input Adapter
|
||||
|
||||
### `internal/modules/seriatim/input/transcript`
|
||||
@@ -165,17 +177,16 @@ window settings used by `Plan`.
|
||||
|
||||
The scene chunker prepares a structured Scriptorium request from the full
|
||||
transcript, session, and optional D&D reference inputs. It validates the model's
|
||||
scene boundaries against source-unit IDs and converts them into deterministic
|
||||
plan ranges with optional scene annotations. Preparation injects the shared
|
||||
structured LLM client into the chunker; `Plan`
|
||||
supplies only the run-specific profile, session, source, references, and
|
||||
metadata.
|
||||
inclusive source-unit endpoints against document position and converts them
|
||||
into deterministic plan ranges. Preparation injects the shared structured LLM
|
||||
client into the chunker; `Plan` supplies only the run-specific profile, session,
|
||||
source, references, and metadata.
|
||||
|
||||
Scene validation requires sequential, contiguous, non-overlapping coverage from
|
||||
the first source unit through the last. Scene descriptions, boundaries,
|
||||
confidence, and participants are module-owned annotations. Boundary caveats
|
||||
become warnings. Malformed
|
||||
structured output is returned as an error; there is no fallback chunker.
|
||||
the first source unit through the last. Its private response contains only the
|
||||
boundary endpoints; the accepted plan has no D&D-specific annotations and
|
||||
produces no boundary warnings. Malformed structured output is returned as an
|
||||
error; there is no fallback chunker.
|
||||
|
||||
The package embeds its prompt and response schema and reports their non-secret
|
||||
identity and hashes through singleton module metadata. Shared D&D assets supply
|
||||
@@ -262,6 +273,18 @@ integer source-unit candidates unchanged. Required semantic content, non-empty
|
||||
evidence, and valid source ranges are rejected by the deterministic shape and
|
||||
source-reference validators.
|
||||
|
||||
### `internal/modules/dnd/extract/scenedescriptions`
|
||||
|
||||
The scene-description extractor makes one structured completion for each
|
||||
accepted chunk and maps its private `kind`, `title`, and `summary` response to
|
||||
one `dnd.SceneDescription`. It assigns the current chunk ID and exact range,
|
||||
preserves kind without repair, and trims only title and summary whitespace.
|
||||
Optional players, party, and glossary references can disambiguate prompt terms
|
||||
but do not supply evidence. The package owns its private schema, prompt assets,
|
||||
and mapping fingerprint; deterministic validators own the durable semantic
|
||||
checks. The durable contract is defined in the
|
||||
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
|
||||
|
||||
### `internal/modules/dnd/extract/combatturns`
|
||||
|
||||
The combat extractor prepares one structured request per supplied chunk using
|
||||
@@ -284,6 +307,28 @@ The combat normalizer accepts only the optional structured NPC registry.
|
||||
Campaign references remain extractor-only LLM context and are not materialized
|
||||
for deterministic normalization.
|
||||
|
||||
### `internal/modules/dnd/extract/npcinteractions`
|
||||
|
||||
The NPC interaction extractor requires the structured `npcs` registry slot. It
|
||||
uses the registry's names-only prompt projection with shared extraction
|
||||
evidence, identity, and transcript material, then maps private model records to
|
||||
`dnd.NPCInteractionList` with the current source identity. Registry source
|
||||
references are never reused as interaction evidence. The private response
|
||||
schema carries only name, bounded interaction kind, and source-unit ranges;
|
||||
deterministic validators own registry membership, source validity, and
|
||||
relatedness. Extract-stage source validation additionally requires every cited
|
||||
range to be wholly contained in the current materialized chunk. Prompt, schema,
|
||||
mapping, and the names-only registry projection
|
||||
participate in checkpoint identity, while generated producer identity remains
|
||||
framework provenance.
|
||||
|
||||
The domain-owned `internal/modules/dnd/npcinteractions` package defines
|
||||
canonical source-reference and occurrence ordering, valid-evidence eligibility,
|
||||
and collision-safe exact identity. The interaction normalizer and normalized
|
||||
invariants validator both consume those rules, so their production and checking
|
||||
paths cannot drift. Normalizer and relatedness warning lists use the shared D&D
|
||||
diagnostic cap and emit a final omission-summary warning when truncated.
|
||||
|
||||
### `internal/modules/dnd/normalize/npcs`
|
||||
|
||||
The NPC normalizer performs deterministic identity-aware consolidation in
|
||||
@@ -344,6 +389,25 @@ digest/count metadata; generated identity is retained in framework provenance
|
||||
and dependency fingerprints. The normalizer is included in the production D&D
|
||||
registrar with the default combat normalization chain.
|
||||
|
||||
### `internal/modules/dnd/normalize/npcinteractions`
|
||||
|
||||
The interaction normalizer requires the same immutable NPC registry. It
|
||||
canonicalizes exact registry-name matches, orders and de-duplicates source
|
||||
references, stable-sorts occurrences by source-document position, and collapses
|
||||
only exact interaction identities with valid evidence. It does not infer,
|
||||
merge, or summarize distinct occurrences. Its metadata and fingerprints expose
|
||||
the normalization and NPC identity policies; generated registry identity stays
|
||||
in framework provenance and checkpoint dependencies.
|
||||
|
||||
### `internal/modules/dnd/normalize/scenedescriptions`
|
||||
|
||||
The scene-description normalizer has no options or references. It validates
|
||||
each source range against the source document, trims title and summary
|
||||
whitespace, orders records by source position then ID, removes only exactly
|
||||
identical records, and rejects conflicting reused IDs or ranges. Its policy
|
||||
fingerprint identifies this deterministic behavior; the matching invariant
|
||||
validator checks the normalized result in the production chain.
|
||||
|
||||
## Output Encoder
|
||||
|
||||
### `internal/modules/generic/output/json`
|
||||
@@ -353,6 +417,12 @@ safe logical names, pretty-prints JSON payloads, and assembles the logical index
|
||||
manifest, rejected-result, warning, and lane files. Invalid JSON, unsupported
|
||||
media types, unsafe names, and sanitized-name collisions are errors.
|
||||
|
||||
Its strict `include_chunk_map` option is disabled by default. When enabled, it
|
||||
validates the framework-supplied accepted chunk map through its codec and adds
|
||||
the pipeline-wide `chunk-map.json` plus its index descriptor; it does not treat
|
||||
the map as a lane payload. The external shape is owned by the
|
||||
[Accepted Chunk Map contract](../integrations/chunk-map.md).
|
||||
|
||||
The encoder returns logical files only. The CLI places them on disk, and the
|
||||
[JSON output contract](../integrations/json-output.md) defines their external
|
||||
paths and schemas.
|
||||
@@ -428,6 +498,30 @@ expose local policy fingerprints. In the registered defaults, JSON syntax runs
|
||||
first; combat shape, normalized invariants when applicable, and source-reference
|
||||
validation precede JSON Schema validation; warning-only relatedness runs last.
|
||||
|
||||
## D&D NPC Interaction Validators
|
||||
|
||||
Interaction shape validation owns the required list, registry name, supported
|
||||
kind, and non-empty source-reference collection. Registry validation checks
|
||||
exact membership in the required immutable NPC registry. Source-reference and
|
||||
relatedness validation use the current transcript only; malformed candidates
|
||||
are deferred by later validators and produce no relatedness warning. The
|
||||
normalized-invariants validator owns canonical registry names, source-reference
|
||||
order, chronology, and exact duplicate identity. The production chains run
|
||||
shape, registry, and source-reference checks before JSON Schema validation;
|
||||
relatedness remains warning-only and last.
|
||||
|
||||
## D&D Scene Description Validators
|
||||
|
||||
Scene-description shape validation owns the non-empty list, trimmed ID and
|
||||
prose, closed kind, and basic source-reference shape. Extract-stage source
|
||||
validation additionally requires the one record to attach exactly to the
|
||||
current accepted chunk; later source validation checks source membership.
|
||||
Relatedness checks the title and summary independently against only their cited
|
||||
transcript range and emits bounded advisory warnings. The normalized-invariants
|
||||
validator owns ordering, exact duplicate elimination, and conflicting ID or
|
||||
range detection. The production chains run shape and source-reference checks
|
||||
before JSON Schema validation; the warning-only relatedness check is last.
|
||||
|
||||
## Production Registration
|
||||
|
||||
Production composition occurs through family registrars. The CLI allocates one
|
||||
@@ -436,7 +530,7 @@ complete framework registry set and one LLM asset registry. It invokes
|
||||
`internal/modules/seriatim/register`, and `internal/modules/dnd/register` in
|
||||
that order, then exposes the matching catalog for resolution. The generic and
|
||||
Seriatim registrars own their production leaf registrations. The D&D registrar
|
||||
owns D&D leaf registrations, typed spell, NPC, and combat default-validator
|
||||
owns D&D leaf registrations, typed spell, NPC, combat, interaction, and scene-description default-validator
|
||||
chains, typed append-order specializations, and D&D prompt/schema asset
|
||||
collection. Its registration helpers group module, validator, prompt-asset, and
|
||||
chain composition while retaining artifact-specific merge and clone behavior in
|
||||
|
||||
@@ -50,6 +50,7 @@ required normalized artifacts have crossed the typed handoff.
|
||||
| `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. |
|
||||
| `internal/framework/checkpoint` | Root-based checkpoint loading, recording, identity, and payload serialization. |
|
||||
| `internal/framework/chunkplan` | Source-addressed chunk-plan filesystem storage, envelope validation, and atomic publication. |
|
||||
| `internal/framework/chunkmap` | Strict durable accepted chunk-map construction, schema, validation, cloning, and serialization. |
|
||||
| `internal/framework/debug` | Root-based framework and LLM debug recording. |
|
||||
|
||||
Framework contracts provide typed artifact, provenance-wrapper, chunk-validator,
|
||||
@@ -67,8 +68,10 @@ run-local construction closures. Preparation injects shared dependencies and
|
||||
constructs input, chunk, validators, ordered lanes, and output before source
|
||||
parsing. Production modules use strict construction-time option decoding, and
|
||||
LLM-backed modules retain the injected shared client. The D&D family registers
|
||||
the canonical `dnd/spell-list`, `dnd/npc-list`, and `dnd/combat-turn-list`
|
||||
codecs, typed spell, NPC, and combat extractors and normalizers, validators,
|
||||
the canonical `dnd/spell-list`, `dnd/npc-list`, `dnd/combat-turn-list`,
|
||||
`dnd/npc-interaction-list`, and `dnd/scene-description-list` codecs, typed
|
||||
spell, NPC, combat, interaction, and scene-description extractors and
|
||||
normalizers, validators,
|
||||
plus kind-specific generic merge strategies; generic JSON validators use the
|
||||
serialized-validation contract. The runner executes lanes through
|
||||
private exact-type-checked closures, coordinates extract results independently
|
||||
@@ -87,15 +90,24 @@ Configuration. The implemented module packages are:
|
||||
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
|
||||
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
|
||||
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
|
||||
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, and combat-turn artifact types. |
|
||||
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, combat-turn, NPC-interaction-list, and scene-description-list artifact types. |
|
||||
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
|
||||
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
|
||||
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |
|
||||
| `internal/modules/dnd/codec/npcinteractions` | Strictly decodes and stably encodes the durable D&D NPC-interaction-list representation. |
|
||||
| `internal/modules/dnd/codec/scenedescriptions` | Strictly decodes and stably encodes the durable D&D scene-description-list representation. |
|
||||
| `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. |
|
||||
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
|
||||
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
|
||||
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
|
||||
| `internal/modules/dnd/extract/scenedescriptions` | Maps one private scene description to the current accepted chunk's ID and exact range. |
|
||||
| `internal/modules/dnd/npcinteractions` | Owns canonical source-reference ordering, occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
|
||||
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
|
||||
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
|
||||
| `internal/modules/dnd/normalize/scenedescriptions` | Trims, source-orders, and removes only exactly identical scene descriptions while rejecting ID and range conflicts. |
|
||||
| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat chains. |
|
||||
| `internal/modules/dnd/validate/npcinteractions` | Provides deterministic shape, registry, source-reference, source-relatedness, and normalized-invariant validation for interaction chains. |
|
||||
| `internal/modules/dnd/validate/scenedescriptions` | Provides deterministic shape, exact extraction attachment, source-relatedness, and normalized-invariant validation for scene-description chains. |
|
||||
| `internal/modules/dnd/npcs/registry` | Resolves validated normalized NPC references into immutable grounding data and exact identity lookup. |
|
||||
| `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. |
|
||||
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
|
||||
@@ -103,7 +115,7 @@ Configuration. The implemented module packages are:
|
||||
| `internal/modules/generic/normalize/noop` | Preserves accepted merged output. |
|
||||
| `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. |
|
||||
| `internal/modules/dnd/normalize/npcs` | Consolidates NPC records deterministically by canonical name, unions exact evidence, and reports bounded warnings. |
|
||||
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. |
|
||||
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, rejections, and an explicitly enabled accepted chunk map as logical JSON files. |
|
||||
|
||||
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
|
||||
reference declarations, prompt input assembly, source-unit reference helpers,
|
||||
@@ -116,14 +128,16 @@ The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
|
||||
External references are strictly decoded and identity-validated during
|
||||
preparation; generated references are decoded and identity-validated at the
|
||||
ordered step handoff. Both paths retain canonical registry JSON for provenance
|
||||
and emit a names-only projection to operation-time spell and combat prompts.
|
||||
Combat normalization uses the canonical registry for exact actor lookup. The
|
||||
and emit a names-only projection to operation-time spell, combat, and
|
||||
interaction prompts. Combat and interaction normalization use the canonical
|
||||
registry for exact name lookup. The
|
||||
framework records generated identity and bounded producer provenance, while
|
||||
the raw external reference remains independently tracked by pipeline
|
||||
provenance. An absent registry is represented only by the empty prompt value
|
||||
`{"npcs":[]}`. Spell
|
||||
and combat consumers use this shared boundary without changing their public
|
||||
module contracts.
|
||||
module contracts. Interaction consumers require it and retain only current
|
||||
transcript references as durable evidence.
|
||||
|
||||
Generic validators under `internal/modules/generic/validate` provide
|
||||
unconditional test decisions, JSON syntax validation, and JSON Schema
|
||||
|
||||
@@ -119,10 +119,10 @@ extractor's artifact kind, so variants under one reusable key may declare
|
||||
different capabilities and reference slots. Kind-neutral registry inspection
|
||||
selects the first registered artifact kind in sorted order.
|
||||
|
||||
Production composition registers the D&D spell-list codec and typed extractor,
|
||||
matching typed merge, normalize, and semantic-validator variants, and
|
||||
serialized JSON validators. Every artifact lane resolves through the typed
|
||||
registries and a matching codec.
|
||||
Production composition registers the D&D spell-list, NPC-list, combat-turn-list,
|
||||
NPC-interaction-list, and scene-description-list codecs and typed lane
|
||||
variants, plus serialized JSON validators. Every artifact lane resolves through
|
||||
the typed registries and a matching codec.
|
||||
|
||||
A `ModuleSpec` declares its stage plus required and provided capabilities.
|
||||
Chunk, extract, merge, and normalize specs may also declare reference slots.
|
||||
@@ -208,15 +208,18 @@ The runner:
|
||||
3. selects a stored plan or executes the configured chunker's `Plan` operation;
|
||||
4. canonicalizes and materializes the plan, then validates the resulting
|
||||
chunks;
|
||||
5. executes each resolved step in configuration order. For one step, it
|
||||
5. builds the framework-owned accepted chunk map from the accepted source,
|
||||
logical plan, and exact materialized chunks, then supplies it to the output
|
||||
request independently of output-module options;
|
||||
6. executes each resolved step in configuration order. For one step, it
|
||||
dispatches extract jobs in source-chunk then resolved-lane order, starts a
|
||||
bounded lane continuation when all extracts for that lane are terminal, and
|
||||
waits for every lane to become terminal;
|
||||
6. encodes and validates each accepted normalized producer artifact, then
|
||||
7. encodes and validates each accepted normalized producer artifact, then
|
||||
builds the immutable generated reference sets for the next step;
|
||||
7. invokes the prepared output encoder only after every step succeeds and
|
||||
8. invokes the prepared output encoder only after every step succeeds and
|
||||
validates its logical file results;
|
||||
8. returns the assembled manifest, outcomes, warnings, and files.
|
||||
9. returns the assembled manifest, outcomes, warnings, and files.
|
||||
|
||||
Within each artifact lane, it reuses the prepared extractor, merger, normalizer,
|
||||
and validators while performing these transitions:
|
||||
@@ -265,6 +268,14 @@ currently requested chunker and the effective plan producer. Cache state and
|
||||
paths are configured and operated outside the runner; see
|
||||
[Configuration](../config.md#state-surfaces) and [Operations](../operations.md).
|
||||
|
||||
For an accepted plan, the runner also constructs the strict framework-owned
|
||||
[Accepted Chunk Map](../integrations/chunk-map.md) before lane execution. It
|
||||
uses the current resolved chunker as `requested_chunker` and the stored or
|
||||
generated record as `producer`, preserving that distinction on reuse. Chunk
|
||||
rejection supplies no map; later lane rejection does not discard it. Output
|
||||
encoders receive a defensively owned serialized value and may explicitly
|
||||
ignore it.
|
||||
|
||||
The extract job channel has the same capacity as the effective extract worker
|
||||
count, so dispatch applies backpressure. A fixed continuation executor prevents
|
||||
ready or checkpoint-reused lanes from creating one goroutine each. Workers and
|
||||
|
||||
@@ -17,6 +17,11 @@ debug roots.
|
||||
The pipeline runner returns logical output files. After validating every
|
||||
logical name, the CLI exclusively creates the run directory beneath the
|
||||
selected output root and performs confined, atomic file writes within it.
|
||||
The runner supplies an accepted chunk map as an optional, defensively owned
|
||||
output-request artifact. The JSON encoder alone decides whether its explicit
|
||||
option writes the map and optional index descriptor; neither the map payload
|
||||
nor its annotations are copied into the run manifest. The durable fields are
|
||||
owned by the [Accepted Chunk Map contract](../integrations/chunk-map.md).
|
||||
|
||||
`internal/framework/chunkplan` owns source-addressed plan storage, validation,
|
||||
and atomic publication. Its store is constructed only when the selected mode is
|
||||
|
||||
@@ -37,6 +37,12 @@ automatically removes output. The
|
||||
[JSON output contract](integrations/json-output.md) owns the logical file
|
||||
names, schemas, and media types inside a run directory.
|
||||
|
||||
An enabled JSON `include_chunk_map` option adds an accepted chunk map to durable
|
||||
output. Its annotations may contain source- or model-derived data, so retain
|
||||
and protect it like lane output. The map is opt-in and does not alter existing
|
||||
bundles; its payload exclusions are defined in the
|
||||
[Accepted Chunk Map contract](integrations/chunk-map.md).
|
||||
|
||||
Remove an output run directory only after its consumer data is no longer
|
||||
needed. This is data deletion, not cache cleanup.
|
||||
|
||||
|
||||
766
docs/roadmap/audit.md
Normal file
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
|
||||
iterative human evaluation aid, not a deterministic correctness gate.
|
||||
|
||||
### Expand Sequential D&D Artifacts
|
||||
### Use D&D Scene Chunking
|
||||
|
||||
- Add narrative extraction for scene summaries, party actions, and NPCs
|
||||
encountered when that output proves useful beyond the dedicated NPC artifact.
|
||||
- Use ordered pipeline steps when a later artifact needs an accepted earlier
|
||||
artifact as context. Keep independent lanes in the same step and do not
|
||||
introduce a general DAG or concurrent cross-lane reconciliation model.
|
||||
|
||||
### Improve D&D Scene Classification
|
||||
|
||||
- Extend scene annotations with classifications that downstream extractors can
|
||||
use, including reliable combat and narrative indicators.
|
||||
- Strengthen the scene prompt so every scene containing combat turns is marked
|
||||
as combat, and add validation capable of detecting missing or inconsistent
|
||||
combat classifications.
|
||||
- Allow the combat extractor to no-op for chunks that are not classified as
|
||||
combat, avoiding unnecessary model calls where practical.
|
||||
- Allow a narrative extractor to select the corresponding scene classification
|
||||
rather than processing every chunk indiscriminately.
|
||||
- Allow the combat extractor to no-op for chunks classified as non-combat only
|
||||
after the scene-description artifact can be supplied through an explicit
|
||||
ordered dependency. Do not make generic chunk materialization depend on a D&D
|
||||
classification.
|
||||
- Use ordered pipeline steps whenever a later artifact needs an accepted
|
||||
earlier artifact as context. Keep independent lanes in the same step and do
|
||||
not introduce a general DAG or concurrent cross-lane reconciliation model.
|
||||
- Reassess whether one shared scene plan provides enough context for NPC,
|
||||
spell, combat, and narrative pipelines after these extractors have real-world
|
||||
usage. Add more complex chunking only in response to demonstrated failures.
|
||||
spell, combat, interaction, and scene-description lanes after real-world use.
|
||||
Add more complex chunking only in response to demonstrated failures.
|
||||
|
||||
## Shared Normalization And Quality Work
|
||||
|
||||
|
||||
@@ -1,373 +1,425 @@
|
||||
# Minimal D&D Extraction Contracts Implementation Plan
|
||||
# D&D Extraction Module Audit Execution Plan
|
||||
|
||||
**Status:** Implemented
|
||||
Status: Ready for audit execution
|
||||
|
||||
## Objective
|
||||
This document divides the audit defined in
|
||||
[D&D Extraction Module Refactoring Audit Strategy](audit.md) into five prompts.
|
||||
Execute the stages in order. Each stage is a read-only code audit: it may update
|
||||
`docs/roadmap/audit.md`, but it must not change production code, tests, prompts,
|
||||
schemas, examples, current-behavior documentation, or configuration.
|
||||
|
||||
Implement the durable contract redesign defined by
|
||||
[Minimal D&D Extraction Contracts](minimal-dnd-extraction-contracts.md) and
|
||||
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md).
|
||||
The result is a coordinated in-place redesign of NPC, spell-cast, and combat-turn
|
||||
extraction. Each lane must emit only its narrow, evidence-grounded facts; no
|
||||
removed rich-schema field may survive as an optional field, placeholder, compatibility
|
||||
shim, or deterministic synthesis.
|
||||
The word "implementation" in this filename refers to implementing the audit
|
||||
strategy, not implementing refactors.
|
||||
|
||||
This plan deliberately starts with NPCs because the NPC registry is a generated
|
||||
reference consumed by spell and combat extraction. Spell and combat then change
|
||||
independently, followed by one repository-wide contract and documentation
|
||||
pass.
|
||||
## Global Instructions
|
||||
|
||||
## Fixed Decisions And Guardrails
|
||||
Every stage must:
|
||||
|
||||
The implementing agent must treat these as decisions, not open design choices:
|
||||
1. Read `docs/roadmap/audit.md` in full before beginning.
|
||||
2. Follow `AGENTS.md`, `docs/development.md`, and all policies under
|
||||
`docs/policy/`.
|
||||
3. Prefer the codebase knowledge graph for code discovery and call tracing.
|
||||
Use direct file and text searches for prompt assets, JSON Schemas,
|
||||
configuration, documentation, and exact string comparison.
|
||||
4. Inspect all five extractors where the stage calls for comparison:
|
||||
`spells`, `npcs`, `combatturns`, `npcinteractions`, and
|
||||
`scenedescriptions`.
|
||||
5. Cite exact files and symbols for every observation written to `audit.md`.
|
||||
6. Distinguish confirmed facts, provisional interpretations, intentional
|
||||
specialization, and actionable findings.
|
||||
7. Avoid treating visual similarity, file count, or line count as sufficient
|
||||
evidence for sharing.
|
||||
8. Preserve findings from earlier stages unless new evidence disproves them.
|
||||
When revising an earlier conclusion, edit it in place and record the reason;
|
||||
do not append contradictory conclusions.
|
||||
9. Keep implementation out of scope. Do not modify code to test whether a
|
||||
proposed refactor is convenient.
|
||||
10. Leave the repository otherwise unchanged and report any pre-existing dirty
|
||||
worktree state before proceeding.
|
||||
|
||||
- Keep the artifact kinds `dnd/npc-list`, `dnd/spell-list`, and
|
||||
`dnd/combat-turn-list`, their module keys and capabilities, their media types,
|
||||
and their prompt IDs stable.
|
||||
- Retain exactly the existing v1 schema keys, IDs, names, versions, filenames,
|
||||
prompt IDs, and prompt versions listed in the feature roadmap. Change their
|
||||
unpublished shapes and content in place; do not harmonize the private spell
|
||||
identity as part of this work.
|
||||
- Do not add v2 assets, runtime version negotiation, migration code, dual-write
|
||||
behavior, a second module registration, or compatibility fixtures for the
|
||||
superseded pre-release shapes. Existing fixtures should be rewritten or
|
||||
deleted according to whether they still protect current behavior.
|
||||
- Require the top-level list field and every record field. A list may be empty,
|
||||
but every returned record must have at least one source reference. Use strict
|
||||
JSON objects with unknown fields rejected and no nullable or optional legacy
|
||||
fields.
|
||||
- Keep private source references limited to `start_unit_id` and `end_unit_id`.
|
||||
Mapping assigns the current source document ID; campaign references and an
|
||||
NPC registry never become event evidence.
|
||||
- Keep the shared prompt ordering policy: stable shared instructions first,
|
||||
stable campaign and generated references next, module task material after
|
||||
those references, and the chunk-variable transcript last.
|
||||
- Preserve the existing production validator-chain order and the rule that a
|
||||
configured chain is authoritative. Remove or simplify validators; do not
|
||||
silently reorder chains.
|
||||
- Preserve the existing NPC ID derivation algorithm and comparison/display
|
||||
normalization for canonical names. Retain existing semantic-policy
|
||||
identifiers as well as schema and prompt versions; pre-redesign development
|
||||
state is disposable. Unchanged canonical names must not receive new IDs.
|
||||
- Use deterministic code only for display normalization, known catalog or NPC
|
||||
canonicalization, application-owned IDs, evidence canonicalization, ordering,
|
||||
and exact duplicate collapse. Do not introduce fuzzy matching or inferred
|
||||
enrichment.
|
||||
- Follow the testing policy: protect schemas and behavior at their owning
|
||||
boundaries, delete obsolete tests, and avoid tests that snapshot prompt prose
|
||||
or detect exact shared-prefix length. No live or paid model call is a stage
|
||||
completion requirement.
|
||||
- Each stage must leave the repository compiling and `go test ./...` passing.
|
||||
Update all affected fixtures and callers within the stage that changes a
|
||||
public Go type; do not leave an intentionally broken intermediate commit.
|
||||
## Working Results Structure
|
||||
|
||||
## Stage 1: Cut Over The NPC Contract And Name Projection
|
||||
Stages 1 through 4 maintain one working area at the end of `audit.md`:
|
||||
|
||||
This stage establishes the redesigned generated reference on which later stages
|
||||
depend.
|
||||
```markdown
|
||||
## Audit Results
|
||||
|
||||
### 1.1 Replace the durable and private data shapes
|
||||
Status: In progress
|
||||
|
||||
- Reduce `dnd.NPC` in `internal/modules/dnd/types.go` to exactly `ID`, `Name`,
|
||||
and `SourceRefs`. Delete `NPCRelationship` and every alias, description, and
|
||||
relationship field or helper that becomes unused.
|
||||
- Rewrite the existing strict durable schema `dnd_npcs.v1.json` with required
|
||||
top-level `npcs`, and records containing only required `id`, `name`, and
|
||||
`source_refs`. Keep the NPC codec at schema version `v1`, name
|
||||
`notarius_dnd_npcs_v1`, and its existing schema ID and media type.
|
||||
- Rewrite the strict private schema `dnd_npcs_llm.v1.json`. Its records contain
|
||||
only required `name` and model-facing source ranges. Keep the extractor schema
|
||||
identity and prompt ID/version unchanged.
|
||||
- Reduce the private response DTO and mapping accordingly. Preserve candidate
|
||||
data at the mapping boundary so deterministic validators, rather than mapping
|
||||
defaults, reject blank names or invalid ranges. Assign the current source ID
|
||||
and derive the NPC ID in application code.
|
||||
- Rewrite the NPC task and instruction assets to ask only for individually
|
||||
identifiable NPC names and supporting transcript ranges. Explicitly exclude
|
||||
anonymous groups, generic roles, invented labels, descriptions, aliases, and
|
||||
relationships. Keep transcript material last in the manifest.
|
||||
### Baseline And Module Matrix
|
||||
### Divergence Register
|
||||
### Prompt, Schema, And LLM Review
|
||||
### Extraction And Canonicalization Review
|
||||
### Duplication Register
|
||||
### Contextual Architecture And Ownership Review
|
||||
### Candidate Decisions
|
||||
```
|
||||
|
||||
### 1.2 Simplify NPC identity, normalization, and validation
|
||||
Create headings when their owning stage begins. Later stages may refine earlier
|
||||
sections but must not duplicate them. Use compact tables where they improve
|
||||
five-way comparison. Keep detailed evidence in the relevant review section and
|
||||
keep the divergence and duplication registers concise.
|
||||
|
||||
- Keep the identity policy at `dnd.npcs.identity.v1`. Retain display
|
||||
normalization, comparison keys, ID syntax, and deterministic ID derivation.
|
||||
Validate nonblank canonical names, exact ID/name agreement, duplicate
|
||||
canonical names, and duplicate IDs. Delete alias validation and alias-specific
|
||||
issue locations/codes.
|
||||
- Keep NPC normalization at `dnd.npcs.normalize.v1`. Normalize the retained
|
||||
display name, derive its ID, canonicalize and deduplicate source references,
|
||||
consolidate records only by the canonical-name comparison key, preserve the
|
||||
first stable record, and union exact evidence. Remove alias promotion,
|
||||
relationship merge/rewrites, and their warning codes.
|
||||
- Keep the existing NPC shape and source-relatedness policy identifiers. The
|
||||
shape validator checks only list presence, nonblank `id` and `name`, and
|
||||
nonempty source references; source-relatedness grounds a record only through
|
||||
its retained name. The existing source-ref validator remains the owner of
|
||||
range validity.
|
||||
- Keep the default validation order domain-first, then JSON Schema, then the
|
||||
advisory relatedness validator. Simplify only the validator implementations
|
||||
and selections whose owned behavior changed.
|
||||
Each divergence-register entry must have:
|
||||
|
||||
### 1.3 Separate durable registry provenance from model input
|
||||
- a stable identifier such as `D-01`;
|
||||
- affected modules;
|
||||
- observed difference;
|
||||
- provisional classification from the audit strategy;
|
||||
- evidence links;
|
||||
- impact or reason it may be harmless; and
|
||||
- status: open, confirmed, intentional, rejected, or superseded.
|
||||
|
||||
- Continue to canonicalize and retain the complete redesigned NPC artifact for
|
||||
registry validation, cache identity, manifests, and provenance.
|
||||
`Registry.Digest()` remains the digest of that complete canonical artifact
|
||||
when bound and remains empty when no registry was supplied.
|
||||
- Build a second structural JSON projection for model grounding with the exact
|
||||
shape `{"npcs":[{"name":"Mira Thorn"}]}` in normalized registry order. It
|
||||
contains names only: no IDs, source references, origin URI, aliases, or other
|
||||
provenance. Generate it with typed values and `json.Marshal`, not string
|
||||
concatenation. The empty projection is exactly `{"npcs":[]}`.
|
||||
- Add an explicit projection digest accessor. The digest is SHA-256 over the
|
||||
exact projected bytes, including for an absent or empty registry, and
|
||||
`PromptInput().Digest` must equal it. Keep full-artifact and projection digests
|
||||
distinct even when their content happens to coincide.
|
||||
- Index `Registry.Lookup` by canonical name only. Retain immutable return values
|
||||
and defensive copies. Remove alias indexing and alias-aware comments.
|
||||
- In spell extraction, combat extraction, and combat normalization, use the NPC
|
||||
name-projection digest for the component-local checkpoint fingerprint because
|
||||
it exactly describes the names that affect those operations. Keep the full
|
||||
registry digest and count in manifest metadata for provenance. Framework-owned
|
||||
generated-reference fingerprints may still invalidate a run when any upstream
|
||||
artifact byte changes; do not broaden this stage into a framework cache
|
||||
redesign.
|
||||
Each duplication-register entry must have:
|
||||
|
||||
### 1.4 Update owners, consumers, and tests
|
||||
- a stable identifier such as `R-01`;
|
||||
- participating modules;
|
||||
- repeated responsibility;
|
||||
- exact, structural, or policy-duplication classification;
|
||||
- meaningful differences;
|
||||
- candidate owner, if any;
|
||||
- evidence links; and
|
||||
- provisional outcome: extract, harmonize without sharing, retain separately,
|
||||
or defer.
|
||||
|
||||
- Update NPC codec, schema, extractor, identity, validator, normalizer, registry,
|
||||
registration, pipeline-integration, and CLI fixtures to the new shape. Adapt
|
||||
spell and combat tests that construct `dnd.NPC` values so the repository
|
||||
remains buildable, but do not change their own artifact contracts yet.
|
||||
- Add or rewrite focused tests for strict schema acceptance/rejection,
|
||||
codec round trips, unchanged ID derivation for known names, name-only
|
||||
consolidation, evidence union, registry immutability, and canonical-name-only
|
||||
lookup. Rewrite the existing fixture for the minimal current contract; do not
|
||||
retain the superseded rich fixture solely to test backwards incompatibility.
|
||||
- Test the projection as a data contract: it contains only ordered names,
|
||||
equivalent normalized registries produce identical bytes and digest,
|
||||
evidence/ID-only changes do not change the projection digest, and name/order
|
||||
changes do. This is not authorization to snapshot assembled prompt prose or
|
||||
prefix lengths.
|
||||
- Update `docs/integrations/dnd-npc-artifacts.md` to own the redesigned v1
|
||||
durable schema. Update `docs/internal/modules.md` and
|
||||
`docs/internal/llm.md` only for current NPC behavior that lands in this stage.
|
||||
Do not assign final severity until Stage 5 has traced ownership and evaluated
|
||||
impact.
|
||||
|
||||
### Stage 1 completion criteria
|
||||
## Stage 1: Inventory And Convention Matrix
|
||||
|
||||
- NPC durable and private schemas expose no removed enrichment fields.
|
||||
- The generated NPC prompt input contains names only while manifests retain
|
||||
full registry provenance.
|
||||
- Spell and combat consumers accept generated or external registries in the
|
||||
redesigned v1 shape; rich pre-redesign registry JSON fails strict decoding.
|
||||
- Focused NPC, spell-wiring, combat-wiring, integration, and CLI tests pass, and
|
||||
`go test ./...` passes.
|
||||
### Objective
|
||||
|
||||
## Stage 2: Cut Over Spell-Cast Extraction
|
||||
Establish the complete, factual five-module baseline and identify convention
|
||||
differences without yet recommending shared abstractions.
|
||||
|
||||
### 2.1 Replace the spell contract and prompt
|
||||
### Required review
|
||||
|
||||
- Reduce `dnd.SpellCast` to exactly `Caster`, `Spell`, and `SourceRefs`.
|
||||
- Rewrite `dnd_spells.v1.json` in place. Keep the durable codec at version v1,
|
||||
name `notarius_dnd_spells_v1`, and its existing schema ID. Its strict record
|
||||
contains only required `caster`, `spell`, and `source_refs`.
|
||||
- Rewrite `dnd_spells_llm.v1.json` with the same logical fields and model-facing
|
||||
ranges. Keep the existing private key, ID, name, prompt version, and schema
|
||||
path unchanged.
|
||||
- Reduce the private DTO, canonicalization, and mapping to the retained fields.
|
||||
Continue assigning current source IDs, stable-ordering candidates by evidence,
|
||||
and preserving semantically invalid candidates for deterministic validation.
|
||||
- Rewrite spell task and instruction assets around the narrow casting-event
|
||||
boundary. Retain the spell catalog and name-only NPC projection as
|
||||
disambiguation inputs, never evidence. Remove effect, outcome, and narrative
|
||||
duties and remove the `common-dnd-immediate-resolution.md` message from the
|
||||
spell manifest and spell asset registration. Do not delete the shared file in
|
||||
this stage because combat still uses it.
|
||||
For every extractor, inspect:
|
||||
|
||||
### 2.2 Simplify spell policies
|
||||
- production and test file inventory;
|
||||
- module key, artifact kind, capabilities, and execution class;
|
||||
- constructor inputs and retained prepared state;
|
||||
- option decoding and unknown-option behavior;
|
||||
- `ModuleSpec`, registration builder, and reference slots;
|
||||
- manifest metadata and checkpoint fingerprint providers;
|
||||
- exported versus package-private surface;
|
||||
- embedded asset registration;
|
||||
- principal extractor entry point and result type; and
|
||||
- package-local test organization.
|
||||
|
||||
- Keep the spell shape policy identifier unchanged and validate only list
|
||||
presence, nonblank caster/spell, and nonempty source references.
|
||||
- Keep catalog validation, source-reference validation, and source-relatedness
|
||||
behavior and policy identities unchanged where their actual semantics are
|
||||
already limited to spell name, caster, and evidence.
|
||||
- Simplify normalization and duplicate comparison to caster, catalog-canonical
|
||||
spell name, and the complete valid evidence set. Remove all prose selection,
|
||||
copying, fixture fields, and assertions. Preserve catalog fingerprints and
|
||||
metadata.
|
||||
- Keep the exact inclusion rule from the feature roadmap: an actual casting or
|
||||
unambiguous declared attempt is included; mentions, plans, rules discussion,
|
||||
and catalog matches are not.
|
||||
Trace registration into production composition far enough to confirm that the
|
||||
declared contract is the one actually selected. Inspect neighboring packages
|
||||
only as required to verify an identity or ownership fact.
|
||||
|
||||
### 2.3 Update owners and tests
|
||||
### Write to `audit.md`
|
||||
|
||||
- Update spell codec, private schema, extractor, validators, normalizer, merge
|
||||
and registration tests, pipeline integration, CLI output fixtures, and any
|
||||
maintained examples to the minimal shape. Rewrite or delete rich-schema
|
||||
fixtures rather than retaining them as compatibility cases.
|
||||
- Replace tests of effects and narrative descriptions with focused tests of the
|
||||
retained contract: strict unknown-field rejection, codec round trips, current
|
||||
source-ID assignment, catalog canonicalization, evidence ordering, duplicate
|
||||
collapse, and NPC name-projection wiring.
|
||||
- Update `docs/integrations/dnd-spell-artifacts.md` as the canonical redesigned
|
||||
v1 contract and update current internal module/LLM documentation for the
|
||||
smaller prompt and response. Do not duplicate the spell catalog contract
|
||||
owned by its existing integration document.
|
||||
Create `## Audit Results`, mark it `Status: In progress`, and add:
|
||||
|
||||
### Stage 2 completion criteria
|
||||
1. `### Baseline And Module Matrix`
|
||||
- one row per module;
|
||||
- columns for every comparison dimension in the audit strategy;
|
||||
- concise facts with links rather than judgments; and
|
||||
- an explicit note where a dimension is not applicable.
|
||||
2. `### Divergence Register`
|
||||
- record every observed organizational, naming, construction, registration,
|
||||
provenance, or test-layout difference;
|
||||
- classify only as required specialization, permitted variation, convention
|
||||
drift, architectural divergence, or undetermined; and
|
||||
- do not propose helper extraction in this stage.
|
||||
|
||||
- No production spell type, schema, prompt, validator, normalizer, fixture, or
|
||||
documentation contract refers to effect or narrative description.
|
||||
- Spell prompt/schema identities remain exactly their existing v1 values.
|
||||
- Focused spell and pipeline tests pass, and `go test ./...` passes.
|
||||
|
||||
## Stage 3: Cut Over Combat-Turn Extraction
|
||||
|
||||
### 3.1 Replace the combat contract and prompt
|
||||
|
||||
- Reduce `dnd.CombatTurn` to exactly `Actor`, `TurnKind`, and `SourceRefs`.
|
||||
Delete `CombatAction`, `CombatActionCategory`, their constants, and all
|
||||
now-unused helpers. Retain the existing five `CombatTurnKind` values.
|
||||
- Rewrite `dnd_combat_turns.v1.json` in place. Keep the durable codec at version
|
||||
v1, name `notarius_dnd_combat_turns_v1`, and its existing schema ID. Use a
|
||||
strict required record with `actor`, `turn_kind`, and `source_refs` only.
|
||||
- Simplify the durable codec to direct strict encoding/decoding if its custom
|
||||
wire representation exists only to distinguish removed nullable fields.
|
||||
Preserve presence semantics for the top-level list and strict unknown-field
|
||||
rejection.
|
||||
- Rewrite `dnd_combat_turns_llm.v1.json` while retaining its private schema and
|
||||
prompt identities, and reduce the DTO/mapping to actor, turn kind, and
|
||||
model-facing ranges. Keep the semantic mapping policy identifier unchanged.
|
||||
- Rewrite combat task/instruction assets around detecting ordered turns and
|
||||
discrete interrupting events. Remove round, action, target, declaration,
|
||||
resolution, outcome, and summary duties. Remove the shared immediate-
|
||||
resolution message from the combat manifest.
|
||||
- Once both spell and combat manifests no longer use it, delete
|
||||
`common-dnd-immediate-resolution.md` and its shared and module asset
|
||||
registrations. Retain all other shared evidence, identity, reference, NPC,
|
||||
and transcript assets in their cache-friendly order.
|
||||
|
||||
### 3.2 Simplify combat normalization and validation
|
||||
|
||||
- Keep combat normalization at `dnd.combat_turns.normalize.v1`. Continue to
|
||||
display-normalize and registry-canonicalize actors, canonicalize evidence,
|
||||
order records chronologically, and collapse exact duplicates by actor, turn
|
||||
kind, and complete valid evidence. Delete action/target/prose normalization
|
||||
and warning codes.
|
||||
- Keep the invariant policy identifier unchanged. It checks canonical actor
|
||||
display, canonical and chronological evidence, stable event ordering, and
|
||||
absence of duplicate event identities; it performs no nested-action checks.
|
||||
- Keep the shape policy identifier unchanged and validate only list presence,
|
||||
nonblank actor, allowed turn kind, and nonempty source references.
|
||||
- Keep the source-relatedness policy identifier unchanged and compare only the
|
||||
actor against cited transcript material. Remove declaration-token heuristics
|
||||
and their now-unused helpers. Keep source-reference validation unchanged if
|
||||
its semantics did not change.
|
||||
- Simplify merge/clone behavior to copy only retained values and source refs.
|
||||
Preserve stable ordering and defensive ownership.
|
||||
|
||||
### 3.3 Update owners and tests
|
||||
|
||||
- Update codec, private schema, extractor, normalizer, validators, merge,
|
||||
registration, pipeline integration, CLI fixtures, and examples to the minimal
|
||||
shape. Rewrite or delete rich-schema fixtures rather than retaining them as
|
||||
compatibility cases.
|
||||
- Delete tests whose sole policy was round/action/summary handling. Add or
|
||||
rewrite focused tests for strict schemas, enum validation, mapping and
|
||||
source-ID assignment, actor canonicalization through the redesigned NPC
|
||||
registry, chronology, exact duplicate collapse, and invariant validation.
|
||||
- Update `docs/integrations/dnd-combat-turn-artifacts.md` as the canonical
|
||||
redesigned v1 contract and update current internal module/LLM documentation
|
||||
for the implemented behavior.
|
||||
|
||||
### Stage 3 completion criteria
|
||||
|
||||
- No production combat type, prompt, schema, policy, normalizer, fixture, or
|
||||
current documentation refers to rounds, actions, summaries, declarations,
|
||||
targets, resolutions, or action categories.
|
||||
- The immediate-resolution shared asset has no remaining registration or file.
|
||||
- Focused combat and pipeline tests pass, and `go test ./...` passes.
|
||||
|
||||
## Stage 4: Complete The Repository-Wide Cutover
|
||||
|
||||
### 4.1 Audit contract identity and stale surface area
|
||||
|
||||
- Search code, embedded assets, tests, examples, and current documentation for
|
||||
all removed field names and unintended v2 schema/prompt names. Removed fields
|
||||
may remain only in historical ADR or roadmap context; active schema, prompt,
|
||||
fixture, and current-behavior surfaces must describe the minimal v1 contract.
|
||||
- Verify the three artifact registrations still bind their original kinds and
|
||||
exact redesigned Go types through extract, merge, normalize, codec, and
|
||||
validators.
|
||||
- Verify all prompt manifests still point at their v1 private schemas, stable
|
||||
reference material precedes module-variable material, and transcript content
|
||||
remains last. Do not add a change-detector test for prompt message count,
|
||||
prose, shared-prefix content, or prefix length.
|
||||
- Verify pre-redesign development checkpoints naturally miss through changed
|
||||
prompt/schema content and generated-dependency fingerprints. Do not add
|
||||
migration or compatibility handling for disposable pre-release state.
|
||||
|
||||
### 4.2 Exercise representative assembled behavior
|
||||
|
||||
- Keep unit case matrices at their owning schema, validator, normalizer, and
|
||||
registry boundaries. At the assembled-pipeline boundary, retain only
|
||||
representative tests proving each redesigned lane is registered, strict
|
||||
JSON/schema failures are attributed to the correct validator, semantic
|
||||
failures are attributed to the correct domain validator, and generated NPC
|
||||
output is accepted by later spell/combat stages.
|
||||
- Cover an ordered multi-step run in which NPC extraction produces the minimal
|
||||
artifact and spell/combat consume its name projection. Assert that downstream
|
||||
event source references point only to the current transcript and not to NPC
|
||||
registry evidence.
|
||||
- Verify CLI logical output and manifests retain the v1 schema/prompt identities,
|
||||
full NPC registry provenance, and the relevant component fingerprints without
|
||||
exposing prompt bodies or reference payloads.
|
||||
|
||||
### 4.3 Finish documentation and lifecycle state
|
||||
|
||||
- Reconcile the three integration documents, `docs/internal/modules.md`, and
|
||||
`docs/internal/llm.md` with the final code. Correct any stale validator-order
|
||||
descriptions while doing so; current docs must describe the registered order,
|
||||
not preserve an older generic ordering example.
|
||||
- Mark ADR-0009 `Accepted` and the feature roadmap `Implemented` when the code,
|
||||
tests, and current-behavior documentation all land. Remove the completed item
|
||||
from `docs/roadmap/future.md`; retain the feature roadmap and ADR as design
|
||||
rationale unless the repository's normal roadmap-retirement practice calls
|
||||
for moving the completed roadmap later.
|
||||
- Record any human-reviewed rich/minimal model evaluation separately from CI
|
||||
results. Evaluation may motivate later prompt tuning but does not reopen the
|
||||
approved durable minimal field set within this implementation.
|
||||
|
||||
### 4.4 Final verification
|
||||
|
||||
Run, in order:
|
||||
Also add a short baseline-validation note recording:
|
||||
|
||||
```sh
|
||||
gofmt -w <changed Go files>
|
||||
go test ./...
|
||||
go test -count=1 ./...
|
||||
go vet ./...
|
||||
go build ./cmd/notarius
|
||||
go test -race ./internal/modules/dnd/...
|
||||
gofmt -l .
|
||||
git diff --check
|
||||
```
|
||||
|
||||
If the repository-wide race command exposes an unrelated, pre-existing failure,
|
||||
document it with the narrower affected package result; do not weaken or skip
|
||||
ordinary tests for the changed D&D packages.
|
||||
If a command fails, record the exact command, affected package, and concise
|
||||
failure classification. Do not fix it during the audit.
|
||||
|
||||
### Stage 4 completion criteria
|
||||
### Completion gate
|
||||
|
||||
- All three lanes use only their minimal v1 contracts from model response
|
||||
through durable output.
|
||||
- No compatibility shim, removed-field policy, unintended v2 asset, or stale
|
||||
current-behavior documentation remains.
|
||||
- Generated NPC references are name-only for LLM input and remain full-fidelity
|
||||
for durable provenance.
|
||||
- Repository tests, vet, build, race checks for the changed domain, and diff
|
||||
hygiene checks pass.
|
||||
Stage 1 is complete when every matrix cell is populated or marked not
|
||||
applicable, every observed baseline divergence has a stable register entry, and
|
||||
the repository's starting validation state is recorded.
|
||||
|
||||
## Stage 2: Prompts, Schemas, And LLM Boundaries
|
||||
|
||||
### Objective
|
||||
|
||||
Determine whether the five modules consistently present stable context,
|
||||
references, lane instructions, transcripts, and structured-output contracts to
|
||||
the LLM, and identify exact or near-duplicate assets without changing them.
|
||||
|
||||
### Required review
|
||||
|
||||
For every extractor:
|
||||
|
||||
- read the complete prompt manifest and every referenced local and shared
|
||||
asset;
|
||||
- record the exact ordered message sequence, role, input, and cache-control
|
||||
boundary;
|
||||
- compare shared message files by identity and bytes, not by paraphrased
|
||||
meaning;
|
||||
- verify stable-to-variable ordering against `docs/internal/llm.md`;
|
||||
- map declared prompt inputs to module reference slots and generated inputs;
|
||||
- trace optional, required, empty, and generated reference projections;
|
||||
- inspect prompt and schema registration, hashing, and diagnostic redaction;
|
||||
- inspect the complete private response schema for identity, required fields,
|
||||
nullability, strict objects, and semantic constraints;
|
||||
- map schema fields to private DTOs and response mapping; and
|
||||
- compare prompt and schema tests at their behavioral boundaries.
|
||||
|
||||
Treat the scene-description whole-chunk evidence model as a specialization to
|
||||
explain, not a presumption of drift. Likewise, treat catalog and NPC registry
|
||||
inputs as subset-specific responsibilities unless evidence shows inconsistent
|
||||
handling of the same contract.
|
||||
|
||||
### Write to `audit.md`
|
||||
|
||||
Add `### Prompt, Schema, And LLM Review` containing:
|
||||
|
||||
- a five-way prompt-order and cache-boundary table;
|
||||
- a prompt-input and reference-projection table;
|
||||
- a private-schema and DTO ownership table;
|
||||
- exact shared-asset usage;
|
||||
- local assets with identical or near-identical content;
|
||||
- content-safety and diagnostic observations; and
|
||||
- evidence-backed deviations from documented LLM conventions.
|
||||
|
||||
Update the divergence register for confirmed or newly discovered prompt,
|
||||
schema, reference, provenance, and testing differences.
|
||||
|
||||
Create `### Duplication Register` and add prompt-, schema-, metadata-, and
|
||||
asset-related candidates. For each prompt candidate, state whether the text is
|
||||
byte-identical, merely similar, or semantically different. Do not recommend a
|
||||
shared prompt asset unless all intended consumers should receive future edits
|
||||
atomically.
|
||||
|
||||
### Completion gate
|
||||
|
||||
Stage 2 is complete when every prompt message and input is accounted for, every
|
||||
private schema field has an owner, cache-prefix claims are based on exact
|
||||
message identity, and every asset-sharing candidate has a provisional keep or
|
||||
share outcome.
|
||||
|
||||
## Stage 3: Extraction, Evidence, And Canonicalization
|
||||
|
||||
### Objective
|
||||
|
||||
Compare runtime extraction flow and identify repeated algorithms or policy
|
||||
without erasing artifact-specific semantics.
|
||||
|
||||
### Required review
|
||||
|
||||
For every extractor, inspect and trace:
|
||||
|
||||
- request, context, source, chunk, and dependency validation;
|
||||
- construction-time state versus operation-time overrides;
|
||||
- structured request assembly and provider-error wrapping;
|
||||
- response-to-artifact mapping;
|
||||
- source identity attachment and unit-ID resolution;
|
||||
- evidence range validation assumptions;
|
||||
- canonical source-reference ordering and exact deduplication;
|
||||
- artifact ordering and deterministic tie-breakers;
|
||||
- enum or canonical-name handling;
|
||||
- warnings and diagnostics;
|
||||
- cloning and aliasing boundaries; and
|
||||
- focused extractor, model, canonicalization, and malformed-output tests.
|
||||
|
||||
Compare complete algorithms rather than function names alone. For similar
|
||||
canonicalization helpers, identify which parts are:
|
||||
|
||||
- identical source-reference mechanics;
|
||||
- artifact-specific mapping;
|
||||
- artifact-specific ordering policy; or
|
||||
- validation that belongs to a later validator rather than extraction.
|
||||
|
||||
Use call traces and complexity data to inspect repeated scans, allocations,
|
||||
serialization, or high-cognitive-complexity paths. Report performance only when
|
||||
a plausible workload and complexity impact exist.
|
||||
|
||||
### Write to `audit.md`
|
||||
|
||||
Add `### Extraction And Canonicalization Review` containing:
|
||||
|
||||
- a five-way extraction-flow table;
|
||||
- an evidence and ordering-policy table;
|
||||
- clone, mutation, error, and warning observations;
|
||||
- test-ownership comparisons; and
|
||||
- code-quality or performance candidates with concrete impact.
|
||||
|
||||
Update the divergence register, revising earlier provisional classifications
|
||||
where runtime evidence explains or contradicts them.
|
||||
|
||||
Expand the duplication register with request-validation, reference,
|
||||
canonicalization, ordering, deduplication, mapping, error, and test-support
|
||||
candidates. Give each candidate a provisional owner and explicitly identify the
|
||||
artifact-specific code that must remain local.
|
||||
|
||||
### Completion gate
|
||||
|
||||
Stage 3 is complete when the full extraction path of all five modules is
|
||||
accounted for, every similar canonicalization path has been decomposed into
|
||||
shared mechanics versus domain policy, and every runtime duplication candidate
|
||||
has a provisional ownership decision.
|
||||
|
||||
## Stage 4: Contextual Architecture And Ownership Review
|
||||
|
||||
### Objective
|
||||
|
||||
Validate candidate findings against the surrounding D&D and framework
|
||||
architecture, and decide which apparent similarities should actually be shared.
|
||||
|
||||
### Required review
|
||||
|
||||
For every open divergence and duplication candidate:
|
||||
|
||||
- trace callers and consumers;
|
||||
- inspect existing facilities in `internal/modules/dnd/shared`;
|
||||
- inspect focused D&D registry, identity, catalog, codec, normalize, and
|
||||
validate packages as relevant;
|
||||
- verify the typed artifact and reference contracts;
|
||||
- inspect production registration, default validator composition, and
|
||||
checkpoint fingerprint assembly;
|
||||
- verify documentation ownership and current durable contracts; and
|
||||
- check whether a proposed generic helper has a genuine domain-neutral owner.
|
||||
|
||||
Apply the shared-code hierarchy from `audit.md`:
|
||||
|
||||
1. module-owned artifact semantics;
|
||||
2. D&D-wide shared mechanics;
|
||||
3. focused subset-specific D&D packages; and
|
||||
4. framework-owned domain-neutral behavior.
|
||||
|
||||
Reject or defer candidates whose API would require artifact-specific callbacks,
|
||||
type erasure, module-key branching, a broad configuration object, or speculative
|
||||
future consumers.
|
||||
|
||||
### Write to `audit.md`
|
||||
|
||||
Add `### Contextual Architecture And Ownership Review` containing:
|
||||
|
||||
- traced ownership evidence for every open candidate;
|
||||
- dependency-direction and layer-boundary conclusions;
|
||||
- checkpoint, provenance, and reference compatibility conclusions;
|
||||
- documentation or test ownership implications; and
|
||||
- any product-contract questions that cannot be decided as refactors.
|
||||
|
||||
Add `### Candidate Decisions`, with one row per divergence and duplication
|
||||
identifier. Choose exactly one outcome:
|
||||
|
||||
- extract now;
|
||||
- harmonize without sharing;
|
||||
- retain intentionally separate;
|
||||
- reject as harmful abstraction; or
|
||||
- defer pending a named missing requirement or product decision.
|
||||
|
||||
For extract or harmonize outcomes, specify:
|
||||
|
||||
- target owner;
|
||||
- minimal responsibility and proposed API shape;
|
||||
- participating modules;
|
||||
- behavior that remains package-owned;
|
||||
- migration order;
|
||||
- relevant tests; and
|
||||
- principal risks.
|
||||
|
||||
Update every register entry to confirmed, intentional, rejected, superseded, or
|
||||
explicitly deferred. No entry may remain merely open at the end of this stage.
|
||||
|
||||
### Completion gate
|
||||
|
||||
Stage 4 is complete when all candidates have traced ownership and a final
|
||||
keep/share/harmonize/defer decision, no proposed helper violates dependency
|
||||
direction, and all required product decisions are separated from executable
|
||||
refactoring recommendations.
|
||||
|
||||
## Stage 5: Synthesis And Final Audit
|
||||
|
||||
### Objective
|
||||
|
||||
Turn the working evidence into one concise, internally consistent audit that
|
||||
can support roadmap decisions and a later implementation plan.
|
||||
|
||||
### Required work
|
||||
|
||||
1. Re-read the complete strategy and all working audit results.
|
||||
2. Recheck every cited file and symbol against the current working tree.
|
||||
3. Reconcile duplicate, overlapping, or contradictory observations.
|
||||
4. Assign severity only to confirmed actionable findings:
|
||||
- **high:** correctness, security, data integrity, or architectural failure
|
||||
with substantial impact;
|
||||
- **medium:** meaningful drift, duplication, or design weakness likely to
|
||||
cause defects or costly divergence;
|
||||
- **low:** localized maintainability, clarity, test-quality, or
|
||||
documentation issue with limited immediate impact.
|
||||
5. Keep optional improvements separate from findings.
|
||||
6. Confirm intentional specializations and rejected sharing candidates are
|
||||
documented so future work does not repeatedly reopen them without evidence.
|
||||
7. Run the repository-wide validation commands from Stage 1 again and record
|
||||
the final result. Do not modify code in response to failures.
|
||||
|
||||
### Rewrite `audit.md`
|
||||
|
||||
Preserve the strategy sections above `## Audit Results`, but replace the
|
||||
provisional working area with this final structure:
|
||||
|
||||
```markdown
|
||||
## Audit Results
|
||||
|
||||
Status: Complete
|
||||
|
||||
### Executive Conclusion
|
||||
### Final Module Comparison Matrix
|
||||
### Prioritized Findings
|
||||
### Intentional Differences To Preserve
|
||||
### Shared-Code Decisions
|
||||
### Rejected Or Deferred Candidates
|
||||
### Recommended Refactoring Sequence
|
||||
### Validation And Residual Risks
|
||||
```
|
||||
|
||||
Requirements for the final sections:
|
||||
|
||||
- `Executive Conclusion` directly answers the user's three audit questions.
|
||||
- `Final Module Comparison Matrix` remains factual and compact.
|
||||
- `Prioritized Findings` follows the finding standard in `audit.md`, is ordered
|
||||
by severity and impact, and cites exact evidence.
|
||||
- `Intentional Differences To Preserve` explains why harmonization would be
|
||||
incorrect.
|
||||
- `Shared-Code Decisions` records extract and harmonize recommendations,
|
||||
proposed ownership, and why sharing is better than continued separation.
|
||||
- `Rejected Or Deferred Candidates` records superficially attractive
|
||||
abstractions and why they should not be pursued now.
|
||||
- `Recommended Refactoring Sequence` groups accepted recommendations into
|
||||
independently safe, dependency-ordered scopes. It is a sequence, not a
|
||||
decision-complete implementation plan.
|
||||
- `Validation And Residual Risks` records commands, results, limits of the
|
||||
static audit, and any human or model-quality evaluation still needed.
|
||||
|
||||
Remove the working divergence and duplication registers after their evidence
|
||||
has been incorporated into the final sections. Do not leave raw stage notes,
|
||||
provisional severities, or superseded conclusions in the completed audit.
|
||||
|
||||
### Completion gate
|
||||
|
||||
Stage 5 is complete when:
|
||||
|
||||
- the final audit satisfies every deliverable and completion criterion in
|
||||
`audit.md`;
|
||||
- the three user questions receive explicit answers;
|
||||
- every recommendation has evidence, ownership, scope, and rationale;
|
||||
- no finding relies only on similarity or stylistic preference;
|
||||
- validation results are current;
|
||||
- no code or current-behavior files changed; and
|
||||
- `git status --short` shows only the intended `audit.md` audit-result changes
|
||||
plus any pre-existing user changes.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The feature roadmap, ADR, and fixed decisions above define the cutover,
|
||||
pre-release schema, evidence, projection, validation, testing, and documentation
|
||||
policies needed to implement each stage without further product decisions.
|
||||
None. The audit stages, working-document structure, decision rules, and final
|
||||
deliverable are fully specified.
|
||||
|
||||
@@ -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/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
@@ -457,7 +458,7 @@ func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T)
|
||||
t.Fatalf("resolved validator chains = %#v, want normalize chain for %q", effective.ResolvedPipeline.ValidatorChains, spellnormalize.Key)
|
||||
}
|
||||
|
||||
func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
|
||||
func TestProductionSceneRunRecordsAnnotationFreeChunkPlanAndProvenance(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
|
||||
fake := &productionFakeLLMClient{}
|
||||
@@ -481,11 +482,40 @@ func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
|
||||
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
|
||||
t.Fatalf("chunk producer schema metadata = %#v, want %q", got, scenes.ResponseSchemaID)
|
||||
}
|
||||
index := readProductionJSON[productionChunkMapIndex](t, filepath.Join(outputRoot, productionRunID, "index.json"))
|
||||
if index.ChunkMap == nil || index.ChunkMap.ArtifactKind != chunkmap.ArtifactKind || index.ChunkMap.File != "chunk-map.json" || index.ChunkMap.MediaType != chunkmap.MediaType || index.ChunkMap.SchemaID != chunkmap.SchemaID || index.ChunkMap.SchemaName != chunkmap.SchemaName || index.ChunkMap.SchemaVersion != chunkmap.SchemaVersion {
|
||||
t.Fatalf("chunk map index = %#v, want fixed chunk map descriptor", index.ChunkMap)
|
||||
}
|
||||
for _, output := range index.OutputFiles {
|
||||
if output.File == index.ChunkMap.File {
|
||||
t.Fatalf("lane output files = %#v, want no chunk map", index.OutputFiles)
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(outputRoot, productionRunID, index.ChunkMap.File))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chunkMap, err := chunkmap.New().Decode(content)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(chunk map) error = %v", err)
|
||||
}
|
||||
if chunkMap.SourceID != "session-alpha" || chunkMap.SourceDigest != manifest.ChunkPlan.SourceDigest || chunkMap.PlanDigest != manifest.ChunkPlan.PlanDigest || chunkMap.RequestedChunker != scenes.Key || chunkMap.Producer.InputModule != "seriatim" || chunkMap.Producer.ChunkModule != scenes.Key || chunkMap.Producer.LLMProfile != manifest.ChunkPlan.ProducerLLMProfile {
|
||||
t.Fatalf("chunk map identity and producer = %#v, want accepted scene plan provenance", chunkMap)
|
||||
}
|
||||
if len(chunkMap.Chunks) != 1 || chunkMap.Chunks[0].ID != "chunk-000001" || chunkMap.Chunks[0].Index != 0 || chunkMap.Chunks[0].SourceRef.SourceID != "session-alpha" || chunkMap.Chunks[0].SourceRef.StartUnitID != 1 || chunkMap.Chunks[0].SourceRef.EndUnitID != 2 || chunkMap.Chunks[0].UnitCount != 2 {
|
||||
t.Fatalf("chunk map chunks = %#v, want one stable accepted scene range", chunkMap.Chunks)
|
||||
}
|
||||
if len(chunkMap.PlanAnnotations) != 0 {
|
||||
t.Fatalf("chunk map plan annotations = %#v, want none", chunkMap.PlanAnnotations)
|
||||
}
|
||||
if len(chunkMap.Chunks[0].Annotations) != 0 {
|
||||
t.Fatalf("chunk map range annotations = %#v, want none", chunkMap.Chunks[0].Annotations)
|
||||
}
|
||||
warnings := readProductionJSON[struct {
|
||||
Warnings []contracts.Warning `json:"warnings"`
|
||||
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
|
||||
if len(warnings.Warnings) != 1 || warnings.Warnings[0].ReasonCode != "scene_boundary_caveat" {
|
||||
t.Fatalf("warnings = %#v, want one scene boundary warning", warnings.Warnings)
|
||||
if len(warnings.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want none", warnings.Warnings)
|
||||
}
|
||||
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 {
|
||||
t.Fatalf("fake prompt requests = %#v, want one scene and one spell request", fake.requestPrompts())
|
||||
@@ -506,6 +536,9 @@ func maintainedExampleFiles(t *testing.T) []maintainedExample {
|
||||
{name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml"), pipelineIDs: []string{"dnd-session"}},
|
||||
{name: "combat", path: repositoryPath("examples", "dnd-combat-turns.config.yml"), pipelineIDs: []string{"dnd-combat"}},
|
||||
{name: "npc-grounded", path: repositoryPath("examples", "dnd-npc-grounded.config.yml"), pipelineIDs: []string{"dnd-npc-grounded"}},
|
||||
{name: "npc-interactions", path: repositoryPath("examples", "dnd-npc-interactions.config.yml"), pipelineIDs: []string{"dnd-npc-interactions"}},
|
||||
{name: "scene-descriptions", path: repositoryPath("examples", "dnd-scene-descriptions.config.yml"), pipelineIDs: []string{"dnd-scene-descriptions"}},
|
||||
{name: "scene-chunk-map", path: repositoryPath("examples", "dnd-scene-chunk-map.config.yml"), pipelineIDs: []string{"dnd-scene-chunk-map"}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,12 +620,30 @@ pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk: %s
|
||||
output:
|
||||
module: json
|
||||
options:
|
||||
include_chunk_map: true
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
|
||||
}
|
||||
|
||||
type productionChunkMapIndex struct {
|
||||
OutputFiles []struct {
|
||||
File string `json:"file"`
|
||||
} `json:"output_files"`
|
||||
ChunkMap *struct {
|
||||
ArtifactKind contracts.ArtifactKind `json:"artifact_kind"`
|
||||
File string `json:"file"`
|
||||
MediaType string `json:"media_type"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
SchemaName string `json:"schema_name"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
} `json:"chunk_map"`
|
||||
}
|
||||
|
||||
func writeProductionContractConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
@@ -667,7 +718,7 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
|
||||
var content []byte
|
||||
switch req.PromptID {
|
||||
case scenes.PromptID:
|
||||
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2,"short_title":"Opening scene","primary_mode":"Narrative","main_participants":["Aria"],"summary":"The session opens.","boundary_note":"The opening covers the available transcript.","boundary_confidence":"High"}],"boundary_caveats":["The opening boundary is inferred from the short transcript."]}`)
|
||||
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2}]}`)
|
||||
case spells.PromptID:
|
||||
if client.spellResponse != "" {
|
||||
content = []byte(client.spellResponse)
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
const stateTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
const stateTestDigest = "sha256:e511d8906649b78eb639b11215fa57a9652a1a64f4aefa3ed68320dbda46f439"
|
||||
|
||||
func TestRunStateSurfaceMatrix(t *testing.T) {
|
||||
for _, debug := range []bool{false, true} {
|
||||
@@ -857,7 +857,13 @@ type stateTestInput struct{}
|
||||
|
||||
func (stateTestInput) Key() string { return "test/input" }
|
||||
func (stateTestInput) Parse(_ context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
|
||||
return &source.SourceDocument{ID: "source", Kind: "text", Format: "text/plain", Digest: stateTestDigest, Units: []source.SourceUnit{{ID: 1, Kind: "text", Text: string(req.Raw), Ref: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}}}}, nil
|
||||
doc := &source.SourceDocument{ID: "source", Kind: "text", Format: "text/plain", Units: []source.SourceUnit{{ID: 1, Kind: "text", Text: string(req.Raw), Ref: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}}}}
|
||||
digest, err := source.DigestDocument(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.Digest = digest
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
type stateTestChunker struct{ harness *stateTestHarness }
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
|
||||
func TestRunManifestChunkPlanIsAdditiveAndOmitsPlanContent(t *testing.T) {
|
||||
manifest := RunManifest{ChunkPlan: &ChunkPlanManifest{
|
||||
Mode: "auto", Action: "reused", SourceDigest: "sha256:source", PlanDigest: "sha256:plan",
|
||||
PlanSchemaVersion: "notarius.chunk-plan.v1", RequestedModule: "chunk/current",
|
||||
PlanSchemaVersion: "notarius.chunk-plan.v2", RequestedModule: "chunk/current",
|
||||
ProducerInputModule: "input/original", ProducerModule: "chunk/original",
|
||||
}}
|
||||
encoded, err := json.Marshal(manifest)
|
||||
|
||||
@@ -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)
|
||||
}},
|
||||
{name: "truncated JSON", mutate: func(data []byte) []byte { return data[:len(data)/2] }},
|
||||
{name: "schema mismatch", mutate: replaceJSON(`notarius.chunk-plan.v1`, `SENTINEL_SCHEMA_VALUE`)},
|
||||
{name: "legacy v1 record", mutate: replaceJSON(`notarius.chunk-plan.v2`, `notarius.chunk-plan.v1`)},
|
||||
{name: "source mismatch", mutate: replaceJSON(testSourceDigest, "sha256:"+strings.Repeat("b", 64))},
|
||||
{name: "plan digest mismatch", mutate: func(data []byte) []byte {
|
||||
prefix := []byte(`"plan_digest":"sha256:`)
|
||||
|
||||
@@ -69,6 +69,16 @@ func CloneSerializedArtifact(artifact SerializedArtifact) SerializedArtifact {
|
||||
return artifact
|
||||
}
|
||||
|
||||
// CloneSerializedArtifactPointer returns an independently owned artifact when
|
||||
// one is present.
|
||||
func CloneSerializedArtifactPointer(artifact *SerializedArtifact) *SerializedArtifact {
|
||||
if artifact == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := CloneSerializedArtifact(*artifact)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func CloneSerializedOutput(output SerializedOutput) SerializedOutput {
|
||||
output.Artifact = CloneSerializedArtifact(output.Artifact)
|
||||
return output
|
||||
|
||||
@@ -291,6 +291,7 @@ type OutputRequest struct {
|
||||
Warnings []Warning `json:"warnings,omitempty"`
|
||||
LLMProfile string `json:"llm_profile,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
ChunkMap *SerializedArtifact `json:"chunk_map,omitempty"`
|
||||
}
|
||||
|
||||
type OutputFile struct {
|
||||
|
||||
@@ -26,6 +26,25 @@ func TestCloneReferenceSlotsEmptyInputReturnsNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSerializedArtifactPointerOwnsArtifactData(t *testing.T) {
|
||||
original := &SerializedArtifact{
|
||||
Kind: "test/artifact",
|
||||
Schema: ArtifactSchema{ID: "test.schema", JSONSchema: []byte(`{"type":"object"}`)},
|
||||
Content: []byte(`{"items":[]}`),
|
||||
Metadata: map[string]any{"count": 1},
|
||||
}
|
||||
cloned := CloneSerializedArtifactPointer(original)
|
||||
original.Schema.JSONSchema[0] = '['
|
||||
original.Content[0] = '['
|
||||
original.Metadata["count"] = 2
|
||||
if cloned == nil || string(cloned.Schema.JSONSchema) != `{"type":"object"}` || string(cloned.Content) != `{"items":[]}` || cloned.Metadata["count"] != 1 {
|
||||
t.Fatalf("CloneSerializedArtifactPointer() = %#v, want independent artifact data", cloned)
|
||||
}
|
||||
if CloneSerializedArtifactPointer(nil) != nil {
|
||||
t.Fatal("CloneSerializedArtifactPointer(nil) must return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneReferenceSlotsPreservesFields(t *testing.T) {
|
||||
slots := []ReferenceSlot{
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
const ChunkPlanSchemaVersion = "notarius.chunk-plan.v1"
|
||||
const ChunkPlanSchemaVersion = "notarius.chunk-plan.v2"
|
||||
|
||||
type ChunkPlanProducer struct {
|
||||
InputModule string `json:"input_module"`
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
@@ -251,6 +252,28 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
return failOutput(output), fmt.Errorf("write chunk debug artifact: %w", err)
|
||||
}
|
||||
|
||||
var acceptedChunkMap *contracts.SerializedArtifact
|
||||
if chunkResult.accepted {
|
||||
if chunkResult.plan == nil || chunkResult.record == nil {
|
||||
return failOutput(output), fmt.Errorf("accepted chunk plan is missing plan or producer record")
|
||||
}
|
||||
artifact, buildErr := chunkmap.Serialize(chunkmap.BuildRequest{
|
||||
Source: doc,
|
||||
Plan: *chunkResult.plan,
|
||||
Chunks: chunkResult.chunks,
|
||||
RequestedChunker: input.pipeline.Chunk.Module,
|
||||
Producer: chunkmap.Producer{
|
||||
InputModule: chunkResult.record.Producer.InputModule,
|
||||
ChunkModule: chunkResult.record.Producer.ChunkModule,
|
||||
LLMProfile: chunkResult.record.Producer.LLMProfile,
|
||||
},
|
||||
})
|
||||
if buildErr != nil {
|
||||
return failOutput(output), fmt.Errorf("serialize accepted chunk map: %w", buildErr)
|
||||
}
|
||||
acceptedChunkMap = &artifact
|
||||
}
|
||||
|
||||
if chunkResult.accepted {
|
||||
if err := r.runPreparedSteps(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, chunkResult.chunks, &output); err != nil {
|
||||
return failOutput(output), err
|
||||
@@ -270,18 +293,22 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
return failOutput(output), err
|
||||
}
|
||||
outputStarted := time.Now().UTC()
|
||||
if err := writeDebugTimed(debugRecorder, "output/input.json", debugTimedEnvelope{
|
||||
Stage: string(StageOutput),
|
||||
ModuleKey: encoder.Key(),
|
||||
StartedAt: outputStarted,
|
||||
Payload: map[string]any{
|
||||
outputDebugPayload := map[string]any{
|
||||
"manifest": output.Manifest,
|
||||
"normalize_outputs": debugSerializedOutputEnvelopes(output.NormalizeOutputs),
|
||||
"rejected": debugRejectedOutputEnvelopes(output.Rejected),
|
||||
"warnings": output.Warnings,
|
||||
"options": redactSensitiveMap(input.pipeline.Output.Options),
|
||||
"metadata": redactSensitiveMap(input.Metadata),
|
||||
},
|
||||
}
|
||||
if acceptedChunkMap != nil {
|
||||
outputDebugPayload["chunk_map"] = debugSerializedOutputEnvelope(contracts.SerializedOutput{Artifact: *acceptedChunkMap})
|
||||
}
|
||||
if err := writeDebugTimed(debugRecorder, "output/input.json", debugTimedEnvelope{
|
||||
Stage: string(StageOutput),
|
||||
ModuleKey: encoder.Key(),
|
||||
StartedAt: outputStarted,
|
||||
Payload: outputDebugPayload,
|
||||
}); err != nil {
|
||||
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
|
||||
}
|
||||
@@ -296,6 +323,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
Warnings: output.Warnings,
|
||||
LLMProfile: input.pipeline.Output.LLMProfile,
|
||||
Metadata: outputMetadata,
|
||||
ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap),
|
||||
})
|
||||
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
@@ -40,6 +41,17 @@ type countingChunkValidator struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type capturingChunkMapOutput struct {
|
||||
requests []contracts.OutputRequest
|
||||
}
|
||||
|
||||
func (*capturingChunkMapOutput) Key() string { return "capture/chunk-map" }
|
||||
|
||||
func (output *capturingChunkMapOutput) Encode(_ context.Context, request contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
output.requests = append(output.requests, request)
|
||||
return contracts.OutputResult{}, nil
|
||||
}
|
||||
|
||||
func (*countingChunkValidator) Name() string { return "test/counting-chunks" }
|
||||
func (*countingChunkValidator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
@@ -205,6 +217,116 @@ func TestRunnerChunkPlanHitUsesStoredProducerProvenance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerProvidesAcceptedChunkMapToOutput(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
configure func(*PreparedPipeline, source.ChunkPlan) (RunInput, chunkmap.Producer)
|
||||
}{
|
||||
{
|
||||
name: "bypassed plan",
|
||||
configure: func(prepared *PreparedPipeline, _ source.ChunkPlan) (RunInput, chunkmap.Producer) {
|
||||
return RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheBypass}, chunkmap.Producer{
|
||||
InputModule: prepared.input.Key(), ChunkModule: prepared.chunker.Key(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cached plan",
|
||||
configure: func(prepared *PreparedPipeline, plan source.ChunkPlan) (RunInput, chunkmap.Producer) {
|
||||
prepared.resolved.Chunk.Module = "chunk/requested"
|
||||
record := chunkPlanRecord(t, prepared, plan)
|
||||
record.Producer.InputModule = "input/original"
|
||||
record.Producer.ChunkModule = "chunk/original"
|
||||
record.Producer.LLMProfile = "original-profile"
|
||||
return RunInput{
|
||||
Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto,
|
||||
ChunkPlans: &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}},
|
||||
}, chunkmap.Producer{InputModule: "input/original", ChunkModule: "chunk/original", LLMProfile: "original-profile"}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
prepared, plan := preparedTerminalDebugPipeline(t)
|
||||
encoder := &capturingChunkMapOutput{}
|
||||
prepared.output = encoder
|
||||
input, wantProducer := test.configure(prepared, plan)
|
||||
if _, err := New().Run(context.Background(), input); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(encoder.requests) != 1 || encoder.requests[0].ChunkMap == nil {
|
||||
t.Fatalf("output requests = %#v, want one accepted chunk map", encoder.requests)
|
||||
}
|
||||
artifact := encoder.requests[0].ChunkMap
|
||||
if artifact.Kind != chunkmap.ArtifactKind || artifact.Schema.ID != chunkmap.SchemaID || artifact.MediaType != chunkmap.MediaType {
|
||||
t.Fatalf("chunk map artifact = %#v, want fixed serialized identity", artifact)
|
||||
}
|
||||
value, err := chunkmap.New().Decode(artifact.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("decode output chunk map: %v", err)
|
||||
}
|
||||
if value.RequestedChunker != prepared.resolved.Chunk.Module || value.Producer != wantProducer {
|
||||
t.Fatalf("chunk map = %#v, want current request and producer %#v", value, wantProducer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerOmitsChunkMapForRejectedPlan(t *testing.T) {
|
||||
prepared, _ := preparedTerminalDebugPipeline(t)
|
||||
encoder := &capturingChunkMapOutput{}
|
||||
prepared.output = encoder
|
||||
prepared.chunkValidators.validators = []preparedValidator{{
|
||||
resolved: ResolvedValidator{Binding: Binding("reject/chunk"), Target: ValidatorTargetChunk},
|
||||
chunk: &countingChunkValidator{result: contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "not accepted"}},
|
||||
}}
|
||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(output.Rejected) != 1 || len(encoder.requests) != 1 || encoder.requests[0].ChunkMap != nil {
|
||||
t.Fatalf("output = %#v requests = %#v, want rejected plan without chunk map", output.Rejected, encoder.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRetainsChunkMapAfterLaneRejection(t *testing.T) {
|
||||
prepared, _ := preparedTerminalDebugPipeline(t)
|
||||
encoder := &capturingChunkMapOutput{}
|
||||
prepared.output = encoder
|
||||
prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{{
|
||||
resolved: ResolvedValidator{Binding: Binding("reject/lane"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
|
||||
typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "not accepted"}, nil
|
||||
},
|
||||
}}
|
||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(output.Rejected) != 1 || len(encoder.requests) != 1 || encoder.requests[0].ChunkMap == nil {
|
||||
t.Fatalf("output = %#v requests = %#v, want lane rejection with accepted chunk map", output.Rejected, encoder.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerChunkMapRequestDoesNotAliasStoredPlan(t *testing.T) {
|
||||
prepared, plan := preparedTerminalDebugPipeline(t)
|
||||
encoder := &capturingChunkMapOutput{}
|
||||
prepared.output = encoder
|
||||
record := chunkPlanRecord(t, prepared, plan)
|
||||
record.Plan.Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Opening"}`)}
|
||||
store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
|
||||
if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
store.record.Plan.Annotations["dnd/scenes"][0] = '['
|
||||
value, err := chunkmap.New().Decode(encoder.requests[0].ChunkMap.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("decode captured chunk map after store mutation: %v", err)
|
||||
}
|
||||
if string(value.PlanAnnotations["dnd/scenes"]) != `{"title":"Opening"}` {
|
||||
t.Fatalf("captured chunk map aliases stored plan: %s", value.PlanAnnotations["dnd/scenes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerChunkPlanManifestRetainsCandidateOnRejection(t *testing.T) {
|
||||
prepared, _ := preparedTerminalDebugPipeline(t)
|
||||
validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: false, ReasonCode: "policy", Message: "no"}}
|
||||
@@ -280,19 +402,19 @@ func TestRunnerRegeneratesStructurallyInvalidHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerReusesCrossDomainAnnotationsAsOptionalData(t *testing.T) {
|
||||
func TestRunnerReusesAnnotationsFromDifferentChunkerAsOptionalData(t *testing.T) {
|
||||
prepared, plan := preparedTerminalDebugPipeline(t)
|
||||
plan.Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"boundary_caveats":["uncertain"]}`)}
|
||||
plan.Ranges[0].Annotations = source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Opening"}`)}
|
||||
plan.Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"label":"fixture"}`)}
|
||||
plan.Ranges[0].Annotations = source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"category":"sample"}`)}
|
||||
record := chunkPlanRecord(t, prepared, plan)
|
||||
record.Producer.ChunkModule = "dnd/scenes"
|
||||
record.Producer.ChunkModule = "chunk/producer"
|
||||
store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}}
|
||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output.Manifest.ChunkPlan.ProducerModule != "dnd/scenes" || output.Manifest.ChunkPlan.Action != "reused" {
|
||||
t.Fatalf("cross-domain annotation plan was not reused: %#v", output.Manifest.ChunkPlan)
|
||||
if output.Manifest.ChunkPlan.ProducerModule != "chunk/producer" || output.Manifest.ChunkPlan.Action != "reused" {
|
||||
t.Fatalf("different-chunker annotation plan was not reused: %#v", output.Manifest.ChunkPlan)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
Good reasons to start a new scene include:
|
||||
- the party moves to a new location;
|
||||
- a combat encounter begins or ends;
|
||||
- combat changes into a substantially different phase;
|
||||
- the party shifts between combat, exploration, social interaction, discussion,
|
||||
planning, travel, rest, or downtime;
|
||||
- a new NPC, faction, threat, or objective becomes central;
|
||||
- the party completes one immediate goal and begins another;
|
||||
- a major table-level rules discussion interrupts and materially changes play.
|
||||
Cover the complete provided transcript from its first source unit to its last
|
||||
source unit. Return scenes in source-unit order with no gaps or overlaps. Use
|
||||
only positive integer source-unit IDs from the transcript, and give every scene
|
||||
one inclusive `start_unit_id` and one inclusive `end_unit_id`.
|
||||
|
||||
Do not start a new scene merely because:
|
||||
- the speaker changes;
|
||||
- a new combat round begins;
|
||||
- a player asks a brief rules question;
|
||||
- there is a joke, aside, or short table comment;
|
||||
- a character takes a routine turn;
|
||||
- the same encounter continues without a meaningful change in situation.
|
||||
|
||||
dnd/scenes boundary policy:
|
||||
- cover the full provided transcript from the first source unit to the last
|
||||
source unit;
|
||||
- return sequential scenes with no gaps;
|
||||
- do not overlap scenes;
|
||||
- preserve source-unit order;
|
||||
- use integer source-unit IDs from the transcript;
|
||||
- each scene must have start_unit_id and end_unit_id;
|
||||
- do not include final chunk IDs or chunk indexes.
|
||||
|
||||
For each scene:
|
||||
- short_title should be brief and factual;
|
||||
- primary_mode must be Recap, Discussion, Combat, or Narrative;
|
||||
- main_participants should include only principal characters, NPCs, factions, or
|
||||
groups involved;
|
||||
- summary should be factual and compact, usually one to three sentences;
|
||||
- boundary_note should explain why the scene begins at start_unit_id and ends at
|
||||
end_unit_id;
|
||||
- boundary_confidence must be High, Medium, or Low.
|
||||
|
||||
Primary mode guidance:
|
||||
- Use Recap for opening recap, initiative setup, session framing, or immediate
|
||||
continuation from prior events.
|
||||
- Use Discussion when the party is primarily discussing options or choosing a
|
||||
course of action.
|
||||
- Use Combat when active combat or combat-resolution mechanics dominate.
|
||||
- Use Narrative for all other non-combat gameplay, including exploration, social
|
||||
interactions, shopping, preparation, travel, rest, and downtime.
|
||||
|
||||
In boundary_caveats, list overall caveats about scene divisions. Include scenes
|
||||
that could reasonably be split differently, combat phases that were kept
|
||||
together, gradual transitions, or places where map context would have helped.
|
||||
|
||||
Return exactly one JSON object and no explanatory text.
|
||||
Return exactly one JSON object and no explanatory text. The object must contain
|
||||
only a non-empty `scenes` array. Each scene object must contain only
|
||||
`start_unit_id` and `end_unit_id`.
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
Divide the provided transcript into coherent Dungeons & Dragons scenes for the
|
||||
dnd/scenes chunk module.
|
||||
`dnd/scenes` chunk module.
|
||||
|
||||
A scene is a coherent unit of play. Start a new scene when there is a meaningful
|
||||
change in location, objective, threat, activity, encounter, or mode of play.
|
||||
A scene is a coherent unit of play. Start a new scene when the transcript
|
||||
establishes a meaningful change in location, objective, threat, activity,
|
||||
encounter, or mode of play. Good reasons include a material move, beginning or
|
||||
ending combat, a substantially different encounter phase, a shift between
|
||||
combat, exploration, social interaction, planning, travel, rest, or downtime,
|
||||
a change in the central NPC, faction, threat, or objective, or a sustained
|
||||
table-level interruption that materially changes the activity.
|
||||
|
||||
Do not split a scene merely because a speaker or combat round changes, a
|
||||
routine turn occurs, or the table briefly digresses. Prefer fewer coherent
|
||||
scenes over speculative or fine-grained boundaries.
|
||||
|
||||
Return only inclusive `start_unit_id` and `end_unit_id` endpoints for each
|
||||
scene. Do not return titles, modes, participants, summaries, boundary notes,
|
||||
confidence, caveats, final chunk IDs, or chunk indexes.
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$id": "notarius.dnd.scenes",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"scenes",
|
||||
"boundary_caveats"
|
||||
],
|
||||
"required": ["scenes"],
|
||||
"properties": {
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
@@ -14,16 +11,7 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"start_unit_id",
|
||||
"end_unit_id",
|
||||
"short_title",
|
||||
"primary_mode",
|
||||
"main_participants",
|
||||
"summary",
|
||||
"boundary_note",
|
||||
"boundary_confidence"
|
||||
],
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_unit_id": {
|
||||
"type": "integer",
|
||||
@@ -32,53 +20,9 @@
|
||||
"end_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"short_title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"primary_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Recap",
|
||||
"Discussion",
|
||||
"Combat",
|
||||
"Narrative"
|
||||
]
|
||||
},
|
||||
"main_participants": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"boundary_note": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"boundary_confidence": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"High",
|
||||
"Medium",
|
||||
"Low"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boundary_caveats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ package scenes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -22,8 +20,6 @@ var providedCapabilities = []string{
|
||||
"chunks",
|
||||
}
|
||||
|
||||
const annotationNamespace = "dnd/scenes"
|
||||
|
||||
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
||||
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
|
||||
Party: "Optional party roster reference material used only for scene disambiguation.",
|
||||
@@ -113,18 +109,11 @@ func (c *Chunker) Plan(ctx context.Context, req contracts.ChunkRequest) (contrac
|
||||
return contracts.ChunkPlanResult{}, chunkerErrorf("complete structured output: %w", err)
|
||||
}
|
||||
|
||||
warnings, err := warningsFromCaveats(response.BoundaryCaveats)
|
||||
plan, err := planFromResponse(req.Source, response)
|
||||
if err != nil {
|
||||
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
|
||||
}
|
||||
plan, err := planFromResponse(req.Source, response, warnings)
|
||||
if err != nil {
|
||||
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
|
||||
}
|
||||
return contracts.ChunkPlanResult{
|
||||
Plan: plan,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
return contracts.ChunkPlanResult{Plan: plan}, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
@@ -159,7 +148,7 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
return Options{}, nil
|
||||
}
|
||||
|
||||
func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnings []contracts.Warning) (source.ChunkPlan, error) {
|
||||
func planFromResponse(doc *source.SourceDocument, response chunkResponse) (source.ChunkPlan, error) {
|
||||
if response.Scenes == nil {
|
||||
return source.ChunkPlan{}, fmt.Errorf("scenes must be present")
|
||||
}
|
||||
@@ -175,21 +164,25 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin
|
||||
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
|
||||
previousEnd := -1
|
||||
for i, scene := range response.Scenes {
|
||||
normalized, err := normalizeScene(doc, i, scene)
|
||||
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
|
||||
if err != nil {
|
||||
return source.ChunkPlan{}, err
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
|
||||
}
|
||||
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
|
||||
if err != nil {
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
|
||||
}
|
||||
|
||||
startIndex, ok := unitIndexes[normalized.StartUnitID]
|
||||
startIndex, ok := unitIndexes[startUnitID]
|
||||
if !ok {
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, normalized.StartUnitID)
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
|
||||
}
|
||||
endIndex, ok := unitIndexes[normalized.EndUnitID]
|
||||
endIndex, ok := unitIndexes[endUnitID]
|
||||
if !ok {
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, normalized.EndUnitID)
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
|
||||
}
|
||||
if startIndex > endIndex {
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, normalized.StartUnitID, normalized.EndUnitID)
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, startUnitID, endUnitID)
|
||||
}
|
||||
|
||||
if i == 0 && startIndex != 0 {
|
||||
@@ -205,142 +198,21 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse, warnin
|
||||
}
|
||||
previousEnd = endIndex
|
||||
|
||||
annotation, err := json.Marshal(struct {
|
||||
ShortTitle string `json:"short_title"`
|
||||
PrimaryMode string `json:"primary_mode"`
|
||||
MainParticipants []string `json:"main_participants"`
|
||||
Summary string `json:"summary"`
|
||||
BoundaryNote string `json:"boundary_note"`
|
||||
BoundaryConfidence string `json:"boundary_confidence"`
|
||||
}{normalized.ShortTitle, normalized.PrimaryMode, normalized.MainParticipants, normalized.Summary, normalized.BoundaryNote, normalized.BoundaryConfidence})
|
||||
if err != nil {
|
||||
return source.ChunkPlan{}, fmt.Errorf("encode scene[%d] annotation: %w", i, err)
|
||||
}
|
||||
ranges = append(ranges, source.ChunkRange{
|
||||
StartUnitID: normalized.StartUnitID,
|
||||
EndUnitID: normalized.EndUnitID,
|
||||
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
|
||||
StartUnitID: startUnitID,
|
||||
EndUnitID: endUnitID,
|
||||
})
|
||||
}
|
||||
|
||||
if previousEnd != len(doc.Units)-1 {
|
||||
return source.ChunkPlan{}, fmt.Errorf("final scene must end at final source unit %d", doc.Units[len(doc.Units)-1].ID)
|
||||
}
|
||||
caveats := make([]string, 0, len(warnings))
|
||||
for _, warning := range warnings {
|
||||
caveats = append(caveats, warning.Message)
|
||||
}
|
||||
annotation, err := json.Marshal(struct {
|
||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
||||
}{BoundaryCaveats: caveats})
|
||||
if err != nil {
|
||||
return source.ChunkPlan{}, fmt.Errorf("encode plan annotation: %w", err)
|
||||
}
|
||||
return source.ChunkPlan{
|
||||
SourceDigest: doc.Digest,
|
||||
Ranges: ranges,
|
||||
Annotations: source.ChunkAnnotations{annotationNamespace: annotation},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
|
||||
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
|
||||
if err != nil {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
|
||||
}
|
||||
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
|
||||
if err != nil {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
|
||||
}
|
||||
|
||||
out := normalizedScene{
|
||||
StartUnitID: startUnitID,
|
||||
EndUnitID: endUnitID,
|
||||
ShortTitle: strings.TrimSpace(scene.ShortTitle),
|
||||
PrimaryMode: strings.TrimSpace(scene.PrimaryMode),
|
||||
Summary: strings.TrimSpace(scene.Summary),
|
||||
BoundaryNote: strings.TrimSpace(scene.BoundaryNote),
|
||||
BoundaryConfidence: strings.TrimSpace(scene.BoundaryConfidence),
|
||||
}
|
||||
|
||||
requiredInts := map[string]int{
|
||||
"start_unit_id": out.StartUnitID,
|
||||
"end_unit_id": out.EndUnitID,
|
||||
}
|
||||
for field, value := range requiredInts {
|
||||
if value <= 0 {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %s must be positive", index, field)
|
||||
}
|
||||
}
|
||||
required := map[string]string{
|
||||
"short_title": out.ShortTitle,
|
||||
"primary_mode": out.PrimaryMode,
|
||||
"summary": out.Summary,
|
||||
"boundary_note": out.BoundaryNote,
|
||||
"boundary_confidence": out.BoundaryConfidence,
|
||||
}
|
||||
for field, value := range required {
|
||||
if value == "" {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %s must not be empty", index, field)
|
||||
}
|
||||
}
|
||||
if !validPrimaryMode(out.PrimaryMode) {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] primary_mode %q is not supported", index, out.PrimaryMode)
|
||||
}
|
||||
if !validBoundaryConfidence(out.BoundaryConfidence) {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] boundary_confidence %q is not supported", index, out.BoundaryConfidence)
|
||||
}
|
||||
if len(scene.MainParticipants) == 0 {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants must not be empty", index)
|
||||
}
|
||||
out.MainParticipants = make([]string, 0, len(scene.MainParticipants))
|
||||
for participantIndex, participant := range scene.MainParticipants {
|
||||
trimmed := strings.TrimSpace(participant)
|
||||
if trimmed == "" {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants[%d] must not be empty", index, participantIndex)
|
||||
}
|
||||
out.MainParticipants = append(out.MainParticipants, trimmed)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validPrimaryMode(value string) bool {
|
||||
switch value {
|
||||
case "Recap", "Discussion", "Combat", "Narrative":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validBoundaryConfidence(value string) bool {
|
||||
switch value {
|
||||
case "High", "Medium", "Low":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func warningsFromCaveats(caveats []string) ([]contracts.Warning, error) {
|
||||
if len(caveats) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
warnings := make([]contracts.Warning, 0, len(caveats))
|
||||
for i, caveat := range caveats {
|
||||
trimmed := strings.TrimSpace(caveat)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("boundary_caveats[%d] must not be empty after trimming", i)
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: Key,
|
||||
ReasonCode: "scene_boundary_caveat",
|
||||
Message: trimmed,
|
||||
})
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func chunkerErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd scenes chunker: "+format, args...)
|
||||
}
|
||||
|
||||
@@ -110,32 +110,13 @@ func wantReferenceSlots() []contracts.ReferenceSlot {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput(t *testing.T) {
|
||||
func TestPlanReturnsAnnotationFreeSceneRangesFromStructuredOutput(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{
|
||||
response: chunkResponse{
|
||||
Scenes: []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(2),
|
||||
ShortTitle: " Goblin parley ",
|
||||
PrimaryMode: "Discussion",
|
||||
MainParticipants: []string{" Aria ", "Goblin scout"},
|
||||
Summary: " The party negotiates with a scout. ",
|
||||
BoundaryNote: " The scene covers the discussion before fighting starts. ",
|
||||
BoundaryConfidence: "High",
|
||||
scene(1, 2),
|
||||
scene(3, 4),
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
for i, want := range wantRanges {
|
||||
if result.Plan.Ranges[i].StartUnitID != want.StartUnitID || result.Plan.Ranges[i].EndUnitID != want.EndUnitID {
|
||||
got := result.Plan.Ranges[i]
|
||||
if got.StartUnitID != want.StartUnitID || got.EndUnitID != want.EndUnitID {
|
||||
t.Fatalf("range[%d] = %#v, want %#v", i, result.Plan.Ranges[i], want)
|
||||
}
|
||||
if len(got.Annotations) != 0 {
|
||||
t.Fatalf("range[%d] annotations = %#v, want absent", i, got.Annotations)
|
||||
}
|
||||
var firstAnnotation map[string]any
|
||||
if err := json.Unmarshal(result.Plan.Ranges[0].Annotations[annotationNamespace], &firstAnnotation); err != nil {
|
||||
t.Fatalf("decode first scene annotation: %v", err)
|
||||
}
|
||||
wantFirst := map[string]any{
|
||||
"short_title": "Goblin parley", "primary_mode": "Discussion",
|
||||
"main_participants": []any{"Aria", "Goblin scout"},
|
||||
"summary": "The party negotiates with a scout.",
|
||||
"boundary_note": "The scene covers the discussion before fighting starts.",
|
||||
"boundary_confidence": "High",
|
||||
if len(result.Plan.Annotations) != 0 {
|
||||
t.Fatalf("plan annotations = %#v, want absent", result.Plan.Annotations)
|
||||
}
|
||||
if !reflect.DeepEqual(firstAnnotation, wantFirst) {
|
||||
t.Fatalf("first scene annotation = %#v, want %#v", firstAnnotation, wantFirst)
|
||||
if len(result.Warnings) != 0 {
|
||||
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
|
||||
if err := json.Unmarshal(result.Plan.Annotations[annotationNamespace], &planAnnotation); err != nil {
|
||||
t.Fatalf("decode plan annotation: %v", err)
|
||||
req := chunkRequest()
|
||||
req.Source = doc
|
||||
|
||||
t.Run("accepts document-ordered ranges", func(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{response: chunkResponse{Scenes: []sceneResponse{
|
||||
scene(10, 3),
|
||||
scene(20, 20),
|
||||
}}}
|
||||
result, err := newChunker(t, client).Plan(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Plan() error = %v, want nil", err)
|
||||
}
|
||||
if !reflect.DeepEqual(planAnnotation, map[string]any{"boundary_caveats": []any{"The transition into combat is gradual."}}) || len(planAnnotation) != 1 {
|
||||
t.Fatalf("plan annotation = %#v, want normalized caveats only", planAnnotation)
|
||||
if got := result.Plan.Ranges; !reflect.DeepEqual(got, []source.ChunkRange{{StartUnitID: 10, EndUnitID: 3}, {StartUnitID: 20, EndUnitID: 20}}) {
|
||||
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" ||
|
||||
got[0].Message != "The transition into combat is gradual." {
|
||||
t.Fatalf("Warnings = %#v, want boundary caveat warning", got)
|
||||
})
|
||||
|
||||
t.Run("rejects reversed document positions", func(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{response: chunkResponse{Scenes: []sceneResponse{
|
||||
scene(3, 10),
|
||||
scene(20, 20),
|
||||
}}}
|
||||
_, err := newChunker(t, client).Plan(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "appears after") {
|
||||
t.Fatalf("Plan() error = %v, want document-position reversal", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlanPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{response: chunkResponse{
|
||||
Scenes: []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Ambush",
|
||||
PrimaryMode: "Combat",
|
||||
MainParticipants: []string{"Aria"},
|
||||
Summary: "The party is ambushed.",
|
||||
BoundaryNote: "One scene covers the short fixture.",
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
scene(1, 4),
|
||||
},
|
||||
}}
|
||||
req := chunkRequest()
|
||||
@@ -298,51 +291,6 @@ func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{
|
||||
response: chunkResponse{
|
||||
Scenes: validSceneResponse().Scenes,
|
||||
BoundaryCaveats: []string{
|
||||
" ",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := newChunker(t, client).Plan(context.Background(), chunkRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Plan() error = nil, want malformed structured output error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dnd scenes chunker") || !strings.Contains(err.Error(), "malformed structured output") || !strings.Contains(err.Error(), "boundary_caveats[0]") {
|
||||
t.Fatalf("Plan() error = %q, want malformed boundary caveat context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDefensivelyCopiesAnnotationValues(t *testing.T) {
|
||||
doc := sceneSourceDocument()
|
||||
client := &fakeScenesLLMClient{response: validSceneResponse()}
|
||||
|
||||
result, err := newChunker(t, client).Plan(context.Background(), contracts.ChunkRequest{
|
||||
Source: doc,
|
||||
SourceInput: sceneSourceInput(),
|
||||
SessionID: "session-123",
|
||||
LLMProfile: "profile-scenes",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Plan() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
client.response.Scenes[0].MainParticipants[0] = "mutated"
|
||||
var annotation struct {
|
||||
MainParticipants []string `json:"main_participants"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Plan.Ranges[0].Annotations[annotationNamespace], &annotation); err != nil {
|
||||
t.Fatalf("decode annotation: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(annotation.MainParticipants, []string{"Aria"}) {
|
||||
t.Fatalf("participants = %#v, want defensive copy", annotation.MainParticipants)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkerManifestMetadataIncludesPromptAndSchemaProvenance(t *testing.T) {
|
||||
metadata := newChunker(t, &fakeScenesLLMClient{}).ManifestMetadata()
|
||||
|
||||
@@ -455,38 +403,6 @@ func TestPlanRejectsMalformedStructuredOutput(t *testing.T) {
|
||||
}),
|
||||
want: "final scene",
|
||||
},
|
||||
{
|
||||
name: "empty metadata field",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: " ",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria"},
|
||||
Summary: "Summary.",
|
||||
BoundaryNote: "Note.",
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
}),
|
||||
want: "short_title",
|
||||
},
|
||||
{
|
||||
name: "empty participant",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Title",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria", " "},
|
||||
Summary: "Summary.",
|
||||
BoundaryNote: "Note.",
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
}),
|
||||
want: "main_participants",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -559,7 +475,6 @@ func validSceneResponse() chunkResponse {
|
||||
Scenes: []sceneResponse{
|
||||
scene(1, 4),
|
||||
},
|
||||
BoundaryCaveats: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,12 +487,6 @@ func scene(startUnitID int, endUnitID int) sceneResponse {
|
||||
return sceneResponse{
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
ShortTitle: "Scene title",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria"},
|
||||
Summary: "A compact summary.",
|
||||
BoundaryNote: "The source units form one coherent scene.",
|
||||
BoundaryConfidence: "High",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,27 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
|
||||
type chunkResponse struct {
|
||||
Scenes []sceneResponse `json:"scenes"`
|
||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
||||
}
|
||||
|
||||
type sceneResponse struct {
|
||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
||||
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
||||
ShortTitle string `json:"short_title"`
|
||||
PrimaryMode string `json:"primary_mode"`
|
||||
MainParticipants []string `json:"main_participants"`
|
||||
Summary string `json:"summary"`
|
||||
BoundaryNote string `json:"boundary_note"`
|
||||
BoundaryConfidence string `json:"boundary_confidence"`
|
||||
}
|
||||
|
||||
type normalizedScene struct {
|
||||
StartUnitID int
|
||||
EndUnitID int
|
||||
ShortTitle string
|
||||
PrimaryMode string
|
||||
MainParticipants []string
|
||||
Summary string
|
||||
BoundaryNote string
|
||||
BoundaryConfidence string
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestLoadResponseSchemaForScenes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
|
||||
func TestResponseSchemaValidatesMinimalSceneResponses(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
||||
@@ -55,11 +55,15 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "obsolete segment boundaries",
|
||||
name: "missing scenes",
|
||||
mutate: func(response map[string]any) {
|
||||
scene := response["scenes"].([]any)[0].(map[string]any)
|
||||
scene["start_segment_id"] = 1
|
||||
scene["end_segment_id"] = 2
|
||||
delete(response, "scenes")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty scenes",
|
||||
mutate: func(response map[string]any) {
|
||||
response["scenes"] = []any{}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -75,27 +79,21 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid primary mode",
|
||||
name: "non-integer endpoint",
|
||||
mutate: func(response map[string]any) {
|
||||
response["scenes"].([]any)[0].(map[string]any)["primary_mode"] = "Unknown"
|
||||
response["scenes"].([]any)[0].(map[string]any)["start_unit_id"] = 1.5
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid boundary confidence",
|
||||
name: "unknown top-level field",
|
||||
mutate: func(response map[string]any) {
|
||||
response["scenes"].([]any)[0].(map[string]any)["boundary_confidence"] = "Unknown"
|
||||
response["boundary_caveats"] = []any{}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty boundary caveat",
|
||||
name: "unknown scene field",
|
||||
mutate: func(response map[string]any) {
|
||||
response["boundary_caveats"] = []any{""}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown property",
|
||||
mutate: func(response map[string]any) {
|
||||
response["unexpected"] = true
|
||||
response["scenes"].([]any)[0].(map[string]any)["short_title"] = "Old contract"
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -116,21 +114,7 @@ func TestResponseSchemaValidatesSceneResponses(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"scenes": [
|
||||
{
|
||||
"start_unit_id": 1,
|
||||
"end_unit_id": 3,
|
||||
"short_title": "Ambush",
|
||||
"primary_mode": "Combat",
|
||||
"main_participants": ["Aria"],
|
||||
"summary": "The party fights.",
|
||||
"boundary_note": "Combat starts and resolves.",
|
||||
"boundary_confidence": "High"
|
||||
}
|
||||
],
|
||||
"boundary_caveats": []
|
||||
}`)
|
||||
raw := []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":3}]}`)
|
||||
|
||||
var response chunkResponse
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
@@ -159,7 +143,7 @@ func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
||||
t.Fatalf("schema JSON was mutated: %s", second.JSONSchema)
|
||||
}
|
||||
if len(second.JSONSchema) > 0 && second.JSONSchema[0] == '[' {
|
||||
t.Fatalf("schema JSON did not use defensive copy")
|
||||
t.Fatal("schema JSON did not use defensive copy")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,15 +153,8 @@ func validSceneSchemaResponse() map[string]any {
|
||||
map[string]any{
|
||||
"start_unit_id": 1,
|
||||
"end_unit_id": 3,
|
||||
"short_title": "Ambush",
|
||||
"primary_mode": "Combat",
|
||||
"main_participants": []any{"Aria"},
|
||||
"summary": "The party fights.",
|
||||
"boundary_note": "Combat starts and resolves.",
|
||||
"boundary_confidence": "High",
|
||||
},
|
||||
},
|
||||
"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 (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||
interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants"
|
||||
interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry"
|
||||
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
|
||||
interactionrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_refs"
|
||||
interactionrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_relatedness"
|
||||
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
||||
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
|
||||
sceneinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/invariants"
|
||||
sceneshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape"
|
||||
scenerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_refs"
|
||||
scenerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_relatedness"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
|
||||
@@ -108,5 +121,61 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "npc interactions validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: interactionextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(interactionshape.Key),
|
||||
pipeline.Binding(interactionregistry.Key),
|
||||
pipeline.Binding(interactionrefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(interactionrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "npc interactions normalize validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: interactionnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(interactionshape.Key),
|
||||
pipeline.Binding(interactionregistry.Key),
|
||||
pipeline.Binding(interactioninvariants.Key),
|
||||
pipeline.Binding(interactionrefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(interactionrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "scene descriptions validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: scenedescriptionextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(sceneshape.Key),
|
||||
pipeline.Binding(scenerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(scenerelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "scene descriptions normalize validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: scenedescriptionnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(sceneshape.Key),
|
||||
pipeline.Binding(sceneinvariants.Key),
|
||||
pipeline.Binding(scenerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(scenerelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,6 +57,46 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.Interactions != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.Interactions)
|
||||
}
|
||||
if !present {
|
||||
return dnd.NPCInteractionList{}, nil
|
||||
}
|
||||
combined := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 0, count)}
|
||||
for _, value := range values {
|
||||
for _, interaction := range value.Interactions {
|
||||
combined.Interactions = append(combined.Interactions, cloneNPCInteraction(interaction))
|
||||
}
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendSceneDescriptionLists(values []dnd.SceneDescriptionList) (dnd.SceneDescriptionList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.Scenes != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.Scenes)
|
||||
}
|
||||
if !present {
|
||||
return dnd.SceneDescriptionList{}, nil
|
||||
}
|
||||
combined := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, 0, count)}
|
||||
for _, value := range values {
|
||||
combined.Scenes = append(combined.Scenes, value.Scenes...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
clone := value
|
||||
if value.SourceRefs != nil {
|
||||
@@ -64,3 +104,11 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
|
||||
clone := value
|
||||
if value.SourceRefs != nil {
|
||||
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
@@ -6,13 +6,19 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||
@@ -24,10 +30,16 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
||||
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
||||
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
||||
{name: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }},
|
||||
{name: "scene descriptions codec", register: func() error {
|
||||
return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, scenedescriptioncodec.New())
|
||||
}},
|
||||
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
|
||||
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
|
||||
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
||||
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
||||
{name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }},
|
||||
{name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }},
|
||||
{name: "spell-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
|
||||
}},
|
||||
@@ -37,9 +49,17 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "combat-turn-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
||||
}},
|
||||
{name: "npc-interaction-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists)
|
||||
}},
|
||||
{name: "scene-description-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists)
|
||||
}},
|
||||
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
||||
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
||||
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
||||
{name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }},
|
||||
{name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }},
|
||||
{name: "spell-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind)
|
||||
}},
|
||||
@@ -49,6 +69,12 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "combat-turn-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "npc-interaction-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind)
|
||||
}},
|
||||
{name: "scene-description-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind)
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,5 +84,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error {
|
||||
{name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }},
|
||||
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
|
||||
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
|
||||
{name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }},
|
||||
{name: "scene descriptions prompt assets", register: func() error { return scenedescriptionextract.RegisterPromptAssets(assets) }},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
)
|
||||
|
||||
@@ -27,13 +31,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
||||
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key})
|
||||
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, pipeline.DefaultNormalizeModule})
|
||||
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, interactionextract.Key, scenedescriptionextract.Key})
|
||||
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule})
|
||||
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
|
||||
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||
"extract/dnd/npcs/shape",
|
||||
"extract/dnd/npcs/source_refs",
|
||||
@@ -47,6 +53,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
"extract/dnd/combat-turns/source_refs",
|
||||
"extract/dnd/combat-turns/source_relatedness",
|
||||
"normalize/dnd/combat-turns/invariants",
|
||||
"extract/dnd/npc-interactions/shape",
|
||||
"extract/dnd/npc-interactions/registry",
|
||||
"extract/dnd/npc-interactions/source_refs",
|
||||
"extract/dnd/npc-interactions/source_relatedness",
|
||||
"normalize/dnd/npc-interactions/invariants",
|
||||
"extract/dnd/scene-descriptions/shape",
|
||||
"extract/dnd/scene-descriptions/source_refs",
|
||||
"extract/dnd/scene-descriptions/source_relatedness",
|
||||
"normalize/dnd/scene-descriptions/invariants",
|
||||
"generic/always_accept",
|
||||
"generic/always_reject",
|
||||
})
|
||||
@@ -106,9 +121,56 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
|
||||
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
|
||||
}
|
||||
interactionExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/shape"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/registry"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"),
|
||||
}
|
||||
interactionNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/shape"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/registry"),
|
||||
pipeline.Binding("normalize/dnd/npc-interactions/invariants"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, interactionextract.Key); !reflect.DeepEqual(got, interactionExtractChain) {
|
||||
t.Fatalf("NPC interaction extract validator chain = %#v, want %#v", got, interactionExtractChain)
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, interactionnormalize.Key); !reflect.DeepEqual(got, interactionNormalizeChain) {
|
||||
t.Fatalf("NPC interaction normalize validator chain = %#v, want %#v", got, interactionNormalizeChain)
|
||||
}
|
||||
sceneExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/scene-descriptions/shape"),
|
||||
pipeline.Binding("extract/dnd/scene-descriptions/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, scenedescriptionextract.Key); !reflect.DeepEqual(got, sceneExtractChain) {
|
||||
t.Fatalf("scene description extract validator chain = %#v, want %#v", got, sceneExtractChain)
|
||||
}
|
||||
sceneNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/scene-descriptions/shape"),
|
||||
pipeline.Binding("normalize/dnd/scene-descriptions/invariants"),
|
||||
pipeline.Binding("extract/dnd/scene-descriptions/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, scenedescriptionnormalize.Key); !reflect.DeepEqual(got, sceneNormalizeChain) {
|
||||
t.Fatalf("scene description normalize validator chain = %#v, want %#v", got, sceneNormalizeChain)
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil {
|
||||
t.Fatalf("NPC merge validator chain = %#v, want absent", got)
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageMerge, interactionextract.Key); got != nil {
|
||||
t.Fatalf("NPC interaction merge validator chain = %#v, want absent", got)
|
||||
}
|
||||
assertAssetNamesContain(t, assets.PromptFS, []string{
|
||||
"dnd.scenes/dnd.scenes.yaml",
|
||||
"dnd.scenes/instructions.md",
|
||||
@@ -134,12 +196,25 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
"dnd.combat_turns/sharedassets/common-dnd-system.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.combat_turns/task.md",
|
||||
"dnd.npc_interactions/dnd.npc_interactions.yaml",
|
||||
"dnd.npc_interactions/instructions.md",
|
||||
"dnd.npc_interactions/sharedassets/common-dnd-npcs.md",
|
||||
"dnd.npc_interactions/task.md",
|
||||
"dnd.scene_descriptions/dnd.scene_descriptions.yaml",
|
||||
"dnd.scene_descriptions/instructions.md",
|
||||
"dnd.scene_descriptions/sharedassets/common-dnd-identity.md",
|
||||
"dnd.scene_descriptions/sharedassets/common-dnd-references.md",
|
||||
"dnd.scene_descriptions/sharedassets/common-dnd-system.md",
|
||||
"dnd.scene_descriptions/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.scene_descriptions/task.md",
|
||||
})
|
||||
assertAssetNamesContain(t, assets.SchemaFS, []string{
|
||||
"dnd_scenes.v1.json",
|
||||
"dnd_spells_llm.v1.json",
|
||||
"dnd_npcs_llm.v1.json",
|
||||
"dnd_combat_turns_llm.v1.json",
|
||||
"dnd_npc_interactions_llm.v1.json",
|
||||
"dnd_scene_descriptions_llm.v1.json",
|
||||
})
|
||||
if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" {
|
||||
t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok)
|
||||
@@ -162,6 +237,33 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize {
|
||||
t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok)
|
||||
}
|
||||
interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key)
|
||||
interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key)
|
||||
if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize {
|
||||
t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK)
|
||||
}
|
||||
sceneExtractSpec, sceneExtractOK := registries.Extractors.Spec(scenedescriptionextract.Key)
|
||||
sceneNormalizeSpec, sceneNormalizeOK := registries.Normalizers.Spec(scenedescriptionnormalize.Key)
|
||||
if !sceneExtractOK || sceneExtractSpec.ArtifactKind != dnd.SceneDescriptionListKind || !sceneNormalizeOK || sceneNormalizeSpec.ArtifactKind != dnd.SceneDescriptionListKind || sceneNormalizeSpec.Stage != pipeline.StageNormalize {
|
||||
t.Fatalf("scene description specs = %#v / %#v, present = %t / %t", sceneExtractSpec, sceneNormalizeSpec, sceneExtractOK, sceneNormalizeOK)
|
||||
}
|
||||
if len(sceneExtractSpec.ReferenceSlots) != 3 || len(sceneNormalizeSpec.ReferenceSlots) != 0 {
|
||||
t.Fatalf("scene description reference slots = %#v / %#v, want extractor campaign slots only", sceneExtractSpec.ReferenceSlots, sceneNormalizeSpec.ReferenceSlots)
|
||||
}
|
||||
extractRegistrySlot := referenceSlot(interactionExtractSpec.ReferenceSlots, "npcs")
|
||||
normalizeRegistrySlot := referenceSlot(interactionNormalizeSpec.ReferenceSlots, "npcs")
|
||||
if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || !reflect.DeepEqual(extractRegistrySlot, normalizeRegistrySlot) {
|
||||
t.Fatalf("NPC interaction registry slots disagree: %#v / %#v", interactionExtractSpec.ReferenceSlots, interactionNormalizeSpec.ReferenceSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func referenceSlot(slots []contracts.ReferenceSlot, name string) contracts.ReferenceSlot {
|
||||
for _, slot := range slots {
|
||||
if slot.Name == name {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return contracts.ReferenceSlot{}
|
||||
}
|
||||
|
||||
func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
|
||||
@@ -185,6 +287,52 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
input := []dnd.NPCInteractionList{
|
||||
{},
|
||||
{Interactions: []dnd.NPCInteraction{}},
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}},
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
|
||||
}
|
||||
got, err := appendNPCInteractionLists(input)
|
||||
if err != nil {
|
||||
t.Fatalf("appendNPCInteractionLists() error = %v", err)
|
||||
}
|
||||
if got.Interactions == nil || !reflect.DeepEqual([]string{got.Interactions[0].Name, got.Interactions[1].Name}, []string{"Aria", "Borin"}) {
|
||||
t.Fatalf("combined interactions = %#v", got)
|
||||
}
|
||||
got.Interactions[0].SourceRefs[0].StartUnitID = 999
|
||||
if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 {
|
||||
t.Fatal("merged interactions share source reference storage")
|
||||
}
|
||||
for _, values := range [][]dnd.NPCInteractionList{nil, []dnd.NPCInteractionList{{}, {}}} {
|
||||
result, err := appendNPCInteractionLists(values)
|
||||
if err != nil || result.Interactions != nil {
|
||||
t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSceneDescriptionListsPreservesOrderAndArrayPresence(t *testing.T) {
|
||||
input := []dnd.SceneDescriptionList{
|
||||
{},
|
||||
{Scenes: []dnd.SceneDescription{}},
|
||||
{Scenes: []dnd.SceneDescription{{ID: "first"}}},
|
||||
{Scenes: []dnd.SceneDescription{{ID: "second"}}},
|
||||
}
|
||||
got, err := appendSceneDescriptionLists(input)
|
||||
if err != nil || !reflect.DeepEqual(got.Scenes, []dnd.SceneDescription{{ID: "first"}, {ID: "second"}}) {
|
||||
t.Fatalf("appendSceneDescriptionLists() = %#v, error = %v", got, err)
|
||||
}
|
||||
for _, values := range [][]dnd.SceneDescriptionList{nil, []dnd.SceneDescriptionList{{}, {}}} {
|
||||
result, err := appendSceneDescriptionLists(values)
|
||||
if err != nil || result.Scenes != nil {
|
||||
t.Fatalf("nil-only merge = %#v, %v; want nil scenes", result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
||||
input := []dnd.CombatTurnList{
|
||||
|
||||
@@ -7,10 +7,19 @@ import (
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||
interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants"
|
||||
interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry"
|
||||
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
|
||||
interactionrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_refs"
|
||||
interactionrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/source_relatedness"
|
||||
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
||||
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
|
||||
sceneinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/invariants"
|
||||
sceneshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape"
|
||||
scenerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_refs"
|
||||
scenerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/source_relatedness"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
|
||||
@@ -33,6 +42,15 @@ func registerValidators(registries pipeline.Registries) error {
|
||||
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
|
||||
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
|
||||
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
|
||||
{name: "npc interaction shape validator", register: func() error { return interactionshape.Register(registries.Validators) }},
|
||||
{name: "npc interaction registry validator", register: func() error { return interactionregistry.Register(registries.Validators) }},
|
||||
{name: "npc interaction source references validator", register: func() error { return interactionrefs.Register(registries.Validators) }},
|
||||
{name: "npc interaction source relatedness validator", register: func() error { return interactionrelatedness.Register(registries.Validators) }},
|
||||
{name: "npc interaction normalized invariants validator", register: func() error { return interactioninvariants.Register(registries.Validators) }},
|
||||
{name: "scene description shape validator", register: func() error { return sceneshape.Register(registries.Validators) }},
|
||||
{name: "scene description source references validator", register: func() error { return scenerefs.Register(registries.Validators) }},
|
||||
{name: "scene description source relatedness validator", register: func() error { return scenerelatedness.Register(registries.Validators) }},
|
||||
{name: "scene description normalized invariants validator", register: func() error { return sceneinvariants.Register(registries.Validators) }},
|
||||
{name: "spell-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
||||
}},
|
||||
@@ -51,5 +69,17 @@ func registerValidators(registries pipeline.Registries) error {
|
||||
{name: "combat-turn-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "npc-interaction-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind)
|
||||
}},
|
||||
{name: "npc-interaction-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind)
|
||||
}},
|
||||
{name: "scene-description-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind)
|
||||
}},
|
||||
{name: "scene-description-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind)
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
An optional normalized Dungeons & Dragons NPC registry is provided below as
|
||||
grounding material. Use it only to prefer exact canonical participant names
|
||||
and recognize their aliases when the transcript identifies a participant.
|
||||
A normalized Dungeons & Dragons NPC registry is provided below as grounding
|
||||
material. It may be empty. Use it only to prefer exact canonical participant
|
||||
names when the transcript identifies a participant.
|
||||
|
||||
Registry content is context, not event evidence. Do not extract events,
|
||||
participants, effects, or source references from the registry. Registry source
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxIssues = 20
|
||||
MaxWarnings = 20
|
||||
MaxDisplayedRunes = 128
|
||||
MaxMessageBytes = 4096
|
||||
)
|
||||
@@ -37,6 +40,28 @@ func Aggregate(prefix string, issues []string) string {
|
||||
return aggregateMessage(prefix, displayed, len(issues)-len(displayed))
|
||||
}
|
||||
|
||||
// LimitWarnings returns at most MaxWarnings warnings, reserving the final
|
||||
// position for a deterministic omission summary when truncation is required.
|
||||
func LimitWarnings(warnings []contracts.Warning, scope, reasonCode string) []contracts.Warning {
|
||||
if warnings == nil {
|
||||
return nil
|
||||
}
|
||||
if len(warnings) <= MaxWarnings {
|
||||
bounded := make([]contracts.Warning, len(warnings))
|
||||
copy(bounded, warnings)
|
||||
return bounded
|
||||
}
|
||||
displayed := MaxWarnings - 1
|
||||
bounded := make([]contracts.Warning, displayed, MaxWarnings)
|
||||
copy(bounded, warnings[:displayed])
|
||||
bounded = append(bounded, contracts.Warning{
|
||||
Scope: scope,
|
||||
ReasonCode: reasonCode,
|
||||
Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed),
|
||||
})
|
||||
return bounded
|
||||
}
|
||||
|
||||
func aggregateMessage(prefix string, issues []string, omitted int) string {
|
||||
message := prefix + ": " + strings.Join(issues, ", ")
|
||||
if omitted > 0 {
|
||||
|
||||
@@ -2,9 +2,12 @@ package diagnostics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
||||
@@ -26,3 +29,25 @@ func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
||||
t.Fatalf("Aggregate() = %q, want %q", message, wantOmitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitWarningsBoundsOutputAndReportsOmissions(t *testing.T) {
|
||||
warnings := make([]contracts.Warning, MaxWarnings+3)
|
||||
for index := range warnings {
|
||||
warnings[index] = contracts.Warning{ReasonCode: fmt.Sprintf("warning-%d", index)}
|
||||
}
|
||||
before := append([]contracts.Warning(nil), warnings...)
|
||||
|
||||
got := LimitWarnings(warnings, "records", "warnings_omitted")
|
||||
if len(got) != MaxWarnings {
|
||||
t.Fatalf("LimitWarnings() count = %d, want %d", len(got), MaxWarnings)
|
||||
}
|
||||
summary := got[len(got)-1]
|
||||
wantOmitted := len(warnings) - (MaxWarnings - 1)
|
||||
if summary.Scope != "records" || summary.ReasonCode != "warnings_omitted" ||
|
||||
summary.Message != fmt.Sprintf("%d additional warning(s) omitted", wantOmitted) {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
if !reflect.DeepEqual(warnings, before) {
|
||||
t.Fatal("LimitWarnings() mutated its input")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ const NPCListKind contracts.ArtifactKind = "dnd/npc-list"
|
||||
|
||||
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
||||
|
||||
const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
|
||||
|
||||
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
||||
|
||||
type SpellList struct {
|
||||
SpellCasts []SpellCast `json:"spell_casts"`
|
||||
}
|
||||
@@ -51,3 +55,45 @@ type CombatTurn struct {
|
||||
TurnKind CombatTurnKind `json:"turn_kind"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type NPCInteractionKind string
|
||||
|
||||
const (
|
||||
NPCInteractionKindMentioned NPCInteractionKind = "mentioned"
|
||||
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence"
|
||||
NPCInteractionKindDialogue NPCInteractionKind = "dialogue"
|
||||
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally"
|
||||
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent"
|
||||
NPCInteractionKindOther NPCInteractionKind = "other"
|
||||
)
|
||||
|
||||
type NPCInteractionList struct {
|
||||
Interactions []NPCInteraction `json:"interactions"`
|
||||
}
|
||||
|
||||
type NPCInteraction struct {
|
||||
Name string `json:"name"`
|
||||
Kind NPCInteractionKind `json:"kind"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type SceneKind string
|
||||
|
||||
const (
|
||||
SceneKindCombat SceneKind = "combat"
|
||||
SceneKindNarrative SceneKind = "narrative"
|
||||
SceneKindRecap SceneKind = "recap"
|
||||
SceneKindMeta SceneKind = "meta"
|
||||
)
|
||||
|
||||
type SceneDescriptionList struct {
|
||||
Scenes []SceneDescription `json:"scenes"`
|
||||
}
|
||||
|
||||
type SceneDescription struct {
|
||||
ID string `json:"id"`
|
||||
SourceRef source.SourceRef `json:"source_ref"`
|
||||
Kind SceneKind `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
@@ -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