Compare commits
5 Commits
d3e171aa82
...
a1f5dce405
| Author | SHA1 | Date | |
|---|---|---|---|
| a1f5dce405 | |||
| 50aa60e0b8 | |||
| 2fbb3813aa | |||
| 6dd695611c | |||
| 92acb45775 |
15
docs/cli.md
15
docs/cli.md
@@ -104,6 +104,21 @@ go run ./cmd/notarius run dnd-spells \
|
|||||||
--reference spells.extract.npcs=./npc-run/lanes/npcs.json
|
--reference spells.extract.npcs=./npc-run/lanes/npcs.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For the independent NPC-to-combat workflow, bind the same completed NPC lane
|
||||||
|
to both combat stages explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/notarius run dnd-combat \
|
||||||
|
--config examples/dnd-npc-combat-sequential.config.yml \
|
||||||
|
--input examples/seriatim-minimal-transcript.json \
|
||||||
|
--reference combat.extract.npcs=./npc-run/lanes/npcs.json \
|
||||||
|
--reference combat.normalize.npcs=./npc-run/lanes/npcs.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The two selectors are independent stage-local bindings. Binding extraction
|
||||||
|
does not implicitly bind normalization, and Notarius does not discover or
|
||||||
|
schedule the preceding NPC run.
|
||||||
|
|
||||||
The same grammar can target chunk, merge, and normalize slots when the configured
|
The same grammar can target chunk, merge, and normalize slots when the configured
|
||||||
modules declare them:
|
modules declare them:
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ The explicit-path option is defined in the [CLI reference](cli.md).
|
|||||||
- [Production-oriented D&D spell configuration](../examples/dnd-spells-production.config.yml)
|
- [Production-oriented D&D spell configuration](../examples/dnd-spells-production.config.yml)
|
||||||
- [D&D NPC configuration](../examples/dnd-npcs.config.yml)
|
- [D&D NPC configuration](../examples/dnd-npcs.config.yml)
|
||||||
- [Sequential D&D NPC and spell configuration](../examples/dnd-npc-spell-sequential.config.yml)
|
- [Sequential D&D NPC and spell configuration](../examples/dnd-npc-spell-sequential.config.yml)
|
||||||
|
- [D&D combat-turn configuration](../examples/dnd-combat-turns.config.yml)
|
||||||
|
- [Sequential D&D NPC and combat-turn configuration](../examples/dnd-npc-combat-sequential.config.yml)
|
||||||
|
|
||||||
All are complete version 3 files. The fragments below illustrate individual
|
All are complete version 3 files. The fragments below illustrate individual
|
||||||
fields and are not alternate complete configurations.
|
fields and are not alternate complete configurations.
|
||||||
@@ -286,10 +288,12 @@ production validators do not call the LLM and must not set `llm_profile`.
|
|||||||
| chunk | `dnd/scenes` | Uses an LLM to split transcript source units into D&D scenes. |
|
| chunk | `dnd/scenes` | Uses an LLM to split transcript source units into D&D scenes. |
|
||||||
| extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. |
|
| extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. |
|
||||||
| extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. |
|
| extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. |
|
||||||
|
| extract | `dnd/combat-turns` | Extracts typed D&D combat-turn-list artifacts. |
|
||||||
| merge | `appendorder` | Combines typed artifacts in chunk order. |
|
| merge | `appendorder` | Combines typed artifacts in chunk order. |
|
||||||
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
|
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
|
||||||
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
|
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
|
||||||
| normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical identity and aliases. |
|
| normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical identity and aliases. |
|
||||||
|
| 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. |
|
| output | `json` | Produces JSON output files for normalized `application/json` lanes. |
|
||||||
|
|
||||||
## Implemented Production Validators
|
## Implemented Production Validators
|
||||||
@@ -308,6 +312,10 @@ production validators do not call the LLM and must not set `llm_profile`.
|
|||||||
| `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. |
|
| `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. |
|
||||||
| `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name or alias is not found near its cited source text. |
|
| `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name or alias is not found near its cited source text. |
|
||||||
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs, aliases, and cross-record identity collisions. |
|
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs, aliases, and cross-record identity collisions. |
|
||||||
|
| `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. |
|
||||||
|
| `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 or declared action is not found near cited source text. |
|
||||||
|
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, target, evidence-order, and chronology violations. |
|
||||||
|
|
||||||
The production default chain for `dnd/spells` is used for both its extract and
|
The production default chain for `dnd/spells` is used for both its extract and
|
||||||
normalize stages:
|
normalize stages:
|
||||||
@@ -342,6 +350,26 @@ normalize:
|
|||||||
- extract/dnd/npcs/source_relatedness
|
- extract/dnd/npcs/source_relatedness
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The production default chains for `dnd/combat-turns` are:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
extract:
|
||||||
|
validators:
|
||||||
|
- generic/valid_json
|
||||||
|
- generic/valid_json_schema
|
||||||
|
- extract/dnd/combat-turns/shape
|
||||||
|
- extract/dnd/combat-turns/source_refs
|
||||||
|
- extract/dnd/combat-turns/source_relatedness
|
||||||
|
normalize:
|
||||||
|
validators:
|
||||||
|
- generic/valid_json
|
||||||
|
- generic/valid_json_schema
|
||||||
|
- extract/dnd/combat-turns/shape
|
||||||
|
- normalize/dnd/combat-turns/invariants
|
||||||
|
- extract/dnd/combat-turns/source_refs
|
||||||
|
- extract/dnd/combat-turns/source_relatedness
|
||||||
|
```
|
||||||
|
|
||||||
Empty chains approve output by default.
|
Empty chains approve output by default.
|
||||||
|
|
||||||
The `generic` chunker accepts:
|
The `generic` chunker accepts:
|
||||||
@@ -395,6 +423,26 @@ accepts no references. To pass an NPC result to a later spell run, bind the
|
|||||||
normalized payload explicitly at runtime; the maintained sequential example
|
normalized payload explicitly at runtime; the maintained sequential example
|
||||||
documents that operator workflow.
|
documents that operator workflow.
|
||||||
|
|
||||||
|
The `dnd/combat-turns` extractor and normalizer declare the same optional
|
||||||
|
campaign slots and the structured `npcs` slot. The combat extractor uses the
|
||||||
|
NPC payload only to ground actor and target identity; the normalizer uses its
|
||||||
|
prepared immutable registry for the same canonicalization. Both slots accept
|
||||||
|
exactly one UTF-8 `application/json` file no larger than 1 MiB. The registry's
|
||||||
|
source ranges remain provenance for the reference and never become combat
|
||||||
|
evidence. Binding `npcs` to extraction and normalization is stage-local, so an
|
||||||
|
operator-driven combat run uses two explicit selectors:
|
||||||
|
|
||||||
|
```text
|
||||||
|
combat.extract.npcs=<npc-run>/lanes/npcs.json
|
||||||
|
combat.normalize.npcs=<npc-run>/lanes/npcs.json
|
||||||
|
```
|
||||||
|
|
||||||
|
When bound, the combat extractor and normalizer record only the registry's
|
||||||
|
semantic digest and count in their metadata and checkpoint fingerprints; names,
|
||||||
|
aliases, content, and paths are not recorded there. When absent, the combat
|
||||||
|
prompt receives the exact empty registry value `{"npcs":[]}` and no registry
|
||||||
|
provenance or fingerprint is recorded.
|
||||||
|
|
||||||
## State Surfaces
|
## State Surfaces
|
||||||
|
|
||||||
The `output`, `cache`, and `debug` top-level fields select independent physical
|
The `output`, `cache`, and `debug` top-level fields select independent physical
|
||||||
|
|||||||
167
docs/integrations/dnd-combat-turn-artifacts.md
Normal file
167
docs/integrations/dnd-combat-turn-artifacts.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# D&D Combat-Turn Artifact Contract
|
||||||
|
|
||||||
|
This document defines the durable artifact, serialization, extraction,
|
||||||
|
candidate-validation, normalization, and production lane boundaries for D&D
|
||||||
|
combat turns.
|
||||||
|
|
||||||
|
## Artifact identity
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Artifact kind | `dnd/combat-turn-list` |
|
||||||
|
| Schema ID | `notarius.dnd.combat_turns` |
|
||||||
|
| Schema name | `notarius_dnd_combat_turns_v1` |
|
||||||
|
| Schema version | `v1` |
|
||||||
|
| Media type | `application/json` |
|
||||||
|
|
||||||
|
The top-level JSON object contains the required `combat_turns` array, which
|
||||||
|
may be empty. Every object rejects unknown fields.
|
||||||
|
|
||||||
|
## JSON shape
|
||||||
|
|
||||||
|
Each combat turn contains these required fields:
|
||||||
|
|
||||||
|
| Field | Shape |
|
||||||
|
| --- | --- |
|
||||||
|
| `actor` | Non-empty string. |
|
||||||
|
| `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. |
|
||||||
|
| `round` | Required JSON field containing a positive integer or `null`. |
|
||||||
|
| `actions` | Required array with at least one action. |
|
||||||
|
| `summary` | Non-empty string. |
|
||||||
|
| `source_refs` | Required array with at least one source reference. |
|
||||||
|
|
||||||
|
Each action contains these required fields:
|
||||||
|
|
||||||
|
| Field | Shape |
|
||||||
|
| --- | --- |
|
||||||
|
| `category` | One of `attack`, `spell`, `movement`, `item`, `ability_check`, `saving_throw`, `condition`, or `other`. |
|
||||||
|
| `declaration` | Non-empty string describing what was declared. |
|
||||||
|
| `targets` | Required array of strings; the array may be empty, but entries may not be empty. |
|
||||||
|
| `resolution` | Required JSON field containing a non-empty string or `null`. |
|
||||||
|
|
||||||
|
Source references use the shared source-reference shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source_id": "session-alpha",
|
||||||
|
"start_unit_id": 1,
|
||||||
|
"end_unit_id": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`source_id` must be non-empty and both unit IDs must be positive integers. The
|
||||||
|
codec does not resolve references against a source document or enforce source
|
||||||
|
range ordering; those checks belong to the later source-reference validation
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
## Codec behavior
|
||||||
|
|
||||||
|
The codec exposes two representations of the same typed artifact:
|
||||||
|
|
||||||
|
- Candidate encode/decode preserves invalid enum values, nullable values,
|
||||||
|
required-array presence, required strings, targets, and source references so
|
||||||
|
later validators can report them. Candidate decoding still requires valid
|
||||||
|
JSON, one JSON value, known fields, and the explicitly present `round` and
|
||||||
|
`resolution` keys; `null` is distinct from a missing key.
|
||||||
|
- Approved encode/decode enforces the structural rules in this contract.
|
||||||
|
|
||||||
|
The codec owns the durable JSON Schema, whose object layers all set
|
||||||
|
`additionalProperties` to `false`. Codec metadata contains only
|
||||||
|
`combat_turn_count`.
|
||||||
|
|
||||||
|
The maintained compact fixture is
|
||||||
|
`internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json`.
|
||||||
|
|
||||||
|
## Extraction boundary
|
||||||
|
|
||||||
|
The standalone extractor uses these identities:
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Extractor key | `dnd/combat-turns` |
|
||||||
|
| Capability | `dnd.combat_turns` |
|
||||||
|
| Prompt ID | `dnd.combat_turns` |
|
||||||
|
| Prompt version | `v1` |
|
||||||
|
| Private response-schema key | `dnd_combat_turns_llm` |
|
||||||
|
| Private response-schema ID | `notarius.dnd.combat_turns.llm` |
|
||||||
|
| Default profile | `gemini-2-flash` |
|
||||||
|
|
||||||
|
It requires `chunks` and `source.transcript`, accepts no options, and makes one
|
||||||
|
structured completion for each supplied chunk. The prompt receives the
|
||||||
|
chunk-scoped transcript plus the existing `players`, `party`, and `glossary`
|
||||||
|
inputs, and optionally the deprecated `roster` reference through the shared
|
||||||
|
party mapping. The optional `npcs` reference is an approved normalized NPC
|
||||||
|
artifact used only for identity grounding; it never supplies combat evidence.
|
||||||
|
|
||||||
|
The private response shape is the same as the durable turn/action shape except
|
||||||
|
that source references contain only `start_unit_id` and `end_unit_id`. The
|
||||||
|
extractor assigns the current source ID, removes exact duplicate ranges, and
|
||||||
|
stable-sorts turns by the earliest valid source-document position. Numeric unit
|
||||||
|
IDs are identifiers; source-document slice position determines chronology.
|
||||||
|
Malformed candidate fields remain in the typed result for deterministic
|
||||||
|
validators to report.
|
||||||
|
|
||||||
|
## Deterministic candidate validation
|
||||||
|
|
||||||
|
The standalone validator keys are:
|
||||||
|
|
||||||
|
| Validator | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `extract/dnd/combat-turns/shape` | Required arrays, strings, nullable fields, positive rounds, and supported enum values. |
|
||||||
|
| `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. |
|
||||||
|
| `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor or declared action is not related to cited transcript text. |
|
||||||
|
|
||||||
|
Source-reference and relatedness validators defer malformed shape to the shape
|
||||||
|
validator. Relatedness also defers when any cited source range is invalid. It
|
||||||
|
combines overlapping cited ranges once in document order, compares actors with
|
||||||
|
the shared Unicode-aware NPC identity policy, and checks declaration tokens of
|
||||||
|
at least four Unicode code points against complete cited-text tokens. Targets
|
||||||
|
are not checked deterministically.
|
||||||
|
|
||||||
|
The production D&D registrar exposes the extractor and these validators. Its
|
||||||
|
default extraction chain preserves this order: JSON syntax, private response
|
||||||
|
schema, combat shape, source references, then source relatedness.
|
||||||
|
|
||||||
|
## Normalization boundary
|
||||||
|
|
||||||
|
The standalone normalizer uses key `dnd/combat-turns`, requires `merged`,
|
||||||
|
provides `normalized`, accepts no options, and accepts the same optional
|
||||||
|
`players`, `party`, `glossary`, deprecated `roster`, and structured `npcs`
|
||||||
|
reference slots as extraction. The NPC registry is resolved during
|
||||||
|
preparation; runtime normalization uses that immutable prepared view.
|
||||||
|
|
||||||
|
Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes
|
||||||
|
actor, summary, declarations, targets, and non-null resolutions; canonicalizes
|
||||||
|
exact registry actor and target matches; orders and deduplicates exact source
|
||||||
|
references; stable-sorts records by earliest valid source-document position; and
|
||||||
|
collapses only records with the same actor identity, turn kind, round value, and
|
||||||
|
complete valid evidence set. The first normalized record is retained without
|
||||||
|
merging its actions or prose. Invalid evidence is never eligible for duplicate
|
||||||
|
collapse. Every mutation and collapse emits a bounded warning using the merged
|
||||||
|
input index in its scope.
|
||||||
|
|
||||||
|
The normalizer reports `normalization_policy` and `identity_policy` metadata and
|
||||||
|
fingerprints, plus `npc_registry_digest`, `npc_count`, and `npc_registry` only
|
||||||
|
when a registry is bound. The normalized-invariants validator is
|
||||||
|
`normalize/dnd/combat-turns/invariants`; it defers shape and source-reference
|
||||||
|
failures, then checks display normalization, target identity uniqueness,
|
||||||
|
canonical evidence ordering, chronology, and duplicate identity. It rejects
|
||||||
|
with `invalid_combat_turn_normalization` under policy
|
||||||
|
`dnd.combat_turns.validator.normalized.v1`.
|
||||||
|
|
||||||
|
The production D&D registrar exposes the normalizer and normalized-invariants
|
||||||
|
validator. Its default normalization chain is JSON syntax, durable schema,
|
||||||
|
combat shape, normalized invariants, source references, then source
|
||||||
|
relatedness. The lane uses the framework's typed append-order merger and has no
|
||||||
|
merge validator chain.
|
||||||
|
|
||||||
|
## Production manifest and references
|
||||||
|
|
||||||
|
The selectable lane uses extractor and normalizer key `dnd/combat-turns`,
|
||||||
|
`appendorder` for the typed merger, and the durable codec above. A bound `npcs`
|
||||||
|
reference contributes raw-file provenance to the run manifest. Prepared combat
|
||||||
|
extractor and normalizer metadata and checkpoint fingerprints contain only the
|
||||||
|
NPC registry's semantic digest and count; the registry content, path, and NPC
|
||||||
|
source ranges are not copied into combat output. The normalized lane is emitted
|
||||||
|
as `lanes/<lane-id>.json` by the JSON output module, and warnings and rejection
|
||||||
|
summaries remain in their shared companion files.
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
This document defines the durable D&D NPC-list artifact, its JSON codec, and
|
This document defines the durable D&D NPC-list artifact, its JSON codec, and
|
||||||
the selectable production NPC pipeline. The normalized JSON payload can be
|
the selectable production NPC pipeline. The normalized JSON payload can be
|
||||||
passed explicitly to the spell extractor as an optional caster-name registry;
|
passed explicitly to the spell extractor as an optional caster-name registry
|
||||||
it remains a reference, not spell evidence.
|
or to the combat extractor and normalizer as an actor/target registry. It
|
||||||
|
remains a reference, not spell or combat evidence.
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
|
|||||||
@@ -151,8 +151,9 @@ accepts only artifacts whose codec media type is `application/json`. The file
|
|||||||
contains the codec-owned JSON bytes pretty-printed.
|
contains the codec-owned JSON bytes pretty-printed.
|
||||||
|
|
||||||
The schema of each lane payload is owned by that artifact contract. For the
|
The schema of each lane payload is owned by that artifact contract. For the
|
||||||
current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md) and
|
current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md),
|
||||||
[D&D NPC Artifact](dnd-npc-artifacts.md).
|
[D&D NPC Artifact](dnd-npc-artifacts.md), and
|
||||||
|
[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md).
|
||||||
|
|
||||||
## `rejected.json`
|
## `rejected.json`
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ without exposing Scriptorium types through stage contracts.
|
|||||||
7. injecting that one shared client into complete pipeline preparation before
|
7. injecting that one shared client into complete pipeline preparation before
|
||||||
the source file is read or the runner is invoked.
|
the source file is read or the runner is invoked.
|
||||||
|
|
||||||
The D&D scene chunker and spell and NPC extractors retain this injected client
|
The D&D scene chunker and spell, NPC, and combat extractors retain this
|
||||||
and use it for every structured completion. Operation requests do not carry an
|
injected client and use it for every structured completion. Operation requests
|
||||||
LLM client.
|
do not carry an LLM client.
|
||||||
|
|
||||||
The CLI separately gathers explicit profile IDs from resolved LLM-capable stage
|
The CLI separately gathers explicit profile IDs from resolved LLM-capable stage
|
||||||
and validator bindings. It prepares a small internal check prompt for each ID so
|
and validator bindings. It prepares a small internal check prompt for each ID so
|
||||||
@@ -103,16 +103,22 @@ return defensive copies, and expose a diagnostics map that omits schema bytes.
|
|||||||
The small framework registry contains only generic test schemas; production
|
The small framework registry contains only generic test schemas; production
|
||||||
schemas remain package-owned.
|
schemas remain package-owned.
|
||||||
|
|
||||||
The spell extractor's package-owned prompt declares a required
|
The spell and combat extractors' package-owned prompts declare their structured
|
||||||
|
JSON inputs and private response schemas. The spell extractor's prompt declares a required
|
||||||
`application/json` `spell_catalog` input and an optional `application/json`
|
`application/json` `spell_catalog` input and an optional `application/json`
|
||||||
`npcs` input. The extractor generates the catalog input from its prepared
|
`npcs` input. The extractor generates the catalog input from its prepared
|
||||||
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
||||||
When an NPC registry is bound, it strictly decodes and identity-validates one
|
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
||||||
durable artifact, re-encodes canonical JSON, and generates a semantic digest
|
after the campaign reference message for both extractors. When an NPC registry
|
||||||
over those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests
|
is bound, the
|
||||||
cover the generated bytes; manifests record catalog identity and optional NPC
|
domain registry boundary strictly decodes and identity-validates one durable
|
||||||
|
artifact, re-encodes canonical JSON, and generates a semantic digest over
|
||||||
|
those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover
|
||||||
|
the generated bytes; manifests record catalog identity and optional NPC
|
||||||
registry digest/count rather than names, aliases, overlay bytes, registry
|
registry digest/count rather than names, aliases, overlay bytes, registry
|
||||||
paths, or source metadata.
|
paths, or source metadata. Combat prompt, response-schema, mapping,
|
||||||
|
normalization, identity, and bound-registry fingerprints remain separate
|
||||||
|
semantic inputs to checkpoint identity.
|
||||||
|
|
||||||
## Debug And Redaction Boundaries
|
## Debug And Redaction Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ validator registry. Package-family registrars compose those leaf registrations
|
|||||||
into the production catalog and own family-level policy such as default
|
into the production catalog and own family-level policy such as default
|
||||||
validator chains and prompt asset collection.
|
validator chains and prompt asset collection.
|
||||||
|
|
||||||
Production input, chunk, output, and D&D spell-extract packages register strict
|
Production input, chunk, output, and D&D spell- and combat-extract packages
|
||||||
option decoders and run-local builders. Preparation decodes their options into
|
register strict option decoders and run-local builders. Preparation decodes their options into
|
||||||
implementation-owned values and injects dependencies plus the materialized
|
implementation-owned values and injects dependencies plus the materialized
|
||||||
reference set for the selected target. Each builder receives an isolated clone
|
reference set for the selected target. Each builder receives an isolated clone
|
||||||
of that set; input and output builders receive no references. The spell
|
of that set; input and output builders receive no references. The spell and
|
||||||
extractor is typed over the canonical D&D model. D&D validators, merge, and
|
combat extractors are typed over the canonical D&D model. D&D validators, merge,
|
||||||
normalize use typed variants; JSON representation validators use serialized
|
and normalize use typed variants; JSON representation validators use serialized
|
||||||
requests; and unconditional validators expose separate chunk and typed
|
requests; and unconditional validators expose separate chunk and typed
|
||||||
variants. The D&D production registrar registers the canonical typed spell and
|
variants. The D&D production registrar registers the canonical typed spell,
|
||||||
NPC implementations, including their kind-specific merge and normalize
|
NPC, and combat implementations, including their kind-specific merge and
|
||||||
behavior.
|
normalize behavior.
|
||||||
|
|
||||||
Prepared extractors, extract validators, and codecs may be reused concurrently
|
Prepared extractors, extract validators, and codecs may be reused concurrently
|
||||||
by the run-wide extract pool. Production implementations are immutable after
|
by the run-wide extract pool. Production implementations are immutable after
|
||||||
@@ -47,7 +47,8 @@ LLM-backed extensions own their prompt definitions and response schemas under
|
|||||||
package-local embedded assets. Shared filesystem composition belongs in
|
package-local embedded assets. Shared filesystem composition belongs in
|
||||||
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
||||||
declarations, prompt-input assembly, and source-unit helpers belong in
|
declarations, prompt-input assembly, and source-unit helpers belong in
|
||||||
`internal/modules/dnd/shared`. Stage contracts expose only Notarius structured-
|
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. Stage
|
||||||
|
contracts expose only Notarius structured-
|
||||||
completion types, not Scriptorium public types.
|
completion types, not Scriptorium public types.
|
||||||
|
|
||||||
Reference material may inform a module or prompt but must not become source
|
Reference material may inform a module or prompt but must not become source
|
||||||
@@ -82,14 +83,23 @@ semantic digest; overlay content remains contextual reference material rather
|
|||||||
than source evidence. Its external JSON contract is defined in the
|
than source evidence. Its external JSON contract is defined in the
|
||||||
[spell-catalog overlay contract](../integrations/dnd-spell-catalog-overlays.md).
|
[spell-catalog overlay contract](../integrations/dnd-spell-catalog-overlays.md).
|
||||||
|
|
||||||
### `internal/modules/dnd/npcs/identity` and `internal/modules/dnd/codec/npcs`
|
### `internal/modules/dnd/npcs/identity`, `internal/modules/dnd/npcs/registry`, and `internal/modules/dnd/codec/npcs`
|
||||||
|
|
||||||
The NPC identity package owns Unicode comparison keys, deterministic
|
The NPC identity package owns Unicode comparison keys, deterministic
|
||||||
`npc:sha256:` IDs, display normalization, and whole-registry collision issues.
|
`npc:sha256:` IDs, display normalization, and whole-registry collision issues.
|
||||||
The NPC codec owns the strict durable `dnd/npc-list` JSON boundary and exposes
|
The registry package resolves one optional normalized artifact through the
|
||||||
candidate versus approved encode/decode operations. NPC source references are
|
strict codec, validates whole-registry identity, canonicalizes its JSON, and
|
||||||
durable provenance and may later be consumed by another pipeline as registry
|
provides immutable records, prompt input, semantic digest, count, and exact
|
||||||
context without being treated as evidence for that pipeline.
|
canonical-name/alias lookup. It owns the `npcs` slot and its bounded,
|
||||||
|
content-safe preparation failures. NPC source references are durable
|
||||||
|
provenance and are not treated as evidence for a consuming pipeline. The NPC
|
||||||
|
codec owns the strict durable `dnd/npc-list` JSON boundary and exposes
|
||||||
|
candidate versus approved encode/decode operations.
|
||||||
|
|
||||||
|
The `internal/modules/dnd/codec/combatturns` package owns the durable
|
||||||
|
`dnd/combat-turn-list` schema and candidate versus approved JSON boundary. It
|
||||||
|
is registered by the production D&D family registrar for the selectable combat
|
||||||
|
lane.
|
||||||
|
|
||||||
## Input Adapter
|
## Input Adapter
|
||||||
|
|
||||||
@@ -196,13 +206,13 @@ stages, using the codec only for checkpoint, debug, and output boundaries.
|
|||||||
Shared D&D helpers keep prompt input
|
Shared D&D helpers keep prompt input
|
||||||
names and source-unit reference conversion consistent with the scene chunker.
|
names and source-unit reference conversion consistent with the scene chunker.
|
||||||
|
|
||||||
The extractor also declares the optional `npcs` registry slot. Preparation
|
The extractor also declares the optional `npcs` registry slot and consumes the
|
||||||
requires one approved `application/json` item no larger than 1 MiB, validates
|
prepared immutable registry boundary from `internal/modules/dnd/npcs/registry`.
|
||||||
identity without relating registry source references to the current transcript,
|
A bound registry adds only `npc_registry_digest` and `npc_count` to manifest
|
||||||
and supplies canonical JSON to a spell-owned prompt message. A bound registry
|
metadata and an `npc_registry` checkpoint fingerprint. The unbound prompt
|
||||||
adds only `npc_registry_digest` and `npc_count` to manifest metadata and an
|
input is exactly `{"npcs":[]}` and has no registry provenance or fingerprint.
|
||||||
`npc_registry` checkpoint fingerprint. The unbound prompt input is exactly
|
The shared NPC grounding fragment is placed immediately after the common
|
||||||
`{"npcs":[]}` and has no registry provenance or fingerprint.
|
campaign reference message and is included in the spell prompt fingerprint.
|
||||||
|
|
||||||
The durable payload and manifest metadata shapes are defined in the
|
The durable payload and manifest metadata shapes are defined in the
|
||||||
[D&D spell artifact contract](../integrations/dnd-spell-artifacts.md).
|
[D&D spell artifact contract](../integrations/dnd-spell-artifacts.md).
|
||||||
@@ -212,9 +222,22 @@ The durable payload and manifest metadata shapes are defined in the
|
|||||||
The NPC extractor maps private model output to the canonical `dnd.NPCList`,
|
The NPC extractor maps private model output to the canonical `dnd.NPCList`,
|
||||||
assigns source identity and deterministic NPC IDs, and preserves source
|
assigns source identity and deterministic NPC IDs, and preserves source
|
||||||
references for deterministic validation. It uses the shared campaign
|
references for deterministic validation. It uses the shared campaign
|
||||||
references only for disambiguation and does not consume the spell-owned NPC
|
references only for disambiguation and does not consume the optional NPC
|
||||||
registry slot. Its prompt and private response schema are package-owned.
|
registry slot. Its prompt and private response schema are package-owned.
|
||||||
|
|
||||||
|
### `internal/modules/dnd/extract/combatturns`
|
||||||
|
|
||||||
|
The combat extractor prepares one structured request per supplied chunk using
|
||||||
|
the shared transcript, campaign-reference, and NPC-grounding prompt inputs. It
|
||||||
|
maps the private response to `dnd.CombatTurnList`, assigns the current source
|
||||||
|
identity, removes exact duplicate source ranges, and orders turns by valid
|
||||||
|
source-document position while preserving malformed candidate fields for
|
||||||
|
deterministic validators. Its prompt and private response schema are
|
||||||
|
package-owned, and its prepared metadata and checkpoint fingerprints contain
|
||||||
|
only prompt/schema/mapping identities plus an optional NPC registry digest.
|
||||||
|
The package exposes typed registration and is included in the production D&D
|
||||||
|
registrar with the default combat extraction chain.
|
||||||
|
|
||||||
### `internal/modules/dnd/normalize/npcs`
|
### `internal/modules/dnd/normalize/npcs`
|
||||||
|
|
||||||
The NPC normalizer performs deterministic identity-aware consolidation in
|
The NPC normalizer performs deterministic identity-aware consolidation in
|
||||||
@@ -261,6 +284,19 @@ raw overlay bytes are not included in either surface. The normalize-stage
|
|||||||
reference is stage-local, so an overlay-capable pipeline binds the catalog
|
reference is stage-local, so an overlay-capable pipeline binds the catalog
|
||||||
independently for extraction and normalization.
|
independently for extraction and normalization.
|
||||||
|
|
||||||
|
### `internal/modules/dnd/normalize/combatturns`
|
||||||
|
|
||||||
|
The combat normalizer prepares the optional NPC registry once and uses the
|
||||||
|
immutable prepared view during runtime. It display-normalizes combat fields,
|
||||||
|
rewrites exact canonical-name or alias matches for actors and targets, orders
|
||||||
|
and deduplicates source references, stable-sorts records by source-document
|
||||||
|
position, and collapses only exact duplicate identities with fully valid
|
||||||
|
evidence. It deep-clones output storage and emits bounded warnings scoped to
|
||||||
|
merged input indexes. Its metadata and fingerprints identify the normalization
|
||||||
|
and NPC identity policies, with registry digest/count only when bound. The
|
||||||
|
normalizer is included in the production D&D registrar with the default combat
|
||||||
|
normalization chain.
|
||||||
|
|
||||||
## Output Encoder
|
## Output Encoder
|
||||||
|
|
||||||
### `internal/modules/generic/output/json`
|
### `internal/modules/generic/output/json`
|
||||||
@@ -318,6 +354,20 @@ canonical names, aliases, and cross-record ownership or canonical collisions.
|
|||||||
All are deterministic and expose the policy fingerprints used by the
|
All are deterministic and expose the policy fingerprints used by the
|
||||||
production chains.
|
production chains.
|
||||||
|
|
||||||
|
## D&D Combat Validators
|
||||||
|
|
||||||
|
Combat shape validation owns required arrays, strings, nullable values, positive
|
||||||
|
rounds, and supported enums. Combat source-reference validation defers invalid
|
||||||
|
shape and checks source identity, unit existence, and range order. Combat
|
||||||
|
source-relatedness defers invalid shape or ranges, combines overlapping cited
|
||||||
|
units in document order, and emits at most one bounded advisory warning per
|
||||||
|
turn for unrelated actor or declaration text. The normalized-invariants
|
||||||
|
validator owns display normalization, comparison-unique targets, canonical
|
||||||
|
source-reference order, chronology, and exact duplicate identity; it defers
|
||||||
|
shape and source-reference failures. All four validators are deterministic and
|
||||||
|
expose local policy fingerprints. The D&D registrar orders them after generic
|
||||||
|
JSON and response-schema validation at extraction and normalization.
|
||||||
|
|
||||||
## Production Registration
|
## Production Registration
|
||||||
|
|
||||||
Production composition occurs through family registrars. The CLI allocates one
|
Production composition occurs through family registrars. The CLI allocates one
|
||||||
@@ -326,8 +376,9 @@ complete framework registry set and one LLM asset registry. It invokes
|
|||||||
`internal/modules/seriatim/register`, and `internal/modules/dnd/register` in
|
`internal/modules/seriatim/register`, and `internal/modules/dnd/register` in
|
||||||
that order, then exposes the matching catalog for resolution. The generic and
|
that order, then exposes the matching catalog for resolution. The generic and
|
||||||
Seriatim registrars own their production leaf registrations. The D&D registrar
|
Seriatim registrars own their production leaf registrations. The D&D registrar
|
||||||
owns D&D leaf registrations, typed spell and NPC default-validator chains,
|
owns D&D leaf registrations, typed spell, NPC, and combat default-validator
|
||||||
typed append-order specializations, and D&D prompt/schema asset collection.
|
chains, typed append-order specializations, and D&D prompt/schema asset
|
||||||
|
collection.
|
||||||
|
|
||||||
Concrete implementation packages do not import generic implementation
|
Concrete implementation packages do not import generic implementation
|
||||||
packages directly. A concrete family's `register` package is its composition
|
packages directly. A concrete family's `register` package is its composition
|
||||||
|
|||||||
@@ -65,9 +65,10 @@ run-local construction closures. Preparation injects shared dependencies and
|
|||||||
constructs input, chunk, validators, ordered lanes, and output before source
|
constructs input, chunk, validators, ordered lanes, and output before source
|
||||||
parsing. Production modules use strict construction-time option decoding, and
|
parsing. Production modules use strict construction-time option decoding, and
|
||||||
LLM-backed modules retain the injected shared client. The D&D family registers
|
LLM-backed modules retain the injected shared client. The D&D family registers
|
||||||
the canonical `dnd/spell-list` and `dnd/npc-list` codecs, typed spell and NPC
|
the canonical `dnd/spell-list`, `dnd/npc-list`, and `dnd/combat-turn-list`
|
||||||
extractors and normalizers, validators, plus kind-specific generic merge
|
codecs, typed spell, NPC, and combat extractors and normalizers, validators,
|
||||||
strategies; generic JSON validators use the serialized-validation contract. The runner executes lanes through
|
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
|
private exact-type-checked closures, coordinates extract results independently
|
||||||
of completion timing, and serializes artifacts only through their codec at
|
of completion timing, and serializes artifacts only through their codec at
|
||||||
checkpoint, debug, and output boundaries.
|
checkpoint, debug, and output boundaries.
|
||||||
@@ -84,11 +85,16 @@ Configuration. The implemented module packages are:
|
|||||||
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
|
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
|
||||||
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
|
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
|
||||||
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
|
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
|
||||||
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, and relationship artifact types. |
|
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, relationship, combat-turn-list, combat-turn, and combat-action artifact types. |
|
||||||
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
|
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
|
||||||
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
|
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
|
||||||
|
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |
|
||||||
| `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. |
|
| `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. |
|
||||||
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
|
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
|
||||||
|
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
|
||||||
|
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
|
||||||
|
| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat 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/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. |
|
||||||
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
|
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
|
||||||
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
|
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
|
||||||
@@ -98,17 +104,19 @@ Configuration. The implemented module packages are:
|
|||||||
| `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, and rejections as logical JSON files. |
|
||||||
|
|
||||||
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
|
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
|
||||||
reference declarations, prompt input assembly, and source-unit reference
|
reference declarations, prompt input assembly, source-unit reference helpers,
|
||||||
helpers. Domain-neutral prompt filesystem composition lives in
|
and bounded diagnostics under `internal/modules/dnd/shared/diagnostics`.
|
||||||
|
The shared NPC grounding fragment is mounted for D&D prompts and is owned by
|
||||||
|
this package. Domain-neutral prompt filesystem composition lives in
|
||||||
`internal/framework/promptfs`.
|
`internal/framework/promptfs`.
|
||||||
|
|
||||||
The spell extractor owns its optional `npcs` registry boundary. Preparation
|
The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
|
||||||
strictly decodes and identity-validates one normalized JSON artifact, emits
|
Preparation strictly decodes and identity-validates one normalized JSON
|
||||||
canonical registry JSON to the spell prompt, and records only its semantic
|
artifact, emits canonical registry JSON to the spell prompt, and records only
|
||||||
digest and count in prepared metadata. The raw reference remains independently
|
its semantic digest and count in prepared metadata. The raw reference remains
|
||||||
tracked by pipeline provenance. An absent registry is represented only by the
|
independently tracked by pipeline provenance. An absent registry is represented
|
||||||
empty prompt value `{"npcs":[]}`; the shared D&D reference fragment is not
|
only by the empty prompt value `{"npcs":[]}`. Spell extraction consumes this
|
||||||
changed.
|
shared registry boundary without changing its public module contract.
|
||||||
|
|
||||||
Generic validators under `internal/modules/generic/validate` provide
|
Generic validators under `internal/modules/generic/validate` provide
|
||||||
unconditional test decisions, JSON syntax validation, and JSON Schema
|
unconditional test decisions, JSON syntax validation, and JSON Schema
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ campaign data; protect both output roots and any checkpoint or debug roots that
|
|||||||
retain derived application data. A registry from another session is allowed,
|
retain derived application data. A registry from another session is allowed,
|
||||||
but its source references are never copied into spell output evidence.
|
but its source references are never copied into spell output evidence.
|
||||||
|
|
||||||
|
## Sequential NPC And Combat Runs
|
||||||
|
|
||||||
|
The maintained [sequential NPC and combat configuration](../examples/dnd-npc-combat-sequential.config.yml)
|
||||||
|
also represents two independent runs. Run `dnd-npcs` first, then bind its
|
||||||
|
normalized `lanes/npcs.json` payload to both combat stages:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/notarius run dnd-npcs \
|
||||||
|
--config examples/dnd-npc-combat-sequential.config.yml \
|
||||||
|
--input examples/seriatim-minimal-transcript.json \
|
||||||
|
--output-dir ./npc-output
|
||||||
|
|
||||||
|
go run ./cmd/notarius run dnd-combat \
|
||||||
|
--config examples/dnd-npc-combat-sequential.config.yml \
|
||||||
|
--input examples/seriatim-minimal-transcript.json \
|
||||||
|
--reference combat.extract.npcs=./npc-output/<run-id>/lanes/npcs.json \
|
||||||
|
--reference combat.normalize.npcs=./npc-output/<run-id>/lanes/npcs.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Extraction and normalization bindings are stage-local and are intentionally
|
||||||
|
specified separately. Notarius does not discover the NPC run, copy its source
|
||||||
|
ranges into combat evidence, or compose the two runs into one workflow. The
|
||||||
|
combat manifest records both reference bindings and the prepared registry's
|
||||||
|
semantic digest/count. Changing the referenced NPC payload, prompt or schema,
|
||||||
|
normalization policy, or registry digest makes affected checkpoint state
|
||||||
|
incompatible; output, checkpoint, and debug roots remain independent sensitive
|
||||||
|
state surfaces.
|
||||||
|
|
||||||
## Chunk-Plan Cache
|
## Chunk-Plan Cache
|
||||||
|
|
||||||
Chunk plans are stored at:
|
Chunk plans are stored at:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# D&D Combat-Turn Extraction
|
# D&D Combat-Turn Extraction
|
||||||
|
|
||||||
Status: Accepted.
|
Status: Complete.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
|
|||||||
@@ -16,18 +16,13 @@ not as committed release dates.
|
|||||||
validator, and normalizer development. Treat model-quality review as an
|
validator, and normalizer development. Treat model-quality review as an
|
||||||
iterative human evaluation aid, not a deterministic correctness gate.
|
iterative human evaluation aid, not a deterministic correctness gate.
|
||||||
|
|
||||||
### Add Sequential D&D Artifacts
|
### Expand Sequential D&D Artifacts
|
||||||
|
|
||||||
- The next proposed increment is
|
|
||||||
[D&D combat-turn extraction](dnd-combat-turn-extraction.md), using earlier NPC
|
|
||||||
output as an explicit identity reference while preserving independent runs.
|
|
||||||
- Add narrative extraction for scene summaries, party actions, and NPCs
|
- Add narrative extraction for scene summaries, party actions, and NPCs
|
||||||
encountered when that output proves useful beyond the dedicated NPC artifact.
|
encountered when that output proves useful beyond the dedicated NPC artifact.
|
||||||
- Define the preferred operational sequence for independent pipelines on the
|
- Continue refining the preferred operational sequence for independent
|
||||||
same transcript. The initial direction is NPCs first, followed by spells and
|
pipelines on the same transcript as additional artifacts are introduced.
|
||||||
combat turns as appropriate, with earlier JSON artifacts supplied to later
|
- Keep sequencing operator- or script-driven initially. Do not require a
|
||||||
runs as references.
|
|
||||||
- Keep this sequencing operator- or script-driven initially. Do not require a
|
|
||||||
general DAG or concurrent cross-lane reconciliation model.
|
general DAG or concurrent cross-lane reconciliation model.
|
||||||
|
|
||||||
### Improve D&D Scene Classification
|
### Improve D&D Scene Classification
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# D&D Combat-Turn Extraction Implementation Plan
|
# D&D Combat-Turn Extraction Implementation Plan
|
||||||
|
|
||||||
Status: Ready for implementation.
|
Status: Complete.
|
||||||
|
|
||||||
Implement this plan in order. The feature policy and target state are defined
|
Implement this plan in order. The feature policy and target state are defined
|
||||||
in [D&D Combat-Turn Extraction](dnd-combat-turn-extraction.md); this document
|
in [D&D Combat-Turn Extraction](dnd-combat-turn-extraction.md); this document
|
||||||
@@ -257,7 +257,7 @@ consumer, while preserving spell behavior.
|
|||||||
existing content-safe error behavior.
|
existing content-safe error behavior.
|
||||||
- Update the internal overview, module, and LLM documentation in this stage so
|
- Update the internal overview, module, and LLM documentation in this stage so
|
||||||
the implemented registry owner and shared prompt-fragment ownership remain
|
the implemented registry owner and shared prompt-fragment ownership remain
|
||||||
accurate; do not claim combat extraction exists yet.
|
accurate while the later combat work remains independently scoped.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
|
|||||||
22
examples/dnd-combat-turns.config.yml
Normal file
22
examples/dnd-combat-turns.config.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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-combat:
|
||||||
|
input: seriatim
|
||||||
|
chunk: generic
|
||||||
|
artifacts:
|
||||||
|
combat:
|
||||||
|
extract:
|
||||||
|
module: dnd/combat-turns
|
||||||
|
retries: 2
|
||||||
|
normalize: dnd/combat-turns
|
||||||
31
examples/dnd-npc-combat-sequential.config.yml
Normal file
31
examples/dnd-npc-combat-sequential.config.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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-npcs:
|
||||||
|
input: seriatim
|
||||||
|
chunk: generic
|
||||||
|
artifacts:
|
||||||
|
npcs:
|
||||||
|
extract:
|
||||||
|
module: dnd/npcs
|
||||||
|
retries: 2
|
||||||
|
normalize: dnd/npcs
|
||||||
|
dnd-combat:
|
||||||
|
input: seriatim
|
||||||
|
chunk: generic
|
||||||
|
artifacts:
|
||||||
|
combat:
|
||||||
|
extract:
|
||||||
|
module: dnd/combat-turns
|
||||||
|
retries: 2
|
||||||
|
normalize: dnd/combat-turns
|
||||||
160
internal/cli/dnd_combat_contract_test.go
Normal file
160
internal/cli/dnd_combat_contract_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProductionCombatConfigurationResolvesTypedLane(t *testing.T) {
|
||||||
|
components := productionTestComponents(t)
|
||||||
|
configPath := repositoryPath("examples", "dnd-combat-turns.config.yml")
|
||||||
|
cfg := loadMaintainedExample(t, configPath)
|
||||||
|
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule {
|
||||||
|
t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule)
|
||||||
|
}
|
||||||
|
if len(effective.ResolvedPipeline.ArtifactLanes) != 1 {
|
||||||
|
t.Fatalf("artifact lanes = %#v, want one combat lane", effective.ResolvedPipeline.ArtifactLanes)
|
||||||
|
}
|
||||||
|
lane := effective.ResolvedPipeline.ArtifactLanes[0]
|
||||||
|
if lane.ID != "combat" || lane.ArtifactKind != dnd.CombatTurnListKind || lane.Extract.Module != combatextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != combatnormalize.Key {
|
||||||
|
t.Fatalf("resolved combat lane = %#v, want typed production composition", lane)
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := catalogFromRegistries(components.registries)
|
||||||
|
extractSpec, ok := catalog.Extractors.Spec(combatextract.Key)
|
||||||
|
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.combat_turns"}) {
|
||||||
|
t.Fatalf("combat extractor spec = %#v, want source and artifact capabilities", extractSpec)
|
||||||
|
}
|
||||||
|
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(combatnormalize.Key, dnd.CombatTurnListKind)
|
||||||
|
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
|
||||||
|
t.Fatalf("combat normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
|
||||||
|
}
|
||||||
|
mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.CombatTurnListKind)
|
||||||
|
if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) {
|
||||||
|
t.Fatalf("combat merger spec = %#v, want merged capability", mergeSpec)
|
||||||
|
}
|
||||||
|
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
|
||||||
|
if !ok || codecSpec.Schema.ID != "notarius.dnd.combat_turns" || codecSpec.Schema.Version != "v1" {
|
||||||
|
t.Fatalf("combat codec spec = %#v, want compatible durable schema", codecSpec)
|
||||||
|
}
|
||||||
|
if !hasReferenceSlot(extractSpec.ReferenceSlots, "npcs") || !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") {
|
||||||
|
t.Fatalf("combat reference slots = %#v / %#v, want stage-local NPC slots", extractSpec.ReferenceSlots, normalizeSpec.ReferenceSlots)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantExtractChain := []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding("generic/valid_json"),
|
||||||
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||||
|
}
|
||||||
|
wantNormalizeChain := []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding("generic/valid_json"),
|
||||||
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||||
|
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||||
|
}
|
||||||
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
|
||||||
|
t.Fatalf("combat extract chain = %#v, want %#v", got, wantExtractChain)
|
||||||
|
}
|
||||||
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) {
|
||||||
|
t.Fatalf("combat normalize chain = %#v, want %#v", got, wantNormalizeChain)
|
||||||
|
}
|
||||||
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 {
|
||||||
|
t.Fatalf("combat merge chain = %#v, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
bound, err := cfg.Resolve(config.ResolveInput{
|
||||||
|
PipelineID: "dnd-combat",
|
||||||
|
Catalog: catalog,
|
||||||
|
ReferenceOverrides: []pipeline.ReferenceBinding{
|
||||||
|
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve(bound references) error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
boundLane := bound.ResolvedPipeline.ArtifactLanes[0]
|
||||||
|
if len(boundLane.ExtractReferences.Bindings) != 1 || len(boundLane.NormalizeReferences.Bindings) != 1 || boundLane.ExtractReferences.Bindings[0].SlotName != "npcs" || boundLane.NormalizeReferences.Bindings[0].SlotName != "npcs" {
|
||||||
|
t.Fatalf("bound combat references = %#v / %#v, want one independent NPC binding per stage", boundLane.ExtractReferences, boundLane.NormalizeReferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductionCombatConfigurationRejectsLooseOptionsAndLaneValidators(t *testing.T) {
|
||||||
|
components := productionTestComponents(t)
|
||||||
|
configPath := repositoryPath("examples", "dnd-combat-turns.config.yml")
|
||||||
|
resolve := func(mutate func(*pipeline.PipelineProfile)) error {
|
||||||
|
cfg := loadMaintainedExample(t, configPath)
|
||||||
|
profile := cfg.Pipelines["dnd-combat"]
|
||||||
|
mutate(&profile)
|
||||||
|
cfg.Pipelines["dnd-combat"] = profile
|
||||||
|
_, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||||
|
lane := profile.Artifacts["combat"]
|
||||||
|
lane.Extract.Options = map[string]any{"unexpected": true}
|
||||||
|
profile.Artifacts["combat"] = lane
|
||||||
|
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||||
|
t.Fatalf("unknown extractor option error = %v, want strict option rejection", err)
|
||||||
|
}
|
||||||
|
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||||
|
lane := profile.Artifacts["combat"]
|
||||||
|
lane.Normalize.Options = map[string]any{"unexpected": true}
|
||||||
|
profile.Artifacts["combat"] = lane
|
||||||
|
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||||
|
t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err)
|
||||||
|
}
|
||||||
|
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||||
|
lane := profile.Artifacts["combat"]
|
||||||
|
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}
|
||||||
|
profile.Artifacts["combat"] = lane
|
||||||
|
}); err == nil || !strings.Contains(err.Error(), "artifact lane level") {
|
||||||
|
t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductionCombatConfigurationResolvesTypedUnconditionalValidators(t *testing.T) {
|
||||||
|
components := productionTestComponents(t)
|
||||||
|
cfg := loadMaintainedExample(t, repositoryPath("examples", "dnd-combat-turns.config.yml"))
|
||||||
|
profile := cfg.Pipelines["dnd-combat"]
|
||||||
|
lane := profile.Artifacts["combat"]
|
||||||
|
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}}
|
||||||
|
lane.Normalize.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}}
|
||||||
|
profile.Artifacts["combat"] = lane
|
||||||
|
cfg.Pipelines["dnd-combat"] = profile
|
||||||
|
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want typed unconditional validators to resolve", err)
|
||||||
|
}
|
||||||
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}) {
|
||||||
|
t.Fatalf("extract override chain = %#v, want typed always-accept", got)
|
||||||
|
}
|
||||||
|
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}) {
|
||||||
|
t.Fatalf("normalize override chain = %#v, want typed always-reject", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasReferenceSlot(slots []contracts.ReferenceSlot, name string) bool {
|
||||||
|
for _, slot := range slots {
|
||||||
|
if slot.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -22,8 +22,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||||
|
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||||
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||||
)
|
)
|
||||||
@@ -34,24 +37,29 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
|
|
||||||
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
|
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
|
||||||
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
|
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
|
||||||
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"})
|
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key})
|
||||||
assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
|
assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
|
||||||
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key})
|
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key})
|
||||||
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
|
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
|
||||||
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||||
"extract/dnd/spells/catalog",
|
"extract/dnd/spells/catalog",
|
||||||
"extract/dnd/spells/shape",
|
"extract/dnd/spells/shape",
|
||||||
"extract/dnd/spells/source_refs",
|
"extract/dnd/spells/source_refs",
|
||||||
"extract/dnd/spells/source_relatedness",
|
"extract/dnd/spells/source_relatedness",
|
||||||
|
"extract/dnd/combat-turns/shape",
|
||||||
|
"extract/dnd/combat-turns/source_refs",
|
||||||
|
"extract/dnd/combat-turns/source_relatedness",
|
||||||
|
"normalize/dnd/combat-turns/invariants",
|
||||||
"generic/always_accept",
|
"generic/always_accept",
|
||||||
"generic/always_reject",
|
"generic/always_reject",
|
||||||
"generic/valid_json",
|
"generic/valid_json",
|
||||||
"generic/valid_json_schema",
|
"generic/valid_json_schema",
|
||||||
})
|
})
|
||||||
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind})
|
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||||
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind})
|
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||||
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind})
|
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||||
assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind})
|
assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind})
|
||||||
|
assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
||||||
|
|
||||||
wantChain := []pipeline.ModuleBinding{
|
wantChain := []pipeline.ModuleBinding{
|
||||||
pipeline.Binding("generic/valid_json"),
|
pipeline.Binding("generic/valid_json"),
|
||||||
@@ -67,6 +75,27 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) {
|
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) {
|
||||||
t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain)
|
t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain)
|
||||||
}
|
}
|
||||||
|
combatExtractChain := []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding("generic/valid_json"),
|
||||||
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||||
|
}
|
||||||
|
combatNormalizeChain := []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding("generic/valid_json"),
|
||||||
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||||
|
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||||
|
}
|
||||||
|
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) {
|
||||||
|
t.Fatalf("combat extract validator chain = %#v, want %#v", got, combatExtractChain)
|
||||||
|
}
|
||||||
|
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
|
||||||
|
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
|
||||||
|
}
|
||||||
|
|
||||||
assetNames := productionAssetNames(t, components.assets.PromptFS)
|
assetNames := productionAssetNames(t, components.assets.PromptFS)
|
||||||
requiredAssets := []string{
|
requiredAssets := []string{
|
||||||
@@ -83,6 +112,12 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
"dnd.spells/sharedassets/common-dnd-system.md",
|
"dnd.spells/sharedassets/common-dnd-system.md",
|
||||||
"dnd.spells/sharedassets/common-dnd-transcript.md",
|
"dnd.spells/sharedassets/common-dnd-transcript.md",
|
||||||
"dnd.spells/task.md",
|
"dnd.spells/task.md",
|
||||||
|
"dnd.combat_turns/dnd.combat_turns.yaml",
|
||||||
|
"dnd.combat_turns/instructions.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-references.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-system.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||||
|
"dnd.combat_turns/task.md",
|
||||||
}
|
}
|
||||||
assertProductionContains(t, "production prompt assets", assetNames, requiredAssets)
|
assertProductionContains(t, "production prompt assets", assetNames, requiredAssets)
|
||||||
|
|
||||||
@@ -95,6 +130,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
if !ok || codecSpec.Kind != dnd.SpellListKind || codecSpec.Schema.ID != spellcodec.SchemaID {
|
if !ok || codecSpec.Kind != dnd.SpellListKind || codecSpec.Schema.ID != spellcodec.SchemaID {
|
||||||
t.Fatalf("catalog codec spec = %#v, ok=%t, want typed D&D spell codec", codecSpec, ok)
|
t.Fatalf("catalog codec spec = %#v, ok=%t, want typed D&D spell codec", codecSpec, ok)
|
||||||
}
|
}
|
||||||
|
combatCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
|
||||||
|
if !ok || combatCodecSpec.Kind != dnd.CombatTurnListKind || combatCodecSpec.Schema.ID != combatcodec.SchemaID {
|
||||||
|
t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok)
|
||||||
|
}
|
||||||
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||||
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
|
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
|
||||||
}
|
}
|
||||||
@@ -466,6 +505,8 @@ func maintainedExampleFiles(t *testing.T) []maintainedExample {
|
|||||||
{name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml"), pipelineIDs: []string{"dnd-session"}},
|
{name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml"), pipelineIDs: []string{"dnd-session"}},
|
||||||
{name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml"), pipelineIDs: []string{"dnd-session"}},
|
{name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml"), pipelineIDs: []string{"dnd-session"}},
|
||||||
{name: "sequential", path: repositoryPath("examples", "dnd-npc-spell-sequential.config.yml"), pipelineIDs: []string{"dnd-npcs", "dnd-spells"}},
|
{name: "sequential", path: repositoryPath("examples", "dnd-npc-spell-sequential.config.yml"), pipelineIDs: []string{"dnd-npcs", "dnd-spells"}},
|
||||||
|
{name: "combat", path: repositoryPath("examples", "dnd-combat-turns.config.yml"), pipelineIDs: []string{"dnd-combat"}},
|
||||||
|
{name: "npc-combat-sequential", path: repositoryPath("examples", "dnd-npc-combat-sequential.config.yml"), pipelineIDs: []string{"dnd-combat", "dnd-npcs"}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "notarius.dnd.combat_turns",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["combat_turns"],
|
||||||
|
"properties": {
|
||||||
|
"combat_turns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
|
||||||
|
"properties": {
|
||||||
|
"actor": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"turn_kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"]
|
||||||
|
},
|
||||||
|
"round": {
|
||||||
|
"type": ["integer", "null"],
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["category", "declaration", "targets", "resolution"],
|
||||||
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"]
|
||||||
|
},
|
||||||
|
"declaration": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
internal/modules/dnd/codec/combatturns/codec.go
Normal file
259
internal/modules/dnd/codec/combatturns/codec.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchemaID = "notarius.dnd.combat_turns"
|
||||||
|
SchemaName = "notarius_dnd_combat_turns_v1"
|
||||||
|
SchemaVersion = "v1"
|
||||||
|
MediaType = "application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/schemas/dnd_combat_turns.v1.json
|
||||||
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
|
var _ contracts.ArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil)
|
||||||
|
|
||||||
|
type Codec struct{}
|
||||||
|
|
||||||
|
func New() *Codec { return &Codec{} }
|
||||||
|
|
||||||
|
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.CombatTurnListKind }
|
||||||
|
|
||||||
|
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||||
|
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_combat_turns.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.CombatTurnList) map[string]any {
|
||||||
|
return map[string]any{"combat_turn_count": len(value.CombatTurns)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) Encode(value dnd.CombatTurnList) ([]byte, error) {
|
||||||
|
if err := validate(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode dnd combat turn 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.CombatTurnList) ([]byte, error) {
|
||||||
|
content, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode dnd combat turn list: %w", err)
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
|
||||||
|
value, err := c.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
return dnd.CombatTurnList{}, err
|
||||||
|
}
|
||||||
|
if err := validate(value); err != nil {
|
||||||
|
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||||
|
// validators have approved it. Nullable round and resolution fields are
|
||||||
|
// decoded through raw values so a missing key is not confused with null.
|
||||||
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var wire combatTurnListWire
|
||||||
|
if err := decoder.Decode(&wire); err != nil {
|
||||||
|
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values")
|
||||||
|
}
|
||||||
|
|
||||||
|
value := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, len(wire.CombatTurns))}
|
||||||
|
if wire.CombatTurns == nil {
|
||||||
|
value.CombatTurns = nil
|
||||||
|
}
|
||||||
|
for index, turn := range wire.CombatTurns {
|
||||||
|
round, err := decodeNullableInt(turn.Round, fmt.Sprintf("combat_turns[%d].round", index))
|
||||||
|
if err != nil {
|
||||||
|
return dnd.CombatTurnList{}, err
|
||||||
|
}
|
||||||
|
actions := make([]dnd.CombatAction, len(turn.Actions))
|
||||||
|
if turn.Actions == nil {
|
||||||
|
actions = nil
|
||||||
|
}
|
||||||
|
for actionIndex, action := range turn.Actions {
|
||||||
|
resolution, err := decodeNullableString(action.Resolution, fmt.Sprintf("combat_turns[%d].actions[%d].resolution", index, actionIndex))
|
||||||
|
if err != nil {
|
||||||
|
return dnd.CombatTurnList{}, err
|
||||||
|
}
|
||||||
|
actions[actionIndex] = dnd.CombatAction{
|
||||||
|
Category: action.Category,
|
||||||
|
Declaration: action.Declaration,
|
||||||
|
Targets: action.Targets,
|
||||||
|
Resolution: resolution,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.CombatTurns[index] = dnd.CombatTurn{
|
||||||
|
Actor: turn.Actor,
|
||||||
|
TurnKind: turn.TurnKind,
|
||||||
|
Round: round,
|
||||||
|
Actions: actions,
|
||||||
|
Summary: turn.Summary,
|
||||||
|
SourceRefs: turn.SourceRefs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type combatTurnListWire struct {
|
||||||
|
CombatTurns []combatTurnWire `json:"combat_turns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type combatTurnWire struct {
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
TurnKind dnd.CombatTurnKind `json:"turn_kind"`
|
||||||
|
Round json.RawMessage `json:"round"`
|
||||||
|
Actions []combatActionWire `json:"actions"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type combatActionWire struct {
|
||||||
|
Category dnd.CombatActionCategory `json:"category"`
|
||||||
|
Declaration string `json:"declaration"`
|
||||||
|
Targets []string `json:"targets"`
|
||||||
|
Resolution json.RawMessage `json:"resolution"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeNullableInt(raw json.RawMessage, field string) (*int, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s must be present", field)
|
||||||
|
}
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var value int
|
||||||
|
if err := json.Unmarshal(trimmed, &value); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s must be an integer or null: %w", field, err)
|
||||||
|
}
|
||||||
|
return &value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeNullableString(raw json.RawMessage, field string) (*string, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s must be present", field)
|
||||||
|
}
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
if err := json.Unmarshal(trimmed, &value); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s must be a string or null: %w", field, err)
|
||||||
|
}
|
||||||
|
return &value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(value dnd.CombatTurnList) error {
|
||||||
|
if value.CombatTurns == nil {
|
||||||
|
return fmt.Errorf("combat_turns must be present")
|
||||||
|
}
|
||||||
|
for index, turn := range value.CombatTurns {
|
||||||
|
prefix := fmt.Sprintf("combat_turns[%d]", index)
|
||||||
|
if strings.TrimSpace(turn.Actor) == "" {
|
||||||
|
return fmt.Errorf("%s.actor must not be empty", prefix)
|
||||||
|
}
|
||||||
|
if !validTurnKind(turn.TurnKind) {
|
||||||
|
return fmt.Errorf("%s.turn_kind must be supported", prefix)
|
||||||
|
}
|
||||||
|
if turn.Round != nil && *turn.Round <= 0 {
|
||||||
|
return fmt.Errorf("%s.round must be positive or null", prefix)
|
||||||
|
}
|
||||||
|
if len(turn.Actions) == 0 {
|
||||||
|
return fmt.Errorf("%s.actions must contain at least one action", prefix)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(turn.Summary) == "" {
|
||||||
|
return fmt.Errorf("%s.summary must not be empty", prefix)
|
||||||
|
}
|
||||||
|
if len(turn.SourceRefs) == 0 {
|
||||||
|
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
|
||||||
|
}
|
||||||
|
for actionIndex, action := range turn.Actions {
|
||||||
|
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
|
||||||
|
if !validActionCategory(action.Category) {
|
||||||
|
return fmt.Errorf("%s.category must be supported", actionPrefix)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(action.Declaration) == "" {
|
||||||
|
return fmt.Errorf("%s.declaration must not be empty", actionPrefix)
|
||||||
|
}
|
||||||
|
if action.Targets == nil {
|
||||||
|
return fmt.Errorf("%s.targets must be present", actionPrefix)
|
||||||
|
}
|
||||||
|
for targetIndex, target := range action.Targets {
|
||||||
|
if strings.TrimSpace(target) == "" {
|
||||||
|
return fmt.Errorf("%s.targets[%d] must not be empty", actionPrefix, targetIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" {
|
||||||
|
return fmt.Errorf("%s.resolution must not be empty or null", actionPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for refIndex, ref := range turn.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 validTurnKind(value dnd.CombatTurnKind) bool {
|
||||||
|
switch value {
|
||||||
|
case dnd.CombatTurnKindTurn, dnd.CombatTurnKindReaction, dnd.CombatTurnKindLegendaryAction, dnd.CombatTurnKindLairAction, dnd.CombatTurnKindOther:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validActionCategory(value dnd.CombatActionCategory) bool {
|
||||||
|
switch value {
|
||||||
|
case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
287
internal/modules/dnd/codec/combatturns/codec_test.go
Normal file
287
internal/modules/dnd/codec/combatturns/codec_test.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"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.CombatTurnList {
|
||||||
|
round := 1
|
||||||
|
resolution := "The wight is hit."
|
||||||
|
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: "Aria",
|
||||||
|
TurnKind: dnd.CombatTurnKindTurn,
|
||||||
|
Round: &round,
|
||||||
|
Actions: []dnd.CombatAction{{
|
||||||
|
Category: dnd.CombatActionCategoryAttack,
|
||||||
|
Declaration: "Aria swings her sword",
|
||||||
|
Targets: []string{"wight"},
|
||||||
|
Resolution: &resolution,
|
||||||
|
}},
|
||||||
|
Summary: "Aria attacks the wight.",
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read durable fixture: %v", err)
|
||||||
|
}
|
||||||
|
codec := New()
|
||||||
|
value, err := codec.Decode(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode() error = %v, want nil", 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, want nil", err)
|
||||||
|
}
|
||||||
|
var compact bytes.Buffer
|
||||||
|
if err := json.Compact(&compact, raw); err != nil {
|
||||||
|
t.Fatalf("compact durable fixture: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(encoded, compact.Bytes()) {
|
||||||
|
t.Fatalf("Encode() = %s, want stable durable JSON %s", encoded, compact.Bytes())
|
||||||
|
}
|
||||||
|
second, err := codec.Encode(value)
|
||||||
|
if err != nil || !bytes.Equal(second, encoded) {
|
||||||
|
t.Fatalf("second Encode() = %s, %v; want deterministic bytes", second, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
schema := codec.Schema()
|
||||||
|
if codec.Kind() != dnd.CombatTurnListKind || 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, want durable combat-turn schema", schema)
|
||||||
|
}
|
||||||
|
var document map[string]any
|
||||||
|
if err := json.Unmarshal(schema.JSONSchema, &document); err != nil || document["$id"] != SchemaID {
|
||||||
|
t.Fatalf("durable schema document = %#v, %v", document, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := pipeline.NewArtifactCodecRegistry()
|
||||||
|
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||||
|
t.Fatalf("RegisterArtifactCodec() error = %v", err)
|
||||||
|
}
|
||||||
|
spec, ok := registry.Spec(dnd.CombatTurnListKind)
|
||||||
|
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||||
|
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}],"summary":"attack","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "malformed", raw: `{`, want: "decode dnd combat turn list"},
|
||||||
|
{name: "unknown top-level", raw: `{"combat_turns":[],"unexpected":true}`, want: "unknown field"},
|
||||||
|
{name: "unknown turn field", raw: strings.Replace(validJSON, `"summary":"attack"`, `"summary":"attack","unexpected":true`, 1), want: "unknown field"},
|
||||||
|
{name: "unknown action field", raw: strings.Replace(validJSON, `"resolution":"hits"`, `"resolution":"hits","unexpected":true`, 1), want: "unknown field"},
|
||||||
|
{name: "unknown source reference field", raw: strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), want: "unknown field"},
|
||||||
|
{name: "trailing", raw: `{"combat_turns":[]} {}`, want: "multiple JSON values"},
|
||||||
|
{name: "missing top-level array", raw: `{}`, want: "combat_turns must be present"},
|
||||||
|
{name: "missing round", raw: strings.Replace(validJSON, `,"round":1`, ``, 1), want: "round must be present"},
|
||||||
|
{name: "missing resolution", raw: strings.Replace(validJSON, `,"resolution":"hits"`, ``, 1), want: "resolution must be present"},
|
||||||
|
{name: "invalid round type", raw: strings.Replace(validJSON, `"round":1`, `"round":1.5`, 1), want: "round must be an integer or null"},
|
||||||
|
{name: "unsupported turn kind", raw: strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), want: "turn_kind must be supported"},
|
||||||
|
{name: "empty actions", raw: strings.Replace(validJSON, `"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}]`, `"actions":[]`, 1), want: "actions must contain at least one action"},
|
||||||
|
{name: "empty target", raw: strings.Replace(validJSON, `"targets":["wight"]`, `"targets":[" "]`, 1), want: "targets[0] must not be empty"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := codec.Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
|
||||||
|
round := -1
|
||||||
|
resolution := " "
|
||||||
|
candidate := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: " ",
|
||||||
|
TurnKind: "unsupported",
|
||||||
|
Round: &round,
|
||||||
|
Actions: []dnd.CombatAction{{
|
||||||
|
Category: "unsupported",
|
||||||
|
Declaration: " ",
|
||||||
|
Targets: nil,
|
||||||
|
Resolution: &resolution,
|
||||||
|
}},
|
||||||
|
Summary: " ",
|
||||||
|
SourceRefs: []source.SourceRef{{
|
||||||
|
SourceID: "",
|
||||||
|
StartUnitID: 0,
|
||||||
|
EndUnitID: -1,
|
||||||
|
}},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
content, err := New().EncodeCandidate(candidate)
|
||||||
|
if err != nil || !json.Valid(content) {
|
||||||
|
t.Fatalf("EncodeCandidate() = %s, %v; want JSON", 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 TestCodecCandidatePreservesNilAndPresentEmptyArrays(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
for name, value := range map[string]dnd.CombatTurnList{
|
||||||
|
"nil combat turns": {},
|
||||||
|
"empty combat turns": {CombatTurns: []dnd.CombatTurn{}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
content, err := codec.EncodeCandidate(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||||
|
}
|
||||||
|
decoded, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||||
|
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutTargets := validList()
|
||||||
|
withoutTargets.CombatTurns[0].Actions[0].Targets = nil
|
||||||
|
withEmptyTargets := validList()
|
||||||
|
withEmptyTargets.CombatTurns[0].Actions[0].Targets = []string{}
|
||||||
|
for name, value := range map[string]dnd.CombatTurnList{
|
||||||
|
"nil targets": withoutTargets,
|
||||||
|
"empty targets": withEmptyTargets,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
content, err := codec.EncodeCandidate(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||||
|
}
|
||||||
|
decoded, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||||
|
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutActions := validList()
|
||||||
|
withoutActions.CombatTurns[0].Actions = nil
|
||||||
|
withEmptyActions := validList()
|
||||||
|
withEmptyActions.CombatTurns[0].Actions = []dnd.CombatAction{}
|
||||||
|
withoutSourceRefs := validList()
|
||||||
|
withoutSourceRefs.CombatTurns[0].SourceRefs = nil
|
||||||
|
withEmptySourceRefs := validList()
|
||||||
|
withEmptySourceRefs.CombatTurns[0].SourceRefs = []source.SourceRef{}
|
||||||
|
for name, value := range map[string]dnd.CombatTurnList{
|
||||||
|
"nil actions": withoutActions,
|
||||||
|
"empty actions": withEmptyActions,
|
||||||
|
"nil source refs": withoutSourceRefs,
|
||||||
|
"empty source refs": withEmptySourceRefs,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
content, err := codec.EncodeCandidate(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||||
|
}
|
||||||
|
decoded, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||||
|
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecAcceptsExplicitNullableFieldsAndEmptyTargets(t *testing.T) {
|
||||||
|
value := validList()
|
||||||
|
value.CombatTurns[0].Round = nil
|
||||||
|
value.CombatTurns[0].Actions[0].Resolution = nil
|
||||||
|
value.CombatTurns[0].Actions[0].Targets = []string{}
|
||||||
|
|
||||||
|
codec := New()
|
||||||
|
content, err := codec.Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode() error = %v, want nil for explicit nullable fields", err)
|
||||||
|
}
|
||||||
|
decoded, err := codec.Decode(content)
|
||||||
|
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||||
|
t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value dnd.CombatTurnList
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil combat turns", value: dnd.CombatTurnList{}, want: "combat_turns must be present"},
|
||||||
|
{name: "empty actor", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }), want: "actor must not be empty"},
|
||||||
|
{name: "unsupported turn kind", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unsupported" }), want: "turn_kind must be supported"},
|
||||||
|
{name: "non-positive round", value: mutate(validList(), func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }), want: "round must be positive or null"},
|
||||||
|
{name: "nil actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }), want: "actions must contain at least one action"},
|
||||||
|
{name: "empty actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = []dnd.CombatAction{} }), want: "actions must contain at least one action"},
|
||||||
|
{name: "empty summary", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }), want: "summary must not be empty"},
|
||||||
|
{name: "nil source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }), want: "source_refs must contain at least one reference"},
|
||||||
|
{name: "empty source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = []source.SourceRef{} }), want: "source_refs must contain at least one reference"},
|
||||||
|
{name: "unsupported action category", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unsupported" }), want: "category must be supported"},
|
||||||
|
{name: "empty declaration", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }), want: "declaration must not be empty"},
|
||||||
|
{name: "nil targets", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }), want: "targets must be present"},
|
||||||
|
{name: "empty target", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }), want: "targets[0] must not be empty"},
|
||||||
|
{name: "empty resolution", value: mutate(validList(), func(value *dnd.CombatTurnList) {
|
||||||
|
resolution := " "
|
||||||
|
value.CombatTurns[0].Actions[0].Resolution = &resolution
|
||||||
|
}), want: "resolution must not be empty or null"},
|
||||||
|
{name: "empty source ID", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].SourceID = " " }), want: "source_id must not be empty"},
|
||||||
|
{name: "non-positive source start", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), want: "start_unit_id must be positive"},
|
||||||
|
{name: "non-positive source end", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), want: "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 TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
first := codec.Schema()
|
||||||
|
first.JSONSchema[0] = '['
|
||||||
|
second := codec.Schema()
|
||||||
|
if !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["combat_turn_count"] != 1 {
|
||||||
|
t.Fatalf("Metadata() = %#v, want only combat_turn_count", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutate(value dnd.CombatTurnList, change func(*dnd.CombatTurnList)) dnd.CombatTurnList {
|
||||||
|
change(&value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
1
internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json
vendored
Normal file
1
internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"Aria swings her sword","targets":["wight"],"resolution":"The wight is hit."}],"summary":"Aria attacks the wight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]}
|
||||||
6
internal/modules/dnd/extract/combatturns/assets.go
Normal file
6
internal/modules/dnd/extract/combatturns/assets.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed assets/schemas/dnd_combat_turns_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md
|
||||||
|
var embeddedAssets embed.FS
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
id: dnd.combat_turns
|
||||||
|
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: false
|
||||||
|
content_type: application/json
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content_file: ./sharedassets/common-dnd-system.md
|
||||||
|
- role: user
|
||||||
|
content_file: ./sharedassets/common-dnd-transcript.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
|
||||||
|
- role: user
|
||||||
|
content_file: ./task.md
|
||||||
|
- role: user
|
||||||
|
content_file: ./instructions.md
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: dnd_combat_turns_llm.v1.json
|
||||||
|
repair_attempts: 0
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Return exactly one JSON object and no explanatory text.
|
||||||
|
|
||||||
|
Return the combat_turns array even when no combat turn is established. Return
|
||||||
|
one or more actions for every turn. Use one of the supported turn_kind and
|
||||||
|
action category values. Set round to null when the transcript does not state
|
||||||
|
an explicit or unambiguous positive round number. Set resolution to null when
|
||||||
|
the transcript establishes the declaration but not an immediate resolution.
|
||||||
|
|
||||||
|
Every source reference must contain start_unit_id and end_unit_id from the
|
||||||
|
provided transcript. Do not add source_id; the extraction mapper assigns the
|
||||||
|
current source identity. Do not include fields not defined by the response
|
||||||
|
schema.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript.
|
||||||
|
|
||||||
|
Include a record only when the transcript establishes that an in-world
|
||||||
|
participant takes a combat turn or performs a discrete interrupting combat
|
||||||
|
event. Reactions, legendary actions, lair actions, and other out-of-turn events
|
||||||
|
belong at the point where they occur in transcript chronology.
|
||||||
|
|
||||||
|
Report only the declaration and its immediate observed resolution. Immediate
|
||||||
|
resolution may include directly associated rolls, damage, healing, movement,
|
||||||
|
conditions, target outcomes, or an interruption. Do not follow consequences
|
||||||
|
that occur on later turns or elsewhere in the scene.
|
||||||
|
|
||||||
|
Exclude initiative setup without a turn or combat event, tactical planning,
|
||||||
|
table talk, rules lookup, hypothetical actions, abandoned declarations, recap
|
||||||
|
of combat outside the current passage, and downstream consequences.
|
||||||
|
|
||||||
|
Use only the supplied transcript as evidence. Do not infer a round, target,
|
||||||
|
roll, amount, condition, outcome, or action classification from D&D rules
|
||||||
|
knowledge. Preserve the session as played; attribute relevant nonstandard
|
||||||
|
rulings to the GM or table.
|
||||||
|
|
||||||
|
The actor must be the in-world character or creature, not a player, transcript
|
||||||
|
speaker, or GM. Use player, party, glossary, and NPC reference material only
|
||||||
|
to disambiguate identities. Reference material is context, never combat
|
||||||
|
evidence. Unmatched actors and targets remain permitted.
|
||||||
|
|
||||||
|
For every factual detail in a turn, cite all supporting transcript units in the
|
||||||
|
turn-level source_refs collection. Use narrow ranges when evidence is
|
||||||
|
non-contiguous. Numeric source-unit IDs identify transcript units; they do not
|
||||||
|
establish chronology outside the supplied transcript.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "notarius.dnd.combat_turns.llm",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["combat_turns"],
|
||||||
|
"properties": {
|
||||||
|
"combat_turns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
|
||||||
|
"properties": {
|
||||||
|
"actor": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"turn_kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"]
|
||||||
|
},
|
||||||
|
"round": {
|
||||||
|
"type": ["integer", "null"],
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["category", "declaration", "targets", "resolution"],
|
||||||
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"]
|
||||||
|
},
|
||||||
|
"declaration": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"source_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
|
"properties": {
|
||||||
|
"start_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"end_unit_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
internal/modules/dnd/extract/combatturns/canonicalize.go
Normal file
156
internal/modules/dnd/extract/combatturns/canonicalize.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
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.CombatTurns {
|
||||||
|
canonicalizeCombatTurn(&response.CombatTurns[index])
|
||||||
|
}
|
||||||
|
sort.SliceStable(response.CombatTurns, func(i, j int) bool {
|
||||||
|
left, leftOK := earliestSourcePosition(doc, response.CombatTurns[i])
|
||||||
|
right, rightOK := earliestSourcePosition(doc, response.CombatTurns[j])
|
||||||
|
if leftOK != rightOK {
|
||||||
|
return leftOK
|
||||||
|
}
|
||||||
|
if !leftOK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return left < right
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeCombatTurn(turn *combatTurnResponse) {
|
||||||
|
if turn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.SliceStable(turn.SourceRefs, func(i, j int) bool {
|
||||||
|
left := turn.SourceRefs[i]
|
||||||
|
right := turn.SourceRefs[j]
|
||||||
|
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
|
||||||
|
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
||||||
|
}
|
||||||
|
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
||||||
|
})
|
||||||
|
turn.SourceRefs = dedupeSourceRefs(turn.SourceRefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeSourceRefs(refs []combatSourceRefResponse) []combatSourceRefResponse {
|
||||||
|
if len(refs) < 2 {
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
out := refs[:0]
|
||||||
|
var previous combatSourceRefResponse
|
||||||
|
for index, ref := range refs {
|
||||||
|
if index > 0 && sameSourceRef(previous, ref) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ref)
|
||||||
|
previous = ref
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool {
|
||||||
|
return left.StartUnitID.Int() == right.StartUnitID.Int() && left.EndUnitID.Int() == right.EndUnitID.Int()
|
||||||
|
}
|
||||||
|
|
||||||
|
func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) {
|
||||||
|
if doc == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
earliest := 0
|
||||||
|
found := false
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()}
|
||||||
|
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(ref interface{ Int() int }) int {
|
||||||
|
value := ref.Int()
|
||||||
|
if value <= 0 {
|
||||||
|
return int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.CombatTurnList {
|
||||||
|
turns := make([]dnd.CombatTurn, len(response.CombatTurns))
|
||||||
|
for index, turn := range response.CombatTurns {
|
||||||
|
turns[index] = dnd.CombatTurn{
|
||||||
|
Actor: turn.Actor,
|
||||||
|
TurnKind: dnd.CombatTurnKind(turn.TurnKind),
|
||||||
|
Round: cloneIntPointer(turn.Round),
|
||||||
|
Actions: canonicalActions(turn.Actions),
|
||||||
|
Summary: turn.Summary,
|
||||||
|
SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if response.CombatTurns == nil {
|
||||||
|
turns = nil
|
||||||
|
}
|
||||||
|
return dnd.CombatTurnList{CombatTurns: turns}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalActions(actions []combatActionResponse) []dnd.CombatAction {
|
||||||
|
if actions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]dnd.CombatAction, len(actions))
|
||||||
|
for index, action := range actions {
|
||||||
|
out[index] = dnd.CombatAction{
|
||||||
|
Category: dnd.CombatActionCategory(action.Category),
|
||||||
|
Declaration: action.Declaration,
|
||||||
|
Targets: append([]string(nil), action.Targets...),
|
||||||
|
Resolution: cloneStringPointer(action.Resolution),
|
||||||
|
}
|
||||||
|
if action.Targets != nil {
|
||||||
|
out[index].Targets = append([]string{}, action.Targets...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalSourceRefs(refs []combatSourceRefResponse, 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.Int(), EndUnitID: ref.EndUnitID.Int()}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneIntPointer(value *int) *int {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := *value
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringPointer(value *string) *string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := *value
|
||||||
|
return &out
|
||||||
|
}
|
||||||
239
internal/modules/dnd/extract/combatturns/extractor.go
Normal file
239
internal/modules/dnd/extract/combatturns/extractor.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"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/combat-turns"
|
||||||
|
ArtifactType = "dnd.combat_turn"
|
||||||
|
mappingPolicy = "dnd.combat_turns.extract_mapping.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
|
||||||
|
NPCRegistryMaxBytes = npcregistry.MaxBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
var requiredCapabilities = []string{
|
||||||
|
"chunks",
|
||||||
|
"source.transcript",
|
||||||
|
}
|
||||||
|
|
||||||
|
var providedCapabilities = []string{
|
||||||
|
"dnd.combat_turns",
|
||||||
|
}
|
||||||
|
|
||||||
|
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
||||||
|
Glossary: "Optional campaign glossary reference material used only for disambiguation.",
|
||||||
|
Party: "Optional party roster reference material used only for disambiguation.",
|
||||||
|
Players: "Optional player list reference material used only for disambiguation.",
|
||||||
|
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceSlots() []contracts.ReferenceSlot {
|
||||||
|
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||||
|
slots = append(slots, contracts.ReferenceSlot{
|
||||||
|
Name: NPCRegistryReferenceSlot,
|
||||||
|
Description: "Optional normalized NPC registry used for canonical actor and target grounding.",
|
||||||
|
AcceptedMediaTypes: []string{"application/json"},
|
||||||
|
MaxBytes: NPCRegistryMaxBytes,
|
||||||
|
})
|
||||||
|
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ contracts.Extractor[dnd.CombatTurnList] = (*Extractor)(nil)
|
||||||
|
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
||||||
|
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
|
||||||
|
type Extractor struct {
|
||||||
|
llm contracts.StructuredLLMClient
|
||||||
|
npcRegistry *npcregistry.Registry
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
npcRegistry, err := npcregistry.Resolve(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,
|
||||||
|
npcRegistry: npcRegistry,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if e.npcRegistry.Bound() {
|
||||||
|
metadata["npc_registry_digest"] = e.npcRegistry.Digest()
|
||||||
|
metadata["npc_count"] = e.npcRegistry.Count()
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fingerprints := []pipeline.CheckpointFingerprint{
|
||||||
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
|
{Name: "mapping_policy", Value: mappingPolicy},
|
||||||
|
}
|
||||||
|
if e.npcRegistry.Bound() {
|
||||||
|
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: e.npcRegistry.Digest()})
|
||||||
|
}
|
||||||
|
return fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.CombatTurnList], error) {
|
||||||
|
if e == nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("extractor must not be nil")
|
||||||
|
}
|
||||||
|
if e.llm == nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("LLM client must not be nil")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context must not be nil")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context error before extraction: %w", err)
|
||||||
|
}
|
||||||
|
if req.Source == nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("source must not be nil")
|
||||||
|
}
|
||||||
|
if req.Chunk == nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk must not be nil")
|
||||||
|
}
|
||||||
|
if len(req.Chunk.Units) == 0 {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
||||||
|
}
|
||||||
|
sourceInput, err := chunkSourceInput(req)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var response extractionResponse
|
||||||
|
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||||
|
inputs[NPCRegistryReferenceSlot] = e.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.CombatTurnList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
|
}
|
||||||
|
canonicalizeResponse(&response, req.Source)
|
||||||
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkSourceInput(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||||
|
material := req.SourceInput.Clone()
|
||||||
|
if len(material.Content) == 0 {
|
||||||
|
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(material.Content, req.Chunk.Content) {
|
||||||
|
return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID)
|
||||||
|
}
|
||||||
|
if material.Name == "" {
|
||||||
|
material.Name = "source"
|
||||||
|
}
|
||||||
|
if material.MediaType == "" {
|
||||||
|
material.MediaType = req.Chunk.MediaType
|
||||||
|
}
|
||||||
|
if material.SizeBytes == 0 {
|
||||||
|
material.SizeBytes = int64(len(material.Content))
|
||||||
|
}
|
||||||
|
return material, 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.CombatTurnListKind,
|
||||||
|
ReferenceSlots: referenceSlots(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(registry *pipeline.ExtractorRegistry) error {
|
||||||
|
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.CombatTurnList], 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 combat turns extractor: "+format, args...)
|
||||||
|
}
|
||||||
367
internal/modules/dnd/extract/combatturns/extractor_test.go
Normal file
367
internal/modules/dnd/extract/combatturns/extractor_test.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
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"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||||
|
round := 3
|
||||||
|
resolution := "The ogre falls back."
|
||||||
|
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||||
|
{
|
||||||
|
Actor: "Borin", TurnKind: "turn", Round: &round,
|
||||||
|
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}},
|
||||||
|
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Actor: "Aria", TurnKind: "reaction", Round: nil,
|
||||||
|
Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}},
|
||||||
|
Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{
|
||||||
|
{StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)},
|
||||||
|
{StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Actor: "Unknown", TurnKind: "other", Round: nil,
|
||||||
|
Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}},
|
||||||
|
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromString("missing"), EndUnitID: shared.UnitRefFromString("missing")}},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor, result.Value.CombatTurns[2].Actor}; !reflect.DeepEqual(got, []string{"Aria", "Borin", "Unknown"}) {
|
||||||
|
t.Fatalf("actor order = %#v, want source-position order with invalid evidence last", got)
|
||||||
|
}
|
||||||
|
wantRefs := []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 10, EndUnitID: 10}}
|
||||||
|
if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
|
||||||
|
t.Fatalf("canonical refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
|
||||||
|
}
|
||||||
|
if result.Value.CombatTurns[0].Round != nil || result.Value.CombatTurns[0].Actions[0].Resolution == nil || *result.Value.CombatTurns[0].Actions[0].Resolution != resolution {
|
||||||
|
t.Fatalf("nullable fields = %#v, want nil round and preserved resolution", result.Value.CombatTurns[0])
|
||||||
|
}
|
||||||
|
if ref := result.Value.CombatTurns[2].SourceRefs[0]; ref != (source.SourceRef{SourceID: "session-alpha"}) {
|
||||||
|
t.Fatalf("invalid evidence = %#v, want source identity and invalid range preserved", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(client.requests) != 1 {
|
||||||
|
t.Fatalf("LLM calls = %d, want one call per chunk", len(client.requests))
|
||||||
|
}
|
||||||
|
request := client.requests[0]
|
||||||
|
if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.SessionID != "session-123" || request.ProfileID != "profile-combat" {
|
||||||
|
t.Fatalf("LLM request identity = %#v", request)
|
||||||
|
}
|
||||||
|
transcript := request.Inputs["transcript"]
|
||||||
|
if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" || !reflect.DeepEqual(transcript.Content, extractionRequest().Chunk.Content) {
|
||||||
|
t.Fatalf("transcript input = %#v, want chunk-scoped source input", transcript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
||||||
|
negativeRound := -1
|
||||||
|
emptyResolution := " "
|
||||||
|
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||||
|
{
|
||||||
|
Actor: " ", TurnKind: "unsupported", Round: &negativeRound,
|
||||||
|
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}},
|
||||||
|
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("not-a-unit")}},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil for candidate values", err)
|
||||||
|
}
|
||||||
|
turn := result.Value.CombatTurns[0]
|
||||||
|
if turn.Actor != " " || turn.TurnKind != "unsupported" || turn.Round == nil || *turn.Round != negativeRound || turn.Summary != " " {
|
||||||
|
t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn)
|
||||||
|
}
|
||||||
|
if turn.Actions == nil || turn.Actions[0].Targets != nil || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != emptyResolution {
|
||||||
|
t.Fatalf("invalid action fields = %#v, want preserved candidate values", turn.Actions[0])
|
||||||
|
}
|
||||||
|
if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
|
||||||
|
t.Fatalf("invalid source ref = %#v, want invalid range preserved", turn.SourceRefs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPassesReferencesAndNPCGroundingWithoutUsingItAsEvidence(t *testing.T) {
|
||||||
|
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}}
|
||||||
|
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
|
"players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Alice: Aria")}}},
|
||||||
|
"party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria: cleric")}}},
|
||||||
|
"glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("ogre: a large foe")}}},
|
||||||
|
NPCRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: "application/json", Content: npcRegistryJSON(t)}}},
|
||||||
|
}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = references
|
||||||
|
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
inputs := client.requests[0].Inputs
|
||||||
|
if string(inputs["players"].Content) != "Alice: Aria" || string(inputs["party"].Content) != "Aria: cleric" || string(inputs["glossary"].Content) != "ogre: a large foe" {
|
||||||
|
t.Fatalf("reference inputs = %#v, want configured references", inputs)
|
||||||
|
}
|
||||||
|
registryInput := inputs[NPCRegistryReferenceSlot]
|
||||||
|
if registryInput.Name != NPCRegistryReferenceSlot || registryInput.MediaType != "application/json" || !strings.Contains(string(registryInput.Content), "Mira Thorn") {
|
||||||
|
t.Fatalf("NPC registry input = %#v, want canonical registry grounding", registryInput)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(inputs["transcript"].Content), "Aria: cleric") {
|
||||||
|
t.Fatal("transcript input contains reference content")
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := newExtractor(t, &fakeCombatTurnsLLMClient{}, references).ManifestMetadata()
|
||||||
|
if metadata["npc_count"] != 1 || !strings.HasPrefix(metadata["npc_registry_digest"].(string), "sha256:") {
|
||||||
|
t.Fatalf("bound registry metadata = %#v, want digest and count", metadata)
|
||||||
|
}
|
||||||
|
fingerprints := newExtractor(t, &fakeCombatTurnsLLMClient{}, references).CheckpointFingerprints()
|
||||||
|
if len(fingerprints) != 4 || fingerprints[3].Name != "npc_registry" {
|
||||||
|
t.Fatalf("bound fingerprints = %#v, want local identities plus NPC registry", fingerprints)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(encoded), "Mira Thorn") || strings.Contains(string(encoded), "session-alpha") {
|
||||||
|
t.Fatalf("metadata leaked content: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUnboundRegistryUsesExactEmptyPromptAndOmitsIdentity(t *testing.T) {
|
||||||
|
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}}
|
||||||
|
extractor := newExtractor(t, client)
|
||||||
|
if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
|
||||||
|
if string(input.Content) != `{"npcs":[]}` || input.Digest != "" || input.OriginURI != "" {
|
||||||
|
t.Fatalf("unbound registry input = %#v, want exact empty prompt without identity", input)
|
||||||
|
}
|
||||||
|
metadata := extractor.ManifestMetadata()
|
||||||
|
if _, ok := metadata["npc_registry_digest"]; ok {
|
||||||
|
t.Fatalf("unbound metadata has registry digest: %#v", metadata)
|
||||||
|
}
|
||||||
|
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
||||||
|
if fingerprint.Name == "npc_registry" {
|
||||||
|
t.Fatalf("unbound fingerprints include registry identity: %#v", extractor.CheckpointFingerprints())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsMalformedNPCRegistryBeforeLLMCallWithoutContent(t *testing.T) {
|
||||||
|
client := &fakeCombatTurnsLLMClient{}
|
||||||
|
references := 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 transcript detail"}`)}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
_, err := New(client, Options{}, references)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "prepare NPC registry") || strings.Contains(err.Error(), "private transcript detail") {
|
||||||
|
t.Fatalf("New() error = %v, want bounded content-free registry failure", err)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 0 {
|
||||||
|
t.Fatalf("LLM calls = %d, want none during failed construction", len(client.requests))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
|
||||||
|
validReq := extractionRequest()
|
||||||
|
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
validExtractor := newExtractor(t, &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}})
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
extractor *Extractor
|
||||||
|
ctx context.Context
|
||||||
|
req contracts.TypedExtractionRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"},
|
||||||
|
{name: "nil context", extractor: validExtractor, req: validReq, want: "context"},
|
||||||
|
{name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"},
|
||||||
|
{name: "nil source", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Chunk: validReq.Chunk}, want: "source"},
|
||||||
|
{name: "nil chunk", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Source: validReq.Source}, want: "chunk"},
|
||||||
|
{name: "empty chunk units", extractor: validExtractor, ctx: context.Background(), req: emptyChunkRequest(validReq), want: "units"},
|
||||||
|
{name: "source input mismatch", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := test.extractor.Extract(test.ctx, test.req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "dnd combat turns") || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Extract() error = %v, want %q context", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, err := newExtractor(t, &fakeCombatTurnsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), extractionRequest())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "dnd combat turns") || !strings.Contains(err.Error(), "provider unavailable") {
|
||||||
|
t.Fatalf("provider error = %v, want contextual wrapped error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractorManifestMetadataAndFingerprints(t *testing.T) {
|
||||||
|
extractor := newExtractor(t, &fakeCombatTurnsLLMClient{})
|
||||||
|
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"} {
|
||||||
|
value, ok := metadata[key].(string)
|
||||||
|
if !ok || !strings.HasPrefix(value, "sha256:") {
|
||||||
|
t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantNames := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}}
|
||||||
|
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
||||||
|
if _, ok := wantNames[fingerprint.Name]; !ok {
|
||||||
|
t.Fatalf("unexpected fingerprint = %#v", fingerprint)
|
||||||
|
}
|
||||||
|
delete(wantNames, fingerprint.Name)
|
||||||
|
}
|
||||||
|
if len(wantNames) != 0 {
|
||||||
|
t.Fatalf("missing fingerprints = %#v", wantNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleSpecAndRegistration(t *testing.T) {
|
||||||
|
wantSlots := referenceSlots()
|
||||||
|
got := ModuleSpec()
|
||||||
|
if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.combat_turns"}) || !reflect.DeepEqual(got.ReferenceSlots, wantSlots) {
|
||||||
|
t.Fatalf("ModuleSpec() = %#v, want combat extractor contract", got)
|
||||||
|
}
|
||||||
|
got.Requires[0] = "changed"
|
||||||
|
got.Provides[0] = "changed"
|
||||||
|
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
||||||
|
again := ModuleSpec()
|
||||||
|
if again.Requires[0] == "changed" || again.Provides[0] == "changed" || again.ReferenceSlots[0].AcceptedMediaTypes[0] == "changed" {
|
||||||
|
t.Fatalf("ModuleSpec() returned shared mutable values: %#v", again)
|
||||||
|
}
|
||||||
|
registry := pipeline.NewExtractorRegistry()
|
||||||
|
if err := Register(registry); err != nil {
|
||||||
|
t.Fatalf("Register() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
|
t.Fatal("DecodeOptions() accepted unknown option")
|
||||||
|
}
|
||||||
|
if _, ok := registry.Spec(Key); !ok {
|
||||||
|
t.Fatalf("registry missing spec for %q", Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractionRequest() contracts.TypedExtractionRequest {
|
||||||
|
doc := combatSourceDocument()
|
||||||
|
chunk := &source.Chunk{
|
||||||
|
ID: "session-alpha:chunk:0",
|
||||||
|
SourceID: doc.ID,
|
||||||
|
Index: 0,
|
||||||
|
Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 2},
|
||||||
|
Content: []byte(`{"units":[10,2]}`),
|
||||||
|
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-combat",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func combatSourceDocument() *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: "Aria reacts and strikes the ogre.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 10, EndUnitID: 10}},
|
||||||
|
{ID: 2, Kind: "transcript_segment", Text: "Borin retreats from the ogre.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`{"other":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
|
||||||
|
}
|
||||||
|
|
||||||
|
func npcRegistryJSON(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
value := dnd.NPCList{NPCs: []dnd.NPC{{
|
||||||
|
ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak"},
|
||||||
|
Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
|
}}}
|
||||||
|
content, err := npccodec.New().Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode NPC registry: %v", err)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeCombatTurnsLLMClient struct {
|
||||||
|
response extractionResponse
|
||||||
|
err error
|
||||||
|
requests []contracts.StructuredCompletionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeCombatTurnsLLMClient) 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()
|
||||||
|
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
|
||||||
|
}
|
||||||
28
internal/modules/dnd/extract/combatturns/model.go
Normal file
28
internal/modules/dnd/extract/combatturns/model.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
|
|
||||||
|
type extractionResponse struct {
|
||||||
|
CombatTurns []combatTurnResponse `json:"combat_turns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type combatTurnResponse struct {
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
TurnKind string `json:"turn_kind"`
|
||||||
|
Round *int `json:"round"`
|
||||||
|
Actions []combatActionResponse `json:"actions"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
SourceRefs []combatSourceRefResponse `json:"source_refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type combatActionResponse struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Declaration string `json:"declaration"`
|
||||||
|
Targets []string `json:"targets"`
|
||||||
|
Resolution *string `json:"resolution"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type combatSourceRefResponse struct {
|
||||||
|
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
||||||
|
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
||||||
|
}
|
||||||
21
internal/modules/dnd/extract/combatturns/schema.go
Normal file
21
internal/modules/dnd/extract/combatturns/schema.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
|
|
||||||
|
const (
|
||||||
|
PromptID = "dnd.combat_turns"
|
||||||
|
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_combat_turns_llm")
|
||||||
|
ResponseSchemaID = "notarius.dnd.combat_turns.llm"
|
||||||
|
ResponseSchemaName = "notarius_dnd_combat_turns_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_combat_turns_llm.v1.json",
|
||||||
|
})
|
||||||
|
}
|
||||||
99
internal/modules/dnd/extract/combatturns/schema_test.go
Normal file
99
internal/modules/dnd/extract/combatturns/schema_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadResponseSchema() error = %v", 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 metadata = %#v, want private combat schema identity", schema)
|
||||||
|
}
|
||||||
|
valid := validCombatResponse()
|
||||||
|
content, err := json.Marshal(valid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("valid combat response rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withSourceID := validCombatResponse()
|
||||||
|
withSourceID["combat_turns"].([]any)[0].(map[string]any)["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session-alpha"
|
||||||
|
content, err = json.Marshal(withSourceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(content, schema.JSONSchema); err == nil {
|
||||||
|
t.Fatal("response schema accepted source_id, want private source-reference shape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaJSONIsMutationSafe(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 valid copy", second.JSONSchema, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diagnostics := schema.DiagnosticsMap()
|
||||||
|
if diagnostics["key"] != ResponseSchemaKey || diagnostics["id"] != ResponseSchemaID {
|
||||||
|
t.Fatalf("diagnostics = %#v, want schema identity", diagnostics)
|
||||||
|
}
|
||||||
|
if _, ok := diagnostics["json_schema"]; ok {
|
||||||
|
t.Fatalf("diagnostics include raw schema: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCombatResponse() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"combat_turns": []any{
|
||||||
|
map[string]any{
|
||||||
|
"actor": "Aria", "turn_kind": "reaction", "round": nil,
|
||||||
|
"actions": []any{map[string]any{
|
||||||
|
"category": "attack", "declaration": "Aria strikes", "targets": []any{}, "resolution": nil,
|
||||||
|
}},
|
||||||
|
"summary": "Aria reacts.",
|
||||||
|
"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,45 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||||
|
promptFS, err := shared.ModulePromptFS("dnd.combat_turns", embeddedAssets, []promptfs.ModulePromptFile{
|
||||||
|
{Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"},
|
||||||
|
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||||
|
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare combat-turn 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() {
|
||||||
|
parts := append([]llm.AssetHashPart{
|
||||||
|
{FS: embeddedAssets, Path: "assets/prompts/dnd.combat_turns.yaml"},
|
||||||
|
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||||
|
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||||
|
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||||
|
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||||
|
})
|
||||||
|
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
scriptoriumPromptHashOnce sync.Once
|
||||||
|
scriptoriumPromptHash string
|
||||||
|
scriptoriumPromptHashErr error
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
||||||
|
registry := llm.NewAssetRegistry()
|
||||||
|
if err := RegisterPromptAssets(registry); err != nil {
|
||||||
|
t.Fatalf("RegisterPromptAssets() error = %v", err)
|
||||||
|
}
|
||||||
|
promptFS, err := registry.PromptFS()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
"dnd.combat_turns/dnd.combat_turns.yaml",
|
||||||
|
"dnd.combat_turns/task.md",
|
||||||
|
"dnd.combat_turns/instructions.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-system.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-references.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-npcs.md",
|
||||||
|
} {
|
||||||
|
if _, err := fs.ReadFile(promptFS, path); err != nil {
|
||||||
|
t.Fatalf("prompt asset %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schemaFS, err := registry.SchemaFS()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fs.ReadFile(schemaFS, "dnd_combat_turns_llm.v1.json"); err != nil {
|
||||||
|
t.Fatalf("response schema asset: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := scriptoriumPromptMetadata()
|
||||||
|
if err != nil || !strings.HasPrefix(hash, "sha256:") {
|
||||||
|
t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want digest", hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,9 +32,9 @@ messages:
|
|||||||
cache_control:
|
cache_control:
|
||||||
type: ephemeral
|
type: ephemeral
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./catalog.md
|
content_file: ./sharedassets/common-dnd-npcs.md
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./npc_registry.md
|
content_file: ./catalog.md
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./task.md
|
content_file: ./task.md
|
||||||
- role: user
|
- role: user
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
The optional canonical NPC registry for this extraction is provided below as
|
|
||||||
durable JSON. Use it only to prefer exact canonical NPC names and recognize
|
|
||||||
their aliases when the transcript identifies a caster.
|
|
||||||
|
|
||||||
Registry entries are grounding material, not evidence that a spell was cast.
|
|
||||||
Do not extract a spell, caster, effect, or source reference from the registry.
|
|
||||||
NPC source references describe registry provenance and may belong to another
|
|
||||||
session; they are never spell evidence. Preserve the existing player and party
|
|
||||||
policy for identifying PCs from the transcript.
|
|
||||||
|
|
||||||
{{ input "npcs" }}
|
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
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"
|
||||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||||
)
|
)
|
||||||
@@ -17,6 +18,11 @@ const Key = "dnd/spells"
|
|||||||
const ArtifactType = "dnd.spell_cast"
|
const ArtifactType = "dnd.spell_cast"
|
||||||
const SchemaVersion = "v1"
|
const SchemaVersion = "v1"
|
||||||
|
|
||||||
|
const (
|
||||||
|
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
|
||||||
|
NPCRegistryMaxBytes = npcregistry.MaxBytes
|
||||||
|
)
|
||||||
|
|
||||||
var requiredCapabilities = []string{
|
var requiredCapabilities = []string{
|
||||||
"chunks",
|
"chunks",
|
||||||
"source.transcript",
|
"source.transcript",
|
||||||
@@ -59,7 +65,7 @@ type Extractor struct {
|
|||||||
llm contracts.StructuredLLMClient
|
llm contracts.StructuredLLMClient
|
||||||
effectiveCatalog spellcatalog.EffectiveCatalog
|
effectiveCatalog spellcatalog.EffectiveCatalog
|
||||||
catalogPromptInput contracts.LLMInputMaterial
|
catalogPromptInput contracts.LLMInputMaterial
|
||||||
npcRegistry npcRegistryPromptInput
|
npcRegistry *npcregistry.Registry
|
||||||
promptSHA string
|
promptSHA string
|
||||||
responseSchemaSHA string
|
responseSchemaSHA string
|
||||||
}
|
}
|
||||||
@@ -83,7 +89,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, extractorErrorf("prepare spell catalog prompt input: %w", err)
|
return nil, extractorErrorf("prepare spell catalog prompt input: %w", err)
|
||||||
}
|
}
|
||||||
npcRegistry, err := resolveNPCRegistry(referenceSet)
|
npcRegistry, err := npcregistry.Resolve(referenceSet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, extractorErrorf("prepare NPC registry prompt input: %w", err)
|
return nil, extractorErrorf("prepare NPC registry prompt input: %w", err)
|
||||||
}
|
}
|
||||||
@@ -127,9 +133,9 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
|||||||
"response_schema_version": SchemaVersion,
|
"response_schema_version": SchemaVersion,
|
||||||
"response_schema_sha256": e.responseSchemaSHA,
|
"response_schema_sha256": e.responseSchemaSHA,
|
||||||
}
|
}
|
||||||
if e.npcRegistry.bound {
|
if e.npcRegistry.Bound() {
|
||||||
metadata["npc_registry_digest"] = e.npcRegistry.digest
|
metadata["npc_registry_digest"] = e.npcRegistry.Digest()
|
||||||
metadata["npc_count"] = e.npcRegistry.count
|
metadata["npc_count"] = e.npcRegistry.Count()
|
||||||
}
|
}
|
||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
@@ -143,8 +149,8 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
{Name: "prompt", Value: e.promptSHA},
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
}
|
}
|
||||||
if e.npcRegistry.bound {
|
if e.npcRegistry.Bound() {
|
||||||
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: e.npcRegistry.digest})
|
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: e.npcRegistry.Digest()})
|
||||||
}
|
}
|
||||||
return fingerprints
|
return fingerprints
|
||||||
}
|
}
|
||||||
@@ -179,7 +185,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||||
inputs[spellcatalog.SpellCatalogReferenceSlot] = e.catalogPromptInput.Clone()
|
inputs[spellcatalog.SpellCatalogReferenceSlot] = e.catalogPromptInput.Clone()
|
||||||
inputs[NPCRegistryReferenceSlot] = e.npcRegistry.input.Clone()
|
inputs[NPCRegistryReferenceSlot] = e.npcRegistry.PromptInput()
|
||||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: Key,
|
StageName: Key,
|
||||||
PromptID: PromptID,
|
PromptID: PromptID,
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
package spells
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"mime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
||||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/diagnostics"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
NPCRegistryReferenceSlot = "npcs"
|
|
||||||
NPCRegistryMaxBytes = 1048576
|
|
||||||
)
|
|
||||||
|
|
||||||
type npcRegistryPromptInput struct {
|
|
||||||
input contracts.LLMInputMaterial
|
|
||||||
digest string
|
|
||||||
count int
|
|
||||||
bound bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveNPCRegistry(references contracts.ReferenceSet) (npcRegistryPromptInput, error) {
|
|
||||||
slot, ok := references.Slots[NPCRegistryReferenceSlot]
|
|
||||||
if !ok {
|
|
||||||
return npcRegistryPromptInput{
|
|
||||||
input: contracts.NewLLMInputMaterial(
|
|
||||||
NPCRegistryReferenceSlot,
|
|
||||||
"application/json",
|
|
||||||
[]byte(`{"npcs":[]}`),
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if len(slot.Items) != 1 {
|
|
||||||
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q must contain exactly one item", NPCRegistryReferenceSlot)
|
|
||||||
}
|
|
||||||
|
|
||||||
item := slot.Items[0]
|
|
||||||
mediaType, _, err := mime.ParseMediaType(item.MediaType)
|
|
||||||
if err != nil {
|
|
||||||
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q item media type %q is invalid: %w", NPCRegistryReferenceSlot, item.MediaType, err)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(mediaType, "application/json") {
|
|
||||||
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q item media type %q must be application/json", NPCRegistryReferenceSlot, item.MediaType)
|
|
||||||
}
|
|
||||||
if len(item.Content) > NPCRegistryMaxBytes {
|
|
||||||
return npcRegistryPromptInput{}, fmt.Errorf("reference slot %q item is %d bytes, limit %d", NPCRegistryReferenceSlot, len(item.Content), NPCRegistryMaxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
codec := npccodec.New()
|
|
||||||
value, err := codec.Decode(item.Content)
|
|
||||||
if err != nil {
|
|
||||||
return npcRegistryPromptInput{}, fmt.Errorf("decode NPC registry: invalid approved NPC JSON")
|
|
||||||
}
|
|
||||||
if issues := identity.ValidateList(value); len(issues) > 0 {
|
|
||||||
return npcRegistryPromptInput{}, fmt.Errorf("%s", formatNPCIdentityIssues(issues))
|
|
||||||
}
|
|
||||||
content, err := codec.Encode(value)
|
|
||||||
if err != nil {
|
|
||||||
return npcRegistryPromptInput{}, fmt.Errorf("encode canonical NPC registry: approved NPC value could not be encoded")
|
|
||||||
}
|
|
||||||
|
|
||||||
digest := semanticNPCRegistryDigest(content)
|
|
||||||
return npcRegistryPromptInput{
|
|
||||||
input: contracts.NewLLMInputMaterial(NPCRegistryReferenceSlot, "application/json", content, digest, ""),
|
|
||||||
digest: digest,
|
|
||||||
count: len(value.NPCs),
|
|
||||||
bound: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func semanticNPCRegistryDigest(content []byte) string {
|
|
||||||
sum := sha256.Sum256(content)
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatNPCIdentityIssues(issues []identity.Issue) string {
|
|
||||||
parts := make([]string, len(issues))
|
|
||||||
for index, issue := range issues {
|
|
||||||
location := fmt.Sprintf("record %d", issue.RecordIndex)
|
|
||||||
if issue.AliasIndex >= 0 {
|
|
||||||
location += fmt.Sprintf(" alias %d", issue.AliasIndex)
|
|
||||||
}
|
|
||||||
parts[index] = fmt.Sprintf("%s at %s", issue.Code, location)
|
|
||||||
}
|
|
||||||
return diagnostics.Aggregate("validate NPC registry identity", parts)
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
package spells
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"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/diagnostics"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveNPCRegistryUsesExactEmptyPromptWhenUnbound(t *testing.T) {
|
|
||||||
resolved, err := resolveNPCRegistry(contracts.ReferenceSet{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolveNPCRegistry() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
if resolved.bound || resolved.digest != "" || resolved.count != 0 {
|
|
||||||
t.Fatalf("resolved unbound registry = %#v, want no semantic metadata", resolved)
|
|
||||||
}
|
|
||||||
if resolved.input.Name != NPCRegistryReferenceSlot || resolved.input.MediaType != "application/json" || resolved.input.Digest != "" || resolved.input.OriginURI != "" {
|
|
||||||
t.Fatalf("unbound prompt input metadata = %#v, want name/media type only", resolved.input)
|
|
||||||
}
|
|
||||||
if got := string(resolved.input.Content); got != `{"npcs":[]}` {
|
|
||||||
t.Fatalf("unbound prompt input = %q, want exact empty registry", got)
|
|
||||||
}
|
|
||||||
if resolved.input.SizeBytes != int64(len(`{"npcs":[]}`)) {
|
|
||||||
t.Fatalf("unbound prompt input size = %d, want %d", resolved.input.SizeBytes, len(`{"npcs":[]}`))
|
|
||||||
}
|
|
||||||
if metadata := newExtractor(t, &fakeSpellsLLMClient{}).ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
|
|
||||||
t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata)
|
|
||||||
}
|
|
||||||
fingerprints := newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints()
|
|
||||||
for _, fingerprint := range fingerprints {
|
|
||||||
if fingerprint.Name == "npc_registry" {
|
|
||||||
t.Fatalf("unbound checkpoint fingerprints = %#v, want no NPC registry fingerprint", fingerprints)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveNPCRegistryCanonicalizesContentAndUsesSemanticDigest(t *testing.T) {
|
|
||||||
value := validNPCRegistryList()
|
|
||||||
canonical := encodeNPCRegistry(t, value)
|
|
||||||
raw := append([]byte(" \n"), canonical...)
|
|
||||||
raw = append(raw, []byte("\n ")...)
|
|
||||||
|
|
||||||
resolved, err := resolveNPCRegistry(npcRegistryReference(raw, "file:///another-session/npcs.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolveNPCRegistry() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
if !resolved.bound || resolved.count != len(value.NPCs) {
|
|
||||||
t.Fatalf("resolved registry = %#v, want bound registry with %d NPC", resolved, len(value.NPCs))
|
|
||||||
}
|
|
||||||
if !bytes.Equal(resolved.input.Content, canonical) {
|
|
||||||
t.Fatalf("canonical prompt input = %s, want %s", resolved.input.Content, canonical)
|
|
||||||
}
|
|
||||||
if resolved.input.Digest != semanticNPCRegistryDigest(canonical) || resolved.digest != resolved.input.Digest {
|
|
||||||
t.Fatalf("semantic digest = %q/%q, want %q", resolved.input.Digest, resolved.digest, semanticNPCRegistryDigest(canonical))
|
|
||||||
}
|
|
||||||
if resolved.input.OriginURI != "" {
|
|
||||||
t.Fatalf("prompt input origin = %q, want no provenance path", resolved.input.OriginURI)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved.input.Content[0] = 'X'
|
|
||||||
again, err := resolveNPCRegistry(npcRegistryReference(raw, "file:///another-session/npcs.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second resolveNPCRegistry() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(again.input.Content, canonical) {
|
|
||||||
t.Fatalf("canonical content changed after caller mutation = %s, want %s", again.input.Content, canonical)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveNPCRegistryRejectsInvalidBoundaryValues(t *testing.T) {
|
|
||||||
valid := validNPCRegistryList()
|
|
||||||
second := validNPCRegistryList().NPCs[0]
|
|
||||||
second.ID = identity.DeriveID("Captain Vale")
|
|
||||||
second.Name = "Captain Vale"
|
|
||||||
second.Aliases = []string{"The Greencloak"}
|
|
||||||
valueWithAliasCollision := dnd.NPCList{NPCs: []dnd.NPC{valid.NPCs[0], second}}
|
|
||||||
invalidID := valid
|
|
||||||
invalidID.NPCs[0].ID = "not-an-npc-id"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
reference contracts.ReferenceSet
|
|
||||||
wantError string
|
|
||||||
forbidden []string
|
|
||||||
}{
|
|
||||||
{name: "zero items", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {Items: []contracts.ReferenceItem{}}}}, wantError: "exactly one"},
|
|
||||||
{name: "multiple", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {Items: []contracts.ReferenceItem{{Content: []byte(`{"npcs":[]}`)}, {Content: []byte(`{"npcs":[]}`)}}}}}, wantError: "exactly one"},
|
|
||||||
{name: "wrong media type", reference: npcRegistryReferenceWithMedia([]byte(`{"npcs":[]}`), "text/plain"), wantError: "must be application/json"},
|
|
||||||
{name: "malformed JSON", reference: npcRegistryReference([]byte(`{"npcs":[],"MALFORMED_REGISTRY_SECRET":`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"MALFORMED_REGISTRY_SECRET"}},
|
|
||||||
{name: "unknown field", reference: npcRegistryReference([]byte(`{"npcs":[],"UNKNOWN_FIELD_SECRET":true}`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"UNKNOWN_FIELD_SECRET"}},
|
|
||||||
{name: "invalid ID", reference: npcRegistryReference(marshalNPCRegistry(t, invalidID), "file:///private.json"), wantError: "decode NPC registry"},
|
|
||||||
{name: "alias collision", reference: npcRegistryReference(encodeNPCRegistry(t, valueWithAliasCollision), "file:///private.json"), wantError: string(identity.IssueAliasOwnershipCollision)},
|
|
||||||
{name: "byte limit", reference: npcRegistryReference(bytes.Repeat([]byte("x"), NPCRegistryMaxBytes+1), "file:///private.json"), wantError: "limit"},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
_, err := resolveNPCRegistry(test.reference)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
|
||||||
t.Fatalf("resolveNPCRegistry() error = %v, want %q", err, test.wantError)
|
|
||||||
}
|
|
||||||
for _, forbidden := range append(test.forbidden, "Mira Thorn", "The Greencloak", "private.json") {
|
|
||||||
if strings.Contains(err.Error(), forbidden) {
|
|
||||||
t.Fatalf("error leaked registry content or provenance %q: %v", forbidden, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveNPCRegistryBoundsIdentityDiagnosticsWithoutContent(t *testing.T) {
|
|
||||||
const recordCount = 30
|
|
||||||
value := dnd.NPCList{NPCs: make([]dnd.NPC, recordCount)}
|
|
||||||
for index := range value.NPCs {
|
|
||||||
value.NPCs[index] = dnd.NPC{
|
|
||||||
ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
Name: fmt.Sprintf("PRIVATE NPC %d", index),
|
|
||||||
Aliases: []string{"PRIVATE SHARED ALIAS"},
|
|
||||||
Description: "PRIVATE DESCRIPTION",
|
|
||||||
Relationships: []dnd.NPCRelationship{},
|
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "private-source", StartUnitID: 1, EndUnitID: 1}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
issues := identity.ValidateList(value)
|
|
||||||
if len(issues) <= diagnostics.MaxIssues {
|
|
||||||
t.Fatalf("identity issues = %d, want more than display limit", len(issues))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := resolveNPCRegistry(npcRegistryReference(marshalNPCRegistry(t, value), "file:///private-registry.json"))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("resolveNPCRegistry() error = nil, want bounded identity rejection")
|
|
||||||
}
|
|
||||||
message := err.Error()
|
|
||||||
if !utf8.ValidString(message) || len([]byte(message)) > diagnostics.MaxMessageBytes {
|
|
||||||
t.Fatalf("identity error has invalid encoding or size: bytes=%d message=%q", len([]byte(message)), message)
|
|
||||||
}
|
|
||||||
wantOmitted := fmt.Sprintf("%d additional issue(s) omitted", len(issues)-diagnostics.MaxIssues)
|
|
||||||
if !strings.Contains(message, wantOmitted) {
|
|
||||||
t.Fatalf("identity error = %q, want %q", message, wantOmitted)
|
|
||||||
}
|
|
||||||
for _, forbidden := range []string{"PRIVATE NPC", "PRIVATE SHARED ALIAS", "PRIVATE DESCRIPTION", "private-source", "private-registry.json"} {
|
|
||||||
if strings.Contains(message, forbidden) {
|
|
||||||
t.Fatalf("identity error leaked %q: %s", forbidden, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNPCRegistryFingerprintIsSemanticAndDefensive(t *testing.T) {
|
|
||||||
value := validNPCRegistryList()
|
|
||||||
canonical := encodeNPCRegistry(t, value)
|
|
||||||
pretty, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("MarshalIndent() error = %v", err)
|
|
||||||
}
|
|
||||||
first := newExtractor(t, &fakeSpellsLLMClient{}, npcRegistryReference(canonical, "file:///one.json"))
|
|
||||||
second := newExtractor(t, &fakeSpellsLLMClient{}, npcRegistryReference(pretty, "file:///two.json"))
|
|
||||||
firstFingerprints := checkpointFingerprintMap(first.CheckpointFingerprints())
|
|
||||||
secondFingerprints := checkpointFingerprintMap(second.CheckpointFingerprints())
|
|
||||||
if firstFingerprints["npc_registry"] == "" || firstFingerprints["npc_registry"] != secondFingerprints["npc_registry"] {
|
|
||||||
t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints)
|
|
||||||
}
|
|
||||||
returned := first.CheckpointFingerprints()
|
|
||||||
returned[0].Name = "caller-mutated"
|
|
||||||
if first.CheckpointFingerprints()[0].Name == "caller-mutated" {
|
|
||||||
t.Fatal("CheckpointFingerprints() returned caller-mutable slice state")
|
|
||||||
}
|
|
||||||
|
|
||||||
changed := validNPCRegistryList()
|
|
||||||
changed.NPCs[0].Description = "A different description."
|
|
||||||
changedFingerprint := checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}, npcRegistryReference(encodeNPCRegistry(t, changed), "file:///three.json")).CheckpointFingerprints())
|
|
||||||
if changedFingerprint["npc_registry"] == firstFingerprints["npc_registry"] {
|
|
||||||
t.Fatalf("semantic NPC fingerprint did not change: %#v", changedFingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := first.ManifestMetadata()
|
|
||||||
encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal metadata: %v", err)
|
|
||||||
}
|
|
||||||
for _, forbidden := range []string{"Mira Thorn", "The Greencloak", "another-session", "one.json"} {
|
|
||||||
if strings.Contains(string(encoded), forbidden) {
|
|
||||||
t.Fatalf("metadata or fingerprints leaked %q: %s", forbidden, encoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if metadata["npc_registry_digest"] != firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 {
|
|
||||||
t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractPassesCanonicalNPCRegistryToLLMWithoutProvenance(t *testing.T) {
|
|
||||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
|
||||||
canonical := encodeNPCRegistry(t, validNPCRegistryList())
|
|
||||||
extractor := newExtractor(t, client, npcRegistryReference(append([]byte("\n"), canonical...), "file:///npc-session.json"))
|
|
||||||
if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil {
|
|
||||||
t.Fatalf("Extract() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
|
|
||||||
if input.Name != NPCRegistryReferenceSlot || input.MediaType != "application/json" || input.Digest != semanticNPCRegistryDigest(canonical) || input.OriginURI != "" {
|
|
||||||
t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(input.Content, canonical) {
|
|
||||||
t.Fatalf("NPC prompt input = %s, want canonical JSON %s", input.Content, canonical)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validNPCRegistryList() dnd.NPCList {
|
|
||||||
return dnd.NPCList{NPCs: []dnd.NPC{{
|
|
||||||
ID: identity.DeriveID("Mira Thorn"),
|
|
||||||
Name: "Mira Thorn",
|
|
||||||
Aliases: []string{"The Greencloak"},
|
|
||||||
Description: "A guarded ranger who watches the northern road.",
|
|
||||||
Relationships: []dnd.NPCRelationship{{
|
|
||||||
Target: "Captain Vale", Relationship: "reports to",
|
|
||||||
}},
|
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodeNPCRegistry(t *testing.T, value dnd.NPCList) []byte {
|
|
||||||
t.Helper()
|
|
||||||
content, err := npccodec.New().Encode(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encode NPC registry: %v", err)
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalNPCRegistry(t *testing.T, value dnd.NPCList) []byte {
|
|
||||||
t.Helper()
|
|
||||||
content, err := json.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal NPC registry: %v", err)
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func npcRegistryReference(content []byte, origin string) contracts.ReferenceSet {
|
|
||||||
references := npcRegistryReferenceWithMedia(content, "application/json; charset=utf-8")
|
|
||||||
item := references.Slots[NPCRegistryReferenceSlot].Items[0]
|
|
||||||
item.Origin.URI = origin
|
|
||||||
slot := references.Slots[NPCRegistryReferenceSlot]
|
|
||||||
slot.Items[0] = item
|
|
||||||
references.Slots[NPCRegistryReferenceSlot] = slot
|
|
||||||
return references
|
|
||||||
}
|
|
||||||
|
|
||||||
func npcRegistryReferenceWithMedia(content []byte, mediaType string) contracts.ReferenceSet {
|
|
||||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
||||||
NPCRegistryReferenceSlot: {
|
|
||||||
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, AcceptedMediaTypes: []string{"application/json"}, MaxBytes: NPCRegistryMaxBytes},
|
|
||||||
Items: []contracts.ReferenceItem{{
|
|
||||||
SlotName: NPCRegistryReferenceSlot,
|
|
||||||
MediaType: mediaType,
|
|
||||||
Content: append([]byte(nil), content...),
|
|
||||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///npc-registry.json"},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkpointFingerprintMap(values []pipeline.CheckpointFingerprint) map[string]string {
|
|
||||||
result := make(map[string]string, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
result[value.Name] = value.Value
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
120
internal/modules/dnd/extract/spells/npc_registry_wiring_test.go
Normal file
120
internal/modules/dnd/extract/spells/npc_registry_wiring_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package spells
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *testing.T) {
|
||||||
|
extractor := newExtractor(t, &fakeSpellsLLMClient{})
|
||||||
|
metadata := extractor.ManifestMetadata()
|
||||||
|
if metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
|
||||||
|
t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata)
|
||||||
|
}
|
||||||
|
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
||||||
|
if fingerprint.Name == "npc_registry" {
|
||||||
|
t.Fatalf("unbound checkpoint fingerprints = %#v, want no NPC registry fingerprint", extractor.CheckpointFingerprints())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||||
|
extractor = newExtractor(t, client)
|
||||||
|
if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
|
||||||
|
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" || string(input.Content) != `{"npcs":[]}` {
|
||||||
|
t.Fatalf("NPC prompt input = %#v, want exact empty registry material", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t *testing.T) {
|
||||||
|
value := registryFixture()
|
||||||
|
canonical, err := npccodec.New().Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode NPC registry: %v", err)
|
||||||
|
}
|
||||||
|
pretty, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalIndent() error = %v", err)
|
||||||
|
}
|
||||||
|
first := newExtractor(t, &fakeSpellsLLMClient{}, spellNPCRegistryReference(canonical, "file:///one.json"))
|
||||||
|
second := newExtractor(t, &fakeSpellsLLMClient{}, spellNPCRegistryReference(pretty, "file:///two.json"))
|
||||||
|
firstFingerprints := checkpointFingerprintMap(first.CheckpointFingerprints())
|
||||||
|
secondFingerprints := checkpointFingerprintMap(second.CheckpointFingerprints())
|
||||||
|
if firstFingerprints["npc_registry"] == "" || firstFingerprints["npc_registry"] != secondFingerprints["npc_registry"] {
|
||||||
|
t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints)
|
||||||
|
}
|
||||||
|
metadata := first.ManifestMetadata()
|
||||||
|
if metadata["npc_registry_digest"] != firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 {
|
||||||
|
t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||||
|
extractor := newExtractor(t, client, spellNPCRegistryReference(append([]byte("\n"), canonical...), "file:///npc-session.json"))
|
||||||
|
if _, err := extractor.Extract(context.Background(), extractionRequest()); err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
|
||||||
|
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != firstFingerprints["npc_registry"] || input.OriginURI != "" {
|
||||||
|
t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(input.Content, canonical) {
|
||||||
|
t.Fatalf("NPC prompt input = %s, want canonical JSON %s", input.Content, canonical)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"Mira Thorn", "The Greencloak", "one.json", "two.json"} {
|
||||||
|
if strings.Contains(string(encoded), forbidden) {
|
||||||
|
t.Fatalf("metadata or fingerprints leaked %q: %s", forbidden, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryFixture() dnd.NPCList {
|
||||||
|
return dnd.NPCList{NPCs: []dnd.NPC{{
|
||||||
|
ID: identity.DeriveID("Mira Thorn"),
|
||||||
|
Name: "Mira Thorn",
|
||||||
|
Aliases: []string{"The Greencloak"},
|
||||||
|
Description: "A guarded ranger who watches the northern road.",
|
||||||
|
Relationships: []dnd.NPCRelationship{{
|
||||||
|
Target: "Captain Vale", Relationship: "reports to",
|
||||||
|
}},
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spellNPCRegistryReference(content []byte, origin string) contracts.ReferenceSet {
|
||||||
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
|
registry.ReferenceSlot: {
|
||||||
|
Slot: contracts.ReferenceSlot{Name: registry.ReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: registry.MaxBytes},
|
||||||
|
Items: []contracts.ReferenceItem{{
|
||||||
|
SlotName: registry.ReferenceSlot,
|
||||||
|
MediaType: npccodec.MediaType,
|
||||||
|
Content: append([]byte(nil), content...),
|
||||||
|
Origin: contracts.ReferenceOrigin{Type: "file", URI: origin},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointFingerprintMap(values []pipeline.CheckpointFingerprint) map[string]string {
|
||||||
|
result := make(map[string]string, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result[value.Name] = value.Value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
|||||||
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
|
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
|
||||||
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
||||||
{Name: "catalog.md", Path: "assets/prompts/catalog.md"},
|
{Name: "catalog.md", Path: "assets/prompts/catalog.md"},
|
||||||
{Name: "npc_registry.md", Path: "assets/prompts/npc_registry.md"},
|
|
||||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||||
})
|
})
|
||||||
@@ -33,7 +32,6 @@ func scriptoriumPromptMetadata() (string, error) {
|
|||||||
parts := append([]llm.AssetHashPart{
|
parts := append([]llm.AssetHashPart{
|
||||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
|
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
|
||||||
{FS: embeddedAssets, Path: "assets/prompts/catalog.md"},
|
{FS: embeddedAssets, Path: "assets/prompts/catalog.md"},
|
||||||
{FS: embeddedAssets, Path: "assets/prompts/npc_registry.md"},
|
|
||||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||||
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
|
|||||||
if !strings.Contains(prepared.Messages[2].Content, "Shield: abjuration") {
|
if !strings.Contains(prepared.Messages[2].Content, "Shield: abjuration") {
|
||||||
t.Fatalf("reference message missing glossary content")
|
t.Fatalf("reference message missing glossary content")
|
||||||
}
|
}
|
||||||
if !strings.Contains(prepared.Messages[3].Content, `{"spell_names":["Cure Wounds"]}`) {
|
if !strings.Contains(prepared.Messages[4].Content, `{"spell_names":["Cure Wounds"]}`) {
|
||||||
t.Fatalf("catalog message missing canonical spell-name input: %s", prepared.Messages[3].Content)
|
t.Fatalf("catalog message missing canonical spell-name input: %s", prepared.Messages[4].Content)
|
||||||
}
|
}
|
||||||
if !strings.Contains(prepared.Messages[4].Content, `{"npcs":[]}`) {
|
if !strings.Contains(prepared.Messages[3].Content, `{"npcs":[]}`) {
|
||||||
t.Fatalf("NPC registry message missing empty registry input: %s", prepared.Messages[4].Content)
|
t.Fatalf("NPC registry message missing empty registry input: %s", prepared.Messages[3].Content)
|
||||||
}
|
}
|
||||||
if strings.Contains(prepared.Messages[5].Content, string(transcript)) {
|
if strings.Contains(prepared.Messages[5].Content, string(transcript)) {
|
||||||
t.Fatalf("task message leaked transcript bytes")
|
t.Fatalf("task message leaked transcript bytes")
|
||||||
|
|||||||
556
internal/modules/dnd/normalize/combatturns/normalizer.go
Normal file
556
internal/modules/dnd/normalize/combatturns/normalizer.go
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
// Package combatturns normalizes merged D&D combat-turn candidates.
|
||||||
|
package combatturns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"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/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/combat-turns"
|
||||||
|
NormalizationPolicy = "dnd.combat_turns.normalize.v1"
|
||||||
|
|
||||||
|
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
|
||||||
|
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
||||||
|
ReasonCodeTargetCanonicalized = "combat_target_canonicalized"
|
||||||
|
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||||
|
ReasonCodeTurnsReordered = "combat_turns_reordered"
|
||||||
|
ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 disambiguation.",
|
||||||
|
Party: "Optional party roster reference material used only for disambiguation.",
|
||||||
|
Players: "Optional player list reference material used only for disambiguation.",
|
||||||
|
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ contracts.Normalizer[dnd.CombatTurnList] = (*Normalizer)(nil)
|
||||||
|
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||||
|
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
|
||||||
|
type Normalizer struct {
|
||||||
|
npcRegistry *npcregistry.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
npcRegistry, err := npcregistry.Resolve(referenceSet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, normalizerErrorf("prepare NPC registry: %w", err)
|
||||||
|
}
|
||||||
|
return &Normalizer{npcRegistry: npcRegistry}, 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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
metadata := map[string]any{
|
||||||
|
"normalization_policy": NormalizationPolicy,
|
||||||
|
"identity_policy": identity.Policy,
|
||||||
|
}
|
||||||
|
if n.npcRegistry.Bound() {
|
||||||
|
metadata["npc_registry_digest"] = n.npcRegistry.Digest()
|
||||||
|
metadata["npc_count"] = n.npcRegistry.Count()
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fingerprints := []pipeline.CheckpointFingerprint{
|
||||||
|
{Name: "normalization_policy", Value: NormalizationPolicy},
|
||||||
|
{Name: "identity_policy", Value: identity.Policy},
|
||||||
|
}
|
||||||
|
if n.npcRegistry.Bound() {
|
||||||
|
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: n.npcRegistry.Digest()})
|
||||||
|
}
|
||||||
|
return fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.CombatTurnList]) (contracts.TypedNormalizeResult[dnd.CombatTurnList], error) {
|
||||||
|
if n == nil {
|
||||||
|
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("normalizer must not be nil")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("context must not be nil")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, n.npcRegistry)
|
||||||
|
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedRecord struct {
|
||||||
|
turn dnd.CombatTurn
|
||||||
|
inputIndex int
|
||||||
|
earliest int
|
||||||
|
hasEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type targetCanonicalization struct {
|
||||||
|
actionIndex int
|
||||||
|
targetIndex int
|
||||||
|
from string
|
||||||
|
to string
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
|
||||||
|
if input.CombatTurns == nil {
|
||||||
|
return dnd.CombatTurnList{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]normalizedRecord, len(input.CombatTurns))
|
||||||
|
warnings := make([]contracts.Warning, 0)
|
||||||
|
for index, inputTurn := range input.CombatTurns {
|
||||||
|
turn, fieldsChanged, actorChange, targetChanges, refsChanged := normalizeTurn(inputTurn, registry)
|
||||||
|
earliest, hasEvidence := earliestSourcePosition(doc, turn)
|
||||||
|
records[index] = normalizedRecord{
|
||||||
|
turn: turn,
|
||||||
|
inputIndex: index,
|
||||||
|
earliest: earliest,
|
||||||
|
hasEvidence: hasEvidence,
|
||||||
|
}
|
||||||
|
if fieldsChanged {
|
||||||
|
warnings = append(warnings, contracts.Warning{
|
||||||
|
Scope: turnScope(index),
|
||||||
|
ReasonCode: ReasonCodeFieldsNormalized,
|
||||||
|
Message: fmt.Sprintf("input index %d: combat turn fields normalized", index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if actorChange != nil {
|
||||||
|
warnings = append(warnings, contracts.Warning{
|
||||||
|
Scope: turnScope(index),
|
||||||
|
ReasonCode: ReasonCodeActorCanonicalized,
|
||||||
|
Message: fmt.Sprintf("input index %d: actor canonicalized from %s to %s",
|
||||||
|
index, diagnostics.Quote(actorChange.from), diagnostics.Quote(actorChange.to)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, targetChange := range targetChanges {
|
||||||
|
warnings = append(warnings, contracts.Warning{
|
||||||
|
Scope: turnScope(index),
|
||||||
|
ReasonCode: ReasonCodeTargetCanonicalized,
|
||||||
|
Message: fmt.Sprintf("input index %d: action %d target %d canonicalized from %s to %s",
|
||||||
|
index, targetChange.actionIndex, targetChange.targetIndex,
|
||||||
|
diagnostics.Quote(targetChange.from), diagnostics.Quote(targetChange.to)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if refsChanged {
|
||||||
|
warnings = append(warnings, contracts.Warning{
|
||||||
|
Scope: turnScope(index),
|
||||||
|
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||||
|
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
||||||
|
index, len(inputTurn.SourceRefs), len(turn.SourceRefs)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(records, func(left, right int) bool {
|
||||||
|
if records[left].hasEvidence != records[right].hasEvidence {
|
||||||
|
return records[left].hasEvidence
|
||||||
|
}
|
||||||
|
if !records[left].hasEvidence {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return records[left].earliest < records[right].earliest
|
||||||
|
})
|
||||||
|
for position, record := range records {
|
||||||
|
if position == record.inputIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
warnings = append(warnings, contracts.Warning{
|
||||||
|
Scope: turnScope(record.inputIndex),
|
||||||
|
ReasonCode: ReasonCodeTurnsReordered,
|
||||||
|
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.CombatTurnList{CombatTurns: output}, warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, bool, *targetCanonicalization, []targetCanonicalization, bool) {
|
||||||
|
output := cloneCombatTurn(input)
|
||||||
|
output.Actor = identity.NormalizeDisplay(input.Actor)
|
||||||
|
output.Summary = identity.NormalizeDisplay(input.Summary)
|
||||||
|
|
||||||
|
var actorChange *targetCanonicalization
|
||||||
|
if canonical, ok := registry.Lookup(output.Actor); ok {
|
||||||
|
canonicalName := identity.NormalizeDisplay(canonical.Name)
|
||||||
|
if output.Actor != canonicalName {
|
||||||
|
actorChange = &targetCanonicalization{from: output.Actor, to: canonicalName}
|
||||||
|
output.Actor = canonicalName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetChanges := make([]targetCanonicalization, 0)
|
||||||
|
for actionIndex := range output.Actions {
|
||||||
|
action := &output.Actions[actionIndex]
|
||||||
|
action.Declaration = identity.NormalizeDisplay(action.Declaration)
|
||||||
|
if action.Resolution != nil {
|
||||||
|
resolution := identity.NormalizeDisplay(*action.Resolution)
|
||||||
|
action.Resolution = &resolution
|
||||||
|
}
|
||||||
|
if action.Targets == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets := make([]string, 0, len(action.Targets))
|
||||||
|
seen := make(map[string]struct{}, len(action.Targets))
|
||||||
|
for targetIndex, target := range action.Targets {
|
||||||
|
normalized := identity.NormalizeDisplay(target)
|
||||||
|
if canonical, ok := registry.Lookup(normalized); ok {
|
||||||
|
canonicalName := identity.NormalizeDisplay(canonical.Name)
|
||||||
|
if normalized != canonicalName {
|
||||||
|
targetChanges = append(targetChanges, targetCanonicalization{
|
||||||
|
actionIndex: actionIndex,
|
||||||
|
targetIndex: targetIndex,
|
||||||
|
from: normalized,
|
||||||
|
to: canonicalName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
normalized = canonicalName
|
||||||
|
}
|
||||||
|
key := identity.ComparisonKey(normalized)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
targets = append(targets, normalized)
|
||||||
|
}
|
||||||
|
action.Targets = targets
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldsChanged := input.Actor != output.Actor || input.Summary != output.Summary
|
||||||
|
if len(input.Actions) != len(output.Actions) {
|
||||||
|
fieldsChanged = true
|
||||||
|
}
|
||||||
|
for index := range output.Actions {
|
||||||
|
if input.Actions[index].Declaration != output.Actions[index].Declaration ||
|
||||||
|
!stringSlicesEqual(input.Actions[index].Targets, output.Actions[index].Targets) ||
|
||||||
|
!stringPointersEqual(input.Actions[index].Resolution, output.Actions[index].Resolution) {
|
||||||
|
fieldsChanged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
|
||||||
|
output.SourceRefs = canonicalRefs
|
||||||
|
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||||
|
return output, fieldsChanged, actorChange, targetChanges, refsChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
|
||||||
|
output := input
|
||||||
|
if input.Round != nil {
|
||||||
|
round := *input.Round
|
||||||
|
output.Round = &round
|
||||||
|
}
|
||||||
|
if input.Actions != nil {
|
||||||
|
output.Actions = make([]dnd.CombatAction, len(input.Actions))
|
||||||
|
for index, action := range input.Actions {
|
||||||
|
output.Actions[index] = cloneCombatAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.SourceRefs != nil {
|
||||||
|
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
|
||||||
|
copy(output.SourceRefs, input.SourceRefs)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCombatAction(input dnd.CombatAction) dnd.CombatAction {
|
||||||
|
output := input
|
||||||
|
if input.Targets != nil {
|
||||||
|
output.Targets = make([]string, len(input.Targets))
|
||||||
|
copy(output.Targets, input.Targets)
|
||||||
|
}
|
||||||
|
if input.Resolution != nil {
|
||||||
|
resolution := *input.Resolution
|
||||||
|
output.Resolution = &resolution
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSlicesEqual(left, right []string) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPointersEqual(left, right *string) bool {
|
||||||
|
if (left == nil) != (right == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return left == nil || *left == *right
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
||||||
|
if input == nil {
|
||||||
|
return nil, false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical := make([]source.SourceRef, len(input))
|
||||||
|
copy(canonical, input)
|
||||||
|
sort.SliceStable(canonical, func(left, right int) bool {
|
||||||
|
return sourceRefLess(canonical[left], canonical[right])
|
||||||
|
})
|
||||||
|
|
||||||
|
orderChanged := false
|
||||||
|
for index := range input {
|
||||||
|
if input[index] != canonical[index] {
|
||||||
|
orderChanged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, orderChanged, len(input) - len(unique)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRefLess(left, right source.SourceRef) bool {
|
||||||
|
if left.SourceID != right.SourceID {
|
||||||
|
return left.SourceID < right.SourceID
|
||||||
|
}
|
||||||
|
if left.StartUnitID != right.StartUnitID {
|
||||||
|
return left.StartUnitID < right.StartUnitID
|
||||||
|
}
|
||||||
|
return left.EndUnitID < right.EndUnitID
|
||||||
|
}
|
||||||
|
|
||||||
|
func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) {
|
||||||
|
if doc == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
earliest := 0
|
||||||
|
found := false
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
if source.ValidateRef(doc, ref) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index, ok := source.UnitIndex(doc, ref.StartUnitID)
|
||||||
|
if !ok || (found && index >= earliest) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
earliest = index
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return earliest, found
|
||||||
|
}
|
||||||
|
|
||||||
|
type duplicateGroup struct {
|
||||||
|
retainedIndex int
|
||||||
|
removed []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.CombatTurn, []contracts.Warning) {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return make([]dnd.CombatTurn, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keep := make([]bool, len(records))
|
||||||
|
groups := make([]duplicateGroup, 0)
|
||||||
|
groupByKey := make(map[string]int)
|
||||||
|
for index, record := range records {
|
||||||
|
key, eligible := duplicateKey(record.turn, doc)
|
||||||
|
if !eligible {
|
||||||
|
keep[index] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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.CombatTurn, 0, len(records))
|
||||||
|
for index, record := range records {
|
||||||
|
if keep[index] {
|
||||||
|
output = append(output, cloneCombatTurn(record.turn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings := make([]contracts.Warning, 0)
|
||||||
|
for _, group := range groups {
|
||||||
|
if len(group.removed) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed))
|
||||||
|
}
|
||||||
|
return output, warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool) {
|
||||||
|
if len(turn.SourceRefs) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
if source.ValidateRef(doc, ref) != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var key strings.Builder
|
||||||
|
writeKeyString(&key, identity.ComparisonKey(turn.Actor))
|
||||||
|
writeKeyString(&key, string(turn.TurnKind))
|
||||||
|
if turn.Round == nil {
|
||||||
|
key.WriteByte('0')
|
||||||
|
} else {
|
||||||
|
key.WriteByte('1')
|
||||||
|
writeKeyInt(&key, *turn.Round)
|
||||||
|
}
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
writeKeyString(&key, ref.SourceID)
|
||||||
|
writeKeyInt(&key, ref.StartUnitID)
|
||||||
|
writeKeyInt(&key, ref.EndUnitID)
|
||||||
|
}
|
||||||
|
return key.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
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(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
|
||||||
|
const maxDisplayedIndices = 20
|
||||||
|
displayed := removed
|
||||||
|
if len(displayed) > maxDisplayedIndices {
|
||||||
|
displayed = displayed[:maxDisplayedIndices]
|
||||||
|
}
|
||||||
|
indices := make([]string, len(displayed))
|
||||||
|
for index, removedIndex := range displayed {
|
||||||
|
indices[index] = strconv.Itoa(removedIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", "))
|
||||||
|
if omitted := len(removed) - len(displayed); omitted > 0 {
|
||||||
|
message += fmt.Sprintf("; %d additional removed input indices omitted", omitted)
|
||||||
|
}
|
||||||
|
return contracts.Warning{
|
||||||
|
Scope: turnScope(retainedIndex),
|
||||||
|
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||||
|
Message: diagnostics.Truncate(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index) }
|
||||||
|
|
||||||
|
func referenceSlots() []contracts.ReferenceSlot {
|
||||||
|
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||||
|
slots = append(slots, contracts.ReferenceSlot{
|
||||||
|
Name: NPCRegistryReferenceSlot,
|
||||||
|
Description: "Optional normalized NPC registry used for canonical actor and target grounding.",
|
||||||
|
AcceptedMediaTypes: []string{"application/json"},
|
||||||
|
MaxBytes: NPCRegistryMaxBytes,
|
||||||
|
})
|
||||||
|
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].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.CombatTurnListKind,
|
||||||
|
ReferenceSlots: referenceSlots(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(registry *pipeline.NormalizerRegistry) error {
|
||||||
|
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.CombatTurnList], error) {
|
||||||
|
options, err := DecodeOptions(request.Options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return New(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{}, normalizerErrorf("%w", err)
|
||||||
|
}
|
||||||
|
return Options{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizerErrorf(format string, args ...any) error {
|
||||||
|
return fmt.Errorf("dnd combat turns normalizer: "+format, args...)
|
||||||
|
}
|
||||||
302
internal/modules/dnd/normalize/combatturns/normalizer_test.go
Normal file
302
internal/modules/dnd/normalize/combatturns/normalizer_test.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package combatturns
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
||||||
|
doc := testDocument()
|
||||||
|
references := npcReferences(t)
|
||||||
|
normalizer, err := New(Options{}, references)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
resolution := " the target is hit "
|
||||||
|
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: " storm ",
|
||||||
|
TurnKind: dnd.CombatTurnKindTurn,
|
||||||
|
Actions: []dnd.CombatAction{{
|
||||||
|
Category: dnd.CombatActionCategoryAttack,
|
||||||
|
Declaration: " attacks\n with a sword ",
|
||||||
|
Targets: []string{" minion ", "goblin", " unknown combatant "},
|
||||||
|
Resolution: &resolution,
|
||||||
|
}},
|
||||||
|
Summary: " Aria\n attacks ",
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
||||||
|
}}}
|
||||||
|
original := cloneCombatTurn(input.CombatTurns[0])
|
||||||
|
|
||||||
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
|
||||||
|
Source: doc,
|
||||||
|
MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := result.Value.CombatTurns[0]; got.Actor != "Aria" || got.Summary != "Aria attacks" || got.Actions[0].Declaration != "attacks with a sword" || got.Actions[0].Resolution == nil || *got.Actions[0].Resolution != "the target is hit" {
|
||||||
|
t.Fatalf("normalized turn = %#v, want display-normalized fields", got)
|
||||||
|
}
|
||||||
|
if got := result.Value.CombatTurns[0].Actions[0].Targets; !reflect.DeepEqual(got, []string{"Goblin", "unknown combatant"}) {
|
||||||
|
t.Fatalf("normalized targets = %#v, want canonical deduplicated target and preserved unmatched target", got)
|
||||||
|
}
|
||||||
|
wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}
|
||||||
|
if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
|
||||||
|
t.Fatalf("normalized refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
|
||||||
|
}
|
||||||
|
for _, reason := range []string{ReasonCodeFieldsNormalized, ReasonCodeActorCanonicalized, ReasonCodeTargetCanonicalized, ReasonCodeSourceRefsNormalized} {
|
||||||
|
if !hasWarningReason(result.Warnings, reason) {
|
||||||
|
t.Fatalf("warnings = %#v, missing reason %q", result.Warnings, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(input.CombatTurns[0], original) {
|
||||||
|
t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original)
|
||||||
|
}
|
||||||
|
result.Value.CombatTurns[0].Actions[0].Targets[0] = "changed"
|
||||||
|
if input.CombatTurns[0].Actions[0].Targets[0] == "changed" {
|
||||||
|
t.Fatal("normalized targets share input storage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}}
|
||||||
|
first := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50})
|
||||||
|
first.Summary = "first record"
|
||||||
|
second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90})
|
||||||
|
second.Summary = "later record"
|
||||||
|
duplicate := cloneCombatTurn(first)
|
||||||
|
duplicate.Summary = "must not replace first"
|
||||||
|
duplicate.Actions[0].Declaration = "replacement action"
|
||||||
|
invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})
|
||||||
|
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}}
|
||||||
|
|
||||||
|
normalizer, err := New(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
|
||||||
|
Source: doc,
|
||||||
|
MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Value.CombatTurns) != 3 {
|
||||||
|
t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns))
|
||||||
|
}
|
||||||
|
if result.Value.CombatTurns[0].Summary != "first record" || result.Value.CombatTurns[0].Actions[0].Declaration != "Aria attacks" || result.Value.CombatTurns[1].Summary != "later record" || result.Value.CombatTurns[2].Actor != "Unknown" {
|
||||||
|
t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns)
|
||||||
|
}
|
||||||
|
if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
||||||
|
t.Fatalf("warnings = %#v, want reorder and duplicate warnings", result.Warnings)
|
||||||
|
}
|
||||||
|
for _, warning := range result.Warnings {
|
||||||
|
if warning.ReasonCode == ReasonCodeDuplicateCollapsed && warning.Scope != "combat_turns[1]" {
|
||||||
|
t.Fatalf("duplicate warning = %#v, want retained input scope combat_turns[1]", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}}
|
||||||
|
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
||||||
|
validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}),
|
||||||
|
validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}),
|
||||||
|
}}
|
||||||
|
normalizer, err := New(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor}; !reflect.DeepEqual(got, []string{"Aria", "Borin"}) {
|
||||||
|
t.Fatalf("equal-position order = %#v, want stable input order", got)
|
||||||
|
}
|
||||||
|
if hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) {
|
||||||
|
t.Fatalf("warnings = %#v, equal-position stable sort should not warn", result.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) {
|
||||||
|
doc := testDocument()
|
||||||
|
base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})
|
||||||
|
base.Round = nil
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
other dnd.CombatTurn
|
||||||
|
}{
|
||||||
|
{name: "different actor", other: withActor(base, "Borin")},
|
||||||
|
{name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)},
|
||||||
|
{name: "different round", other: withRound(base, 2)},
|
||||||
|
{name: "different evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20})},
|
||||||
|
{name: "invalid evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
normalizer, err := New(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
|
||||||
|
Source: doc,
|
||||||
|
MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{base, test.other}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Value.CombatTurns) != 2 {
|
||||||
|
t.Fatalf("normalized turn count = %d, want distinct records", len(result.Value.CombatTurns))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePreservesNilAndPresentEmptyStorage(t *testing.T) {
|
||||||
|
normalizer, err := New(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, input := range []dnd.CombatTurnList{
|
||||||
|
{},
|
||||||
|
{CombatTurns: []dnd.CombatTurn{}},
|
||||||
|
} {
|
||||||
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if (result.Value.CombatTurns == nil) != (input.CombatTurns == nil) {
|
||||||
|
t.Fatalf("nil/present-empty distinction lost: input %#v output %#v", input.CombatTurns, result.Value.CombatTurns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T) {
|
||||||
|
unbound, err := New(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != NormalizationPolicy || metadata["identity_policy"] != identity.Policy {
|
||||||
|
t.Fatalf("unbound metadata = %#v", metadata)
|
||||||
|
}
|
||||||
|
if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" {
|
||||||
|
t.Fatalf("unbound fingerprints = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
bound, err := New(Options{}, npcReferences(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bound New() error = %v", err)
|
||||||
|
}
|
||||||
|
metadata := bound.ManifestMetadata()
|
||||||
|
if metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 {
|
||||||
|
t.Fatalf("bound metadata = %#v", metadata)
|
||||||
|
}
|
||||||
|
if got := bound.CheckpointFingerprints(); len(got) != 3 || got[2].Name != "npc_registry" || got[2].Value == "" {
|
||||||
|
t.Fatalf("bound fingerprints = %#v", got)
|
||||||
|
}
|
||||||
|
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 5 {
|
||||||
|
t.Fatalf("ModuleSpec() = %#v", spec)
|
||||||
|
}
|
||||||
|
registry := pipeline.NewNormalizerRegistry()
|
||||||
|
if err := Register(registry); err != nil {
|
||||||
|
t.Fatalf("Register() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
|
t.Fatal("DecodeOptions() accepted unknown option")
|
||||||
|
}
|
||||||
|
if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil {
|
||||||
|
t.Fatal("New() accepted multiple reference sets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) {
|
||||||
|
normalizer, err := New(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := normalizer.Normalize(nil, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil {
|
||||||
|
t.Fatal("Normalize() accepted nil context")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := normalizer.Normalize(ctx, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil {
|
||||||
|
t.Fatal("Normalize() accepted canceled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTurn(actor string, ref source.SourceRef) dnd.CombatTurn {
|
||||||
|
return dnd.CombatTurn{
|
||||||
|
Actor: actor,
|
||||||
|
TurnKind: dnd.CombatTurnKindTurn,
|
||||||
|
Round: intPointer(1),
|
||||||
|
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: actor + " attacks", Targets: []string{}, Resolution: nil}},
|
||||||
|
Summary: actor + " attacks",
|
||||||
|
SourceRefs: []source.SourceRef{ref},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDocument() *source.SourceDocument {
|
||||||
|
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10, Text: "Aria attacks the goblin."}, {ID: 20, Text: "The goblin is hit."}, {ID: 30, Text: "The goblin falls."}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func npcReferences(t *testing.T) contracts.ReferenceSet {
|
||||||
|
t.Helper()
|
||||||
|
npcs := dnd.NPCList{NPCs: []dnd.NPC{
|
||||||
|
{ID: identity.DeriveID("Aria"), Name: "Aria", Aliases: []string{"Storm"}, Description: "a fighter", Relationships: []dnd.NPCRelationship{{Target: "Goblin", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
|
||||||
|
{ID: identity.DeriveID("Goblin"), Name: "Goblin", Aliases: []string{"Minion"}, Description: "a goblin", Relationships: []dnd.NPCRelationship{{Target: "Aria", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
|
||||||
|
}}
|
||||||
|
content, err := npccodec.New().Encode(npcs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode NPC references: %v", err)
|
||||||
|
}
|
||||||
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
|
NPCRegistryReferenceSlot: {
|
||||||
|
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: NPCRegistryMaxBytes},
|
||||||
|
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasWarningReason(warnings []contracts.Warning, reason string) bool {
|
||||||
|
for _, warning := range warnings {
|
||||||
|
if warning.ReasonCode == reason {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPointer(value int) *int { return &value }
|
||||||
|
|
||||||
|
func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn {
|
||||||
|
turn.Actor = actor
|
||||||
|
turn.Actions = cloneCombatTurn(turn).Actions
|
||||||
|
return turn
|
||||||
|
}
|
||||||
|
|
||||||
|
func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn {
|
||||||
|
turn.TurnKind = kind
|
||||||
|
turn.Actions = cloneCombatTurn(turn).Actions
|
||||||
|
return turn
|
||||||
|
}
|
||||||
|
|
||||||
|
func withRound(turn dnd.CombatTurn, round int) dnd.CombatTurn {
|
||||||
|
turn.Round = intPointer(round)
|
||||||
|
turn.Actions = cloneCombatTurn(turn).Actions
|
||||||
|
return turn
|
||||||
|
}
|
||||||
|
|
||||||
|
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
|
||||||
|
turn.SourceRefs = []source.SourceRef{ref}
|
||||||
|
turn.Actions = cloneCombatTurn(turn).Actions
|
||||||
|
return turn
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/diagnostics"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
204
internal/modules/dnd/npcs/registry/registry.go
Normal file
204
internal/modules/dnd/npcs/registry/registry.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// Package registry resolves normalized NPC artifacts into immutable grounding
|
||||||
|
// data for D&D extraction modules.
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReferenceSlot = "npcs"
|
||||||
|
MaxBytes = 1048576
|
||||||
|
emptyPrompt = `{"npcs":[]}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry is an immutable, validated NPC registry prepared for prompt
|
||||||
|
// grounding. All accessors return defensive copies.
|
||||||
|
type Registry struct {
|
||||||
|
bound bool
|
||||||
|
list dnd.NPCList
|
||||||
|
canonical []byte
|
||||||
|
digest string
|
||||||
|
promptInput contracts.LLMInputMaterial
|
||||||
|
lookupByKey map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve prepares the optional NPC registry reference. An absent slot
|
||||||
|
// produces the exact empty prompt input and no semantic registry identity.
|
||||||
|
func Resolve(references contracts.ReferenceSet) (*Registry, error) {
|
||||||
|
slot, ok := references.Slots[ReferenceSlot]
|
||||||
|
if !ok {
|
||||||
|
content := []byte(emptyPrompt)
|
||||||
|
return &Registry{
|
||||||
|
list: dnd.NPCList{NPCs: []dnd.NPC{}},
|
||||||
|
canonical: append([]byte(nil), content...),
|
||||||
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, "", ""),
|
||||||
|
lookupByKey: map[string]int{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if len(slot.Items) != 1 {
|
||||||
|
return nil, fmt.Errorf("reference slot %q must contain exactly one item", ReferenceSlot)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := slot.Items[0]
|
||||||
|
mediaType, _, err := mime.ParseMediaType(item.MediaType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reference slot %q item media type is invalid", ReferenceSlot)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(mediaType, npccodec.MediaType) {
|
||||||
|
return nil, fmt.Errorf("reference slot %q item media type must be %s", ReferenceSlot, npccodec.MediaType)
|
||||||
|
}
|
||||||
|
if len(item.Content) > MaxBytes {
|
||||||
|
return nil, fmt.Errorf("reference slot %q item is %d bytes, limit %d", ReferenceSlot, len(item.Content), MaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
codec := npccodec.New()
|
||||||
|
value, err := codec.Decode(item.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode NPC registry: invalid approved NPC JSON")
|
||||||
|
}
|
||||||
|
if issues := identity.ValidateList(value); len(issues) > 0 {
|
||||||
|
return nil, fmt.Errorf("%s", formatIdentityIssues(issues))
|
||||||
|
}
|
||||||
|
content, err := codec.Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode canonical NPC registry: approved NPC value could not be encoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := cloneNPCList(value)
|
||||||
|
lookupByKey := make(map[string]int, len(list.NPCs)*2)
|
||||||
|
for index, npc := range list.NPCs {
|
||||||
|
lookupByKey[identity.ComparisonKey(npc.Name)] = index
|
||||||
|
for _, alias := range npc.Aliases {
|
||||||
|
lookupByKey[identity.ComparisonKey(alias)] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digest := semanticDigest(content)
|
||||||
|
return &Registry{
|
||||||
|
bound: true,
|
||||||
|
list: list,
|
||||||
|
canonical: append([]byte(nil), content...),
|
||||||
|
digest: digest,
|
||||||
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, digest, ""),
|
||||||
|
lookupByKey: lookupByKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New is an alias for Resolve for callers constructing a prepared registry.
|
||||||
|
func New(references contracts.ReferenceSet) (*Registry, error) { return Resolve(references) }
|
||||||
|
|
||||||
|
// Bound reports whether an NPC reference was supplied and validated.
|
||||||
|
func (r *Registry) Bound() bool { return r != nil && r.bound }
|
||||||
|
|
||||||
|
// NPCs returns a defensive copy of the validated NPC records.
|
||||||
|
func (r *Registry) NPCs() []dnd.NPC {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cloneNPCs(r.list.NPCs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a defensive copy of the validated NPC list.
|
||||||
|
func (r *Registry) List() dnd.NPCList {
|
||||||
|
if r == nil {
|
||||||
|
return dnd.NPCList{}
|
||||||
|
}
|
||||||
|
return cloneNPCList(r.list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalBytes returns a defensive copy of the canonical durable JSON.
|
||||||
|
func (r *Registry) CanonicalBytes() []byte {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]byte(nil), r.canonical...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest returns the semantic SHA-256 digest of the canonical JSON, or an
|
||||||
|
// empty string when the registry is unbound.
|
||||||
|
func (r *Registry) Digest() string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of validated NPC records.
|
||||||
|
func (r *Registry) Count() int {
|
||||||
|
if r == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(r.list.NPCs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptInput returns the canonical registry as a content-safe prompt input.
|
||||||
|
// Reference provenance is deliberately omitted.
|
||||||
|
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
||||||
|
if r == nil {
|
||||||
|
return contracts.LLMInputMaterial{}
|
||||||
|
}
|
||||||
|
return r.promptInput.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the canonical NPC for an exact canonical-name or alias match
|
||||||
|
// under the NPC identity comparison policy.
|
||||||
|
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return dnd.NPC{}, false
|
||||||
|
}
|
||||||
|
index, ok := r.lookupByKey[identity.ComparisonKey(value)]
|
||||||
|
if !ok {
|
||||||
|
return dnd.NPC{}, false
|
||||||
|
}
|
||||||
|
return cloneNPC(r.list.NPCs[index]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func semanticDigest(content []byte) string {
|
||||||
|
sum := sha256.Sum256(content)
|
||||||
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIdentityIssues(issues []identity.Issue) string {
|
||||||
|
parts := make([]string, len(issues))
|
||||||
|
for index, issue := range issues {
|
||||||
|
location := fmt.Sprintf("record %d", issue.RecordIndex)
|
||||||
|
if issue.AliasIndex >= 0 {
|
||||||
|
location += fmt.Sprintf(" alias %d", issue.AliasIndex)
|
||||||
|
}
|
||||||
|
parts[index] = fmt.Sprintf("%s at %s", issue.Code, location)
|
||||||
|
}
|
||||||
|
return diagnostics.Aggregate("validate NPC registry identity", parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneNPCList(value dnd.NPCList) dnd.NPCList {
|
||||||
|
return dnd.NPCList{NPCs: cloneNPCs(value.NPCs)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneNPCs(values []dnd.NPC) []dnd.NPC {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]dnd.NPC, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
cloned[index] = cloneNPC(value)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneNPC(value dnd.NPC) dnd.NPC {
|
||||||
|
value.Aliases = append([]string(nil), value.Aliases...)
|
||||||
|
value.Relationships = append([]dnd.NPCRelationship(nil), value.Relationships...)
|
||||||
|
value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||||
|
return value
|
||||||
|
}
|
||||||
224
internal/modules/dnd/npcs/registry/registry_test.go
Normal file
224
internal/modules/dnd/npcs/registry/registry_test.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"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 TestResolveAbsentRegistryUsesExactEmptyPrompt(t *testing.T) {
|
||||||
|
resolved, err := Resolve(contracts.ReferenceSet{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if resolved.Bound() || resolved.Digest() != "" || resolved.Count() != 0 {
|
||||||
|
t.Fatalf("resolved unbound registry = %#v, want no semantic metadata", resolved)
|
||||||
|
}
|
||||||
|
input := resolved.PromptInput()
|
||||||
|
if input.Name != ReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" {
|
||||||
|
t.Fatalf("unbound prompt input metadata = %#v, want name/media type only", input)
|
||||||
|
}
|
||||||
|
if got := string(input.Content); got != emptyPrompt {
|
||||||
|
t.Fatalf("unbound prompt input = %q, want exact empty registry", got)
|
||||||
|
}
|
||||||
|
if got := string(resolved.CanonicalBytes()); got != emptyPrompt {
|
||||||
|
t.Fatalf("unbound canonical bytes = %q, want exact empty registry", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCanonicalizesAndProvidesSemanticIdentity(t *testing.T) {
|
||||||
|
value := validRegistryList()
|
||||||
|
canonical := encodeRegistry(t, value)
|
||||||
|
raw := append([]byte(" \n"), canonical...)
|
||||||
|
raw = append(raw, []byte("\n ")...)
|
||||||
|
|
||||||
|
resolved, err := Resolve(registryReference(raw, "file:///another-session/npcs.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if !resolved.Bound() || resolved.Count() != len(value.NPCs) {
|
||||||
|
t.Fatalf("resolved registry = %#v, want bound registry with %d NPC", resolved, len(value.NPCs))
|
||||||
|
}
|
||||||
|
if !bytes.Equal(resolved.CanonicalBytes(), canonical) || !bytes.Equal(resolved.PromptInput().Content, canonical) {
|
||||||
|
t.Fatalf("canonical content = %s, want %s", resolved.CanonicalBytes(), canonical)
|
||||||
|
}
|
||||||
|
if resolved.PromptInput().Digest != resolved.Digest() || !strings.HasPrefix(resolved.Digest(), "sha256:") {
|
||||||
|
t.Fatalf("semantic digest = %q, want SHA-256 digest", resolved.Digest())
|
||||||
|
}
|
||||||
|
if resolved.PromptInput().OriginURI != "" {
|
||||||
|
t.Fatalf("prompt input origin = %q, want no provenance path", resolved.PromptInput().OriginURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRejectsInvalidBoundaryValuesWithoutContent(t *testing.T) {
|
||||||
|
valid := validRegistryList()
|
||||||
|
second := valid.NPCs[0]
|
||||||
|
second.ID = identity.DeriveID("Captain Vale")
|
||||||
|
second.Name = "Captain Vale"
|
||||||
|
second.Aliases = []string{"The Greencloak"}
|
||||||
|
valueWithAliasCollision := dnd.NPCList{NPCs: []dnd.NPC{valid.NPCs[0], second}}
|
||||||
|
invalidID := valid
|
||||||
|
invalidID.NPCs[0].ID = "not-an-npc-id"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reference contracts.ReferenceSet
|
||||||
|
wantError string
|
||||||
|
forbidden []string
|
||||||
|
}{
|
||||||
|
{name: "zero items", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{}}}}, wantError: "exactly one"},
|
||||||
|
{name: "multiple", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{{Content: []byte(emptyPrompt)}, {Content: []byte(emptyPrompt)}}}}}, wantError: "exactly one"},
|
||||||
|
{name: "wrong media type", reference: registryReferenceWithMedia([]byte(emptyPrompt), "text/plain"), wantError: "must be application/json"},
|
||||||
|
{name: "malformed JSON", reference: registryReference([]byte(`{"npcs":[],"MALFORMED_REGISTRY_SECRET":`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"MALFORMED_REGISTRY_SECRET"}},
|
||||||
|
{name: "unknown field", reference: registryReference([]byte(`{"npcs":[],"UNKNOWN_FIELD_SECRET":true}`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"UNKNOWN_FIELD_SECRET"}},
|
||||||
|
{name: "invalid ID", reference: registryReference(marshalRegistry(t, invalidID), "file:///private.json"), wantError: "decode NPC registry"},
|
||||||
|
{name: "alias collision", reference: registryReference(encodeRegistry(t, valueWithAliasCollision), "file:///private.json"), wantError: string(identity.IssueAliasOwnershipCollision)},
|
||||||
|
{name: "byte limit", reference: registryReference(bytes.Repeat([]byte("x"), MaxBytes+1), "file:///private.json"), wantError: "limit"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := Resolve(test.reference)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want %q", err, test.wantError)
|
||||||
|
}
|
||||||
|
for _, forbidden := range append(test.forbidden, "Mira Thorn", "The Greencloak", "private.json") {
|
||||||
|
if strings.Contains(err.Error(), forbidden) {
|
||||||
|
t.Fatalf("error leaked registry content or provenance %q: %v", forbidden, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBoundsIdentityDiagnosticsWithoutContent(t *testing.T) {
|
||||||
|
const recordCount = 30
|
||||||
|
value := dnd.NPCList{NPCs: make([]dnd.NPC, recordCount)}
|
||||||
|
for index := range value.NPCs {
|
||||||
|
value.NPCs[index] = dnd.NPC{
|
||||||
|
ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
Name: fmt.Sprintf("PRIVATE NPC %d", index),
|
||||||
|
Aliases: []string{"PRIVATE SHARED ALIAS"},
|
||||||
|
Description: "PRIVATE DESCRIPTION",
|
||||||
|
Relationships: []dnd.NPCRelationship{},
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "private-source", StartUnitID: 1, EndUnitID: 1}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues := identity.ValidateList(value)
|
||||||
|
if len(issues) <= diagnostics.MaxIssues {
|
||||||
|
t.Fatalf("identity issues = %d, want more than display limit", len(issues))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Resolve(registryReference(marshalRegistry(t, value), "file:///private-registry.json"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Resolve() error = nil, want bounded identity rejection")
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
if !utf8.ValidString(message) || len([]byte(message)) > diagnostics.MaxMessageBytes {
|
||||||
|
t.Fatalf("identity error has invalid encoding or size: bytes=%d message=%q", len([]byte(message)), message)
|
||||||
|
}
|
||||||
|
wantOmitted := fmt.Sprintf("%d additional issue(s) omitted", len(issues)-diagnostics.MaxIssues)
|
||||||
|
if !strings.Contains(message, wantOmitted) {
|
||||||
|
t.Fatalf("identity error = %q, want %q", message, wantOmitted)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"PRIVATE NPC", "PRIVATE SHARED ALIAS", "PRIVATE DESCRIPTION", "private-source", "private-registry.json"} {
|
||||||
|
if strings.Contains(message, forbidden) {
|
||||||
|
t.Fatalf("identity error leaked %q: %s", forbidden, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryAccessorsAndLookupAreDefensive(t *testing.T) {
|
||||||
|
resolved, err := Resolve(registryReference(encodeRegistry(t, validRegistryList()), "file:///npc-registry.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
npcs := resolved.NPCs()
|
||||||
|
npcs[0].Name = "changed"
|
||||||
|
npcs[0].Aliases[0] = "changed alias"
|
||||||
|
npcs[0].Relationships[0].Target = "changed target"
|
||||||
|
npcs[0].SourceRefs[0].SourceID = "changed source"
|
||||||
|
if got, ok := resolved.Lookup("The Greencloak"); !ok || got.Name != "Mira Thorn" {
|
||||||
|
t.Fatalf("Lookup() after NPC mutation = %#v, %v, want original NPC", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCanonical := string(resolved.CanonicalBytes())
|
||||||
|
content := resolved.CanonicalBytes()
|
||||||
|
content[0] = 'X'
|
||||||
|
input := resolved.PromptInput()
|
||||||
|
input.Content[0] = 'X'
|
||||||
|
if string(resolved.CanonicalBytes()) != wantCanonical || string(resolved.PromptInput().Content) != wantCanonical {
|
||||||
|
t.Fatal("registry content accessors share mutable state")
|
||||||
|
}
|
||||||
|
if got, ok := resolved.Lookup(" MIRA\u00a0THORN "); !ok || got.Name != "Mira Thorn" {
|
||||||
|
t.Fatalf("Lookup() canonical identity = %#v, %v, want Mira Thorn", got, ok)
|
||||||
|
}
|
||||||
|
if _, ok := resolved.Lookup("unknown NPC"); ok {
|
||||||
|
t.Fatal("Lookup() found unknown NPC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRegistryList() dnd.NPCList {
|
||||||
|
return dnd.NPCList{NPCs: []dnd.NPC{{
|
||||||
|
ID: identity.DeriveID("Mira Thorn"),
|
||||||
|
Name: "Mira Thorn",
|
||||||
|
Aliases: []string{"The Greencloak"},
|
||||||
|
Description: "A guarded ranger who watches the northern road.",
|
||||||
|
Relationships: []dnd.NPCRelationship{{
|
||||||
|
Target: "Captain Vale", Relationship: "reports to",
|
||||||
|
}},
|
||||||
|
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRegistry(t *testing.T, value dnd.NPCList) []byte {
|
||||||
|
t.Helper()
|
||||||
|
content, err := npccodec.New().Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode NPC registry: %v", err)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalRegistry(t *testing.T, value dnd.NPCList) []byte {
|
||||||
|
t.Helper()
|
||||||
|
content, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal NPC registry: %v", err)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryReference(content []byte, origin string) contracts.ReferenceSet {
|
||||||
|
references := registryReferenceWithMedia(content, "application/json; charset=utf-8")
|
||||||
|
item := references.Slots[ReferenceSlot].Items[0]
|
||||||
|
item.Origin.URI = origin
|
||||||
|
slot := references.Slots[ReferenceSlot]
|
||||||
|
slot.Items[0] = item
|
||||||
|
references.Slots[ReferenceSlot] = slot
|
||||||
|
return references
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryReferenceWithMedia(content []byte, mediaType string) contracts.ReferenceSet {
|
||||||
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
|
ReferenceSlot: {
|
||||||
|
Slot: contracts.ReferenceSlot{Name: ReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: MaxBytes},
|
||||||
|
Items: []contracts.ReferenceItem{{
|
||||||
|
SlotName: ReferenceSlot,
|
||||||
|
MediaType: mediaType,
|
||||||
|
Content: append([]byte(nil), content...),
|
||||||
|
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///npc-registry.json"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -4,16 +4,24 @@ package register
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||||
|
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||||
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||||
|
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
|
||||||
|
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"
|
||||||
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||||
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
||||||
@@ -42,19 +50,28 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
|||||||
}{
|
}{
|
||||||
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
||||||
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
||||||
|
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
||||||
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
|
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
|
||||||
{name: "spells extractor", register: func() error { return spells.Register(registries.Extractors) }},
|
{name: "spells extractor", register: func() error { return spells.Register(registries.Extractors) }},
|
||||||
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
||||||
|
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
||||||
{name: "spell-list appendorder merger", register: func() error {
|
{name: "spell-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
|
||||||
}},
|
}},
|
||||||
{name: "npc-list appendorder merger", register: func() error {
|
{name: "npc-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists)
|
||||||
}},
|
}},
|
||||||
|
{name: "combat-turn-list appendorder merger", register: func() error {
|
||||||
|
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
||||||
|
}},
|
||||||
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
||||||
|
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }},
|
{name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }},
|
||||||
{name: "npc-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind) }},
|
{name: "npc-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind) }},
|
||||||
|
{name: "combat-turn-list noop normalizer", register: func() error {
|
||||||
|
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
||||||
|
}},
|
||||||
{name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }},
|
{name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }},
|
||||||
{name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }},
|
{name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }},
|
||||||
{name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }},
|
{name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }},
|
||||||
@@ -63,6 +80,10 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
|||||||
{name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }},
|
{name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }},
|
||||||
{name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }},
|
{name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }},
|
||||||
{name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }},
|
{name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }},
|
||||||
|
{name: "combat shape validator", register: func() error { return combatshape.Register(registries.Validators) }},
|
||||||
|
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
|
||||||
|
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
|
||||||
|
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
|
||||||
{name: "spell-list always accept validator", register: func() error {
|
{name: "spell-list always accept validator", register: func() error {
|
||||||
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
||||||
}},
|
}},
|
||||||
@@ -75,9 +96,16 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
|||||||
{name: "npc-list always reject validator", register: func() error {
|
{name: "npc-list always reject validator", register: func() error {
|
||||||
return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
|
return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
|
||||||
}},
|
}},
|
||||||
|
{name: "combat-turn-list always accept validator", register: func() error {
|
||||||
|
return alwaysaccept.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||||
|
}},
|
||||||
|
{name: "combat-turn-list always reject validator", register: func() error {
|
||||||
|
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||||
|
}},
|
||||||
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
|
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
|
||||||
{name: "spells prompt assets", register: func() error { return spells.RegisterPromptAssets(assets) }},
|
{name: "spells prompt assets", register: func() error { return spells.RegisterPromptAssets(assets) }},
|
||||||
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
|
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
|
||||||
|
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
|
||||||
}
|
}
|
||||||
for _, registration := range registrations {
|
for _, registration := range registrations {
|
||||||
if err := registration.register(); err != nil {
|
if err := registration.register(); err != nil {
|
||||||
@@ -139,6 +167,33 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("register dnd npcs normalize validator chain: %w", err)
|
return fmt.Errorf("register dnd npcs normalize validator chain: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||||
|
Stage: pipeline.StageExtract,
|
||||||
|
Module: combatextract.Key,
|
||||||
|
Validators: []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding(validjson.Key),
|
||||||
|
pipeline.Binding(validjsonschema.Key),
|
||||||
|
pipeline.Binding(combatshape.Key),
|
||||||
|
pipeline.Binding(combatsourcerefs.Key),
|
||||||
|
pipeline.Binding(combatrelatedness.Key),
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("register dnd combat turns validator chain: %w", err)
|
||||||
|
}
|
||||||
|
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||||
|
Stage: pipeline.StageNormalize,
|
||||||
|
Module: combatnormalize.Key,
|
||||||
|
Validators: []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding(validjson.Key),
|
||||||
|
pipeline.Binding(validjsonschema.Key),
|
||||||
|
pipeline.Binding(combatshape.Key),
|
||||||
|
pipeline.Binding(combatinvariants.Key),
|
||||||
|
pipeline.Binding(combatsourcerefs.Key),
|
||||||
|
pipeline.Binding(combatrelatedness.Key),
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("register dnd combat turns normalize validator chain: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +228,52 @@ func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
|
|||||||
return combined, nil
|
return combined, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, error) {
|
||||||
|
count := 0
|
||||||
|
present := false
|
||||||
|
for _, value := range values {
|
||||||
|
if value.CombatTurns != nil {
|
||||||
|
present = true
|
||||||
|
}
|
||||||
|
count += len(value.CombatTurns)
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
return dnd.CombatTurnList{}, nil
|
||||||
|
}
|
||||||
|
combined := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 0, count)}
|
||||||
|
for _, value := range values {
|
||||||
|
for _, turn := range value.CombatTurns {
|
||||||
|
combined.CombatTurns = append(combined.CombatTurns, cloneCombatTurn(turn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return combined, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||||
|
clone := value
|
||||||
|
if value.Round != nil {
|
||||||
|
round := *value.Round
|
||||||
|
clone.Round = &round
|
||||||
|
}
|
||||||
|
if value.Actions != nil {
|
||||||
|
clone.Actions = make([]dnd.CombatAction, len(value.Actions))
|
||||||
|
for index, action := range value.Actions {
|
||||||
|
clone.Actions[index] = action
|
||||||
|
if action.Targets != nil {
|
||||||
|
clone.Actions[index].Targets = append([]string(nil), action.Targets...)
|
||||||
|
}
|
||||||
|
if action.Resolution != nil {
|
||||||
|
resolution := *action.Resolution
|
||||||
|
clone.Actions[index].Resolution = &resolution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.SourceRefs != nil {
|
||||||
|
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
||||||
switch {
|
switch {
|
||||||
case registries.Chunkers == nil:
|
case registries.Chunkers == nil:
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||||
)
|
)
|
||||||
@@ -24,12 +27,13 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
t.Fatalf("Register() error = %v, want nil", err)
|
t.Fatalf("Register() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
||||||
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key})
|
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key})
|
||||||
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, pipeline.DefaultNormalizeModule})
|
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})
|
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})
|
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})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
|
||||||
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
||||||
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||||
"extract/dnd/npcs/shape",
|
"extract/dnd/npcs/shape",
|
||||||
"extract/dnd/npcs/source_refs",
|
"extract/dnd/npcs/source_refs",
|
||||||
@@ -39,6 +43,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
"extract/dnd/spells/shape",
|
"extract/dnd/spells/shape",
|
||||||
"extract/dnd/spells/source_refs",
|
"extract/dnd/spells/source_refs",
|
||||||
"extract/dnd/spells/source_relatedness",
|
"extract/dnd/spells/source_relatedness",
|
||||||
|
"extract/dnd/combat-turns/shape",
|
||||||
|
"extract/dnd/combat-turns/source_refs",
|
||||||
|
"extract/dnd/combat-turns/source_relatedness",
|
||||||
|
"normalize/dnd/combat-turns/invariants",
|
||||||
"generic/always_accept",
|
"generic/always_accept",
|
||||||
"generic/always_reject",
|
"generic/always_reject",
|
||||||
})
|
})
|
||||||
@@ -77,6 +85,27 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, npcNormalizeChain) {
|
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, npcNormalizeChain) {
|
||||||
t.Fatalf("NPC normalize validator chain = %#v, want %#v", got, npcNormalizeChain)
|
t.Fatalf("NPC normalize validator chain = %#v, want %#v", got, npcNormalizeChain)
|
||||||
}
|
}
|
||||||
|
combatExtractChain := []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding("generic/valid_json"),
|
||||||
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||||
|
}
|
||||||
|
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) {
|
||||||
|
t.Fatalf("combat extract validator chain = %#v, want %#v", got, combatExtractChain)
|
||||||
|
}
|
||||||
|
combatNormalizeChain := []pipeline.ModuleBinding{
|
||||||
|
pipeline.Binding("generic/valid_json"),
|
||||||
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||||
|
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||||
|
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||||
|
}
|
||||||
|
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
|
||||||
|
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
|
||||||
|
}
|
||||||
if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil {
|
if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil {
|
||||||
t.Fatalf("NPC merge validator chain = %#v, want absent", got)
|
t.Fatalf("NPC merge validator chain = %#v, want absent", got)
|
||||||
}
|
}
|
||||||
@@ -99,11 +128,18 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
"dnd.npcs/sharedassets/common-dnd-system.md",
|
"dnd.npcs/sharedassets/common-dnd-system.md",
|
||||||
"dnd.npcs/sharedassets/common-dnd-transcript.md",
|
"dnd.npcs/sharedassets/common-dnd-transcript.md",
|
||||||
"dnd.npcs/task.md",
|
"dnd.npcs/task.md",
|
||||||
|
"dnd.combat_turns/dnd.combat_turns.yaml",
|
||||||
|
"dnd.combat_turns/instructions.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-references.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-system.md",
|
||||||
|
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||||
|
"dnd.combat_turns/task.md",
|
||||||
})
|
})
|
||||||
assertAssetNamesContain(t, assets.SchemaFS, []string{
|
assertAssetNamesContain(t, assets.SchemaFS, []string{
|
||||||
"dnd_scenes.v1.json",
|
"dnd_scenes.v1.json",
|
||||||
"dnd_spells_llm.v1.json",
|
"dnd_spells_llm.v1.json",
|
||||||
"dnd_npcs_llm.v1.json",
|
"dnd_npcs_llm.v1.json",
|
||||||
|
"dnd_combat_turns_llm.v1.json",
|
||||||
})
|
})
|
||||||
if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" {
|
if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" {
|
||||||
t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok)
|
t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok)
|
||||||
@@ -120,6 +156,12 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCListKind || spec.Stage != pipeline.StageNormalize {
|
if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCListKind || spec.Stage != pipeline.StageNormalize {
|
||||||
t.Fatalf("NPC normalizer spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok)
|
t.Fatalf("NPC normalizer spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok)
|
||||||
}
|
}
|
||||||
|
if spec, ok := registries.Extractors.Spec(combatextract.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind {
|
||||||
|
t.Fatalf("combat extractor spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
|
func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
|
||||||
@@ -143,6 +185,44 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||||
|
round := 1
|
||||||
|
resolution := "hit"
|
||||||
|
targets := []string{"Mira"}
|
||||||
|
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
||||||
|
input := []dnd.CombatTurnList{
|
||||||
|
{CombatTurns: []dnd.CombatTurn{{Actor: "first", Round: &round, Actions: []dnd.CombatAction{{Targets: targets, Resolution: &resolution}}, SourceRefs: refs}}},
|
||||||
|
{CombatTurns: []dnd.CombatTurn{{Actor: "second"}}},
|
||||||
|
}
|
||||||
|
got, err := appendCombatTurnLists(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("appendCombatTurnLists() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if len(got.CombatTurns) != 2 || got.CombatTurns[0].Actor != "first" || got.CombatTurns[1].Actor != "second" {
|
||||||
|
t.Fatalf("combat turns = %#v, want chunk order", got.CombatTurns)
|
||||||
|
}
|
||||||
|
if got.CombatTurns[0].Round == &round || &got.CombatTurns[0].Actions[0].Targets[0] == &targets[0] || got.CombatTurns[0].Actions[0].Resolution == &resolution || &got.CombatTurns[0].SourceRefs[0] == &refs[0] {
|
||||||
|
t.Fatal("appendCombatTurnLists() retained nested input aliases")
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in []dnd.CombatTurnList
|
||||||
|
want dnd.CombatTurnList
|
||||||
|
}{
|
||||||
|
{name: "no values", in: nil, want: dnd.CombatTurnList{}},
|
||||||
|
{name: "nil values", in: []dnd.CombatTurnList{{}, {}}, want: dnd.CombatTurnList{}},
|
||||||
|
{name: "present empty", in: []dnd.CombatTurnList{{CombatTurns: []dnd.CombatTurn{}}}, want: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{}}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := appendCombatTurnLists(test.in)
|
||||||
|
if err != nil || !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("appendCombatTurnLists() = %#v, error = %v, want %#v", got, err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) {
|
func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var sharedPromptFiles = []string{
|
|||||||
"common-dnd-system.md",
|
"common-dnd-system.md",
|
||||||
"common-dnd-transcript.md",
|
"common-dnd-transcript.md",
|
||||||
"common-dnd-references.md",
|
"common-dnd-references.md",
|
||||||
|
"common-dnd-npcs.md",
|
||||||
}
|
}
|
||||||
|
|
||||||
func SharedPromptFiles() []promptfs.SharedPromptFile {
|
func SharedPromptFiles() []promptfs.SharedPromptFile {
|
||||||
@@ -39,6 +40,7 @@ func CommonHashParts() []llm.AssetHashPart {
|
|||||||
func ReferenceHashParts() []llm.AssetHashPart {
|
func ReferenceHashParts() []llm.AssetHashPart {
|
||||||
return []llm.AssetHashPart{
|
return []llm.AssetHashPart{
|
||||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-references.md"},
|
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-references.md"},
|
||||||
|
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-npcs.md"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
Registry content is context, not event evidence. Do not extract events,
|
||||||
|
participants, effects, or source references from the registry. Registry source
|
||||||
|
references describe registry provenance and may belong to another session; they
|
||||||
|
are never evidence for the current transcript.
|
||||||
|
|
||||||
|
{{ input "npcs" }}
|
||||||
@@ -13,8 +13,8 @@ func TestSharedPromptFilesReturnsNewSlice(t *testing.T) {
|
|||||||
first := SharedPromptFiles()
|
first := SharedPromptFiles()
|
||||||
second := SharedPromptFiles()
|
second := SharedPromptFiles()
|
||||||
|
|
||||||
if len(first) != 3 || len(second) != 3 {
|
if len(first) != 4 || len(second) != 4 {
|
||||||
t.Fatalf("SharedPromptFiles() lengths = %d and %d, want 3", len(first), len(second))
|
t.Fatalf("SharedPromptFiles() lengths = %d and %d, want 4", len(first), len(second))
|
||||||
}
|
}
|
||||||
first[0].Name = "changed.md"
|
first[0].Name = "changed.md"
|
||||||
if second[0].Name != "common-dnd-system.md" {
|
if second[0].Name != "common-dnd-system.md" {
|
||||||
@@ -40,6 +40,7 @@ func TestHashPartsReferenceSharedPrompts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assertHashParts(t, "reference", ReferenceHashParts(), []string{
|
assertHashParts(t, "reference", ReferenceHashParts(), []string{
|
||||||
"assets/prompts/common-dnd-references.md",
|
"assets/prompts/common-dnd-references.md",
|
||||||
|
"assets/prompts/common-dnd-npcs.md",
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, part := range append(CommonHashParts(), ReferenceHashParts()...) {
|
for _, part := range append(CommonHashParts(), ReferenceHashParts()...) {
|
||||||
@@ -79,6 +80,7 @@ func TestModulePromptFSMountsDNDSharedPrompts(t *testing.T) {
|
|||||||
"assets/prompts/dnd.test/sharedassets/common-dnd-system.md",
|
"assets/prompts/dnd.test/sharedassets/common-dnd-system.md",
|
||||||
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md",
|
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md",
|
||||||
"assets/prompts/dnd.test/sharedassets/common-dnd-references.md",
|
"assets/prompts/dnd.test/sharedassets/common-dnd-references.md",
|
||||||
|
"assets/prompts/dnd.test/sharedassets/common-dnd-npcs.md",
|
||||||
} {
|
} {
|
||||||
if _, err := fs.ReadFile(fsys, path); err != nil {
|
if _, err := fs.ReadFile(fsys, path); err != nil {
|
||||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package diagnostics provides bounded, safe text for deterministic NPC
|
// Package diagnostics provides bounded, safe text for deterministic D&D
|
||||||
// decisions and warnings.
|
// decisions and warnings.
|
||||||
package diagnostics
|
package diagnostics
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
|||||||
issues[index] = fmt.Sprintf("issue-%d-%s", index, strings.Repeat("火", MaxDisplayedRunes))
|
issues[index] = fmt.Sprintf("issue-%d-%s", index, strings.Repeat("火", MaxDisplayedRunes))
|
||||||
}
|
}
|
||||||
|
|
||||||
message := Aggregate("invalid NPC data", issues)
|
message := Aggregate("invalid D&D data", issues)
|
||||||
if !utf8.ValidString(message) || len([]byte(message)) > MaxMessageBytes {
|
if !utf8.ValidString(message) || len([]byte(message)) > MaxMessageBytes {
|
||||||
t.Fatalf("Aggregate() returned invalid or oversized message: bytes=%d message=%q", len([]byte(message)), message)
|
t.Fatalf("Aggregate() returned invalid or oversized message: bytes=%d message=%q", len([]byte(message)), message)
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ const SpellListKind contracts.ArtifactKind = "dnd/spell-list"
|
|||||||
|
|
||||||
const NPCListKind contracts.ArtifactKind = "dnd/npc-list"
|
const NPCListKind contracts.ArtifactKind = "dnd/npc-list"
|
||||||
|
|
||||||
|
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
||||||
|
|
||||||
type SpellList struct {
|
type SpellList struct {
|
||||||
SpellCasts []SpellCast `json:"spell_casts"`
|
SpellCasts []SpellCast `json:"spell_casts"`
|
||||||
}
|
}
|
||||||
@@ -39,3 +41,46 @@ type NPCRelationship struct {
|
|||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
Relationship string `json:"relationship"`
|
Relationship string `json:"relationship"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CombatTurnList struct {
|
||||||
|
CombatTurns []CombatTurn `json:"combat_turns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CombatTurnKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CombatTurnKindTurn CombatTurnKind = "turn"
|
||||||
|
CombatTurnKindReaction CombatTurnKind = "reaction"
|
||||||
|
CombatTurnKindLegendaryAction CombatTurnKind = "legendary_action"
|
||||||
|
CombatTurnKindLairAction CombatTurnKind = "lair_action"
|
||||||
|
CombatTurnKindOther CombatTurnKind = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CombatTurn struct {
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
TurnKind CombatTurnKind `json:"turn_kind"`
|
||||||
|
Round *int `json:"round"`
|
||||||
|
Actions []CombatAction `json:"actions"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CombatActionCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CombatActionCategoryAttack CombatActionCategory = "attack"
|
||||||
|
CombatActionCategorySpell CombatActionCategory = "spell"
|
||||||
|
CombatActionCategoryMovement CombatActionCategory = "movement"
|
||||||
|
CombatActionCategoryItem CombatActionCategory = "item"
|
||||||
|
CombatActionCategoryAbilityCheck CombatActionCategory = "ability_check"
|
||||||
|
CombatActionCategorySavingThrow CombatActionCategory = "saving_throw"
|
||||||
|
CombatActionCategoryCondition CombatActionCategory = "condition"
|
||||||
|
CombatActionCategoryOther CombatActionCategory = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CombatAction struct {
|
||||||
|
Category CombatActionCategory `json:"category"`
|
||||||
|
Declaration string `json:"declaration"`
|
||||||
|
Targets []string `json:"targets"`
|
||||||
|
Resolution *string `json:"resolution"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Package invariants validates normalized D&D combat-turn artifacts.
|
||||||
|
package invariants
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"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/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
|
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Key = "normalize/dnd/combat-turns/invariants"
|
||||||
|
ReasonCode = "invalid_combat_turn_normalization"
|
||||||
|
policy = "dnd.combat_turns.validator.normalized.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
type Validator struct{}
|
||||||
|
|
||||||
|
var _ contracts.TypedValidator[dnd.CombatTurnList] = (*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.CombatTurnList]) (contracts.ValidationResult, error) {
|
||||||
|
if err := Validate(req.Source, req.Value); err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||||
|
}
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks only invariants owned by normalized combat-turn output. A
|
||||||
|
// shape or source-reference failure is deliberately deferred to its owner.
|
||||||
|
func Validate(doc *source.SourceDocument, value dnd.CombatTurnList) error {
|
||||||
|
if combatshape.Validate(value) != nil || !sourceRefsValid(doc, value) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
issues := issuesFor(doc, value)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn normalization", issues))
|
||||||
|
}
|
||||||
|
|
||||||
|
func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
||||||
|
issues := make([]string, 0)
|
||||||
|
seenIdentity := make(map[string]int)
|
||||||
|
previousPosition := -1
|
||||||
|
for turnIndex, turn := range value.CombatTurns {
|
||||||
|
prefix := fmt.Sprintf("combat_turns[%d]", turnIndex)
|
||||||
|
if turn.Actor != identity.NormalizeDisplay(turn.Actor) {
|
||||||
|
issues = append(issues, prefix+".actor is not display-normalized: "+diagnostics.Quote(turn.Actor))
|
||||||
|
}
|
||||||
|
if turn.Summary != identity.NormalizeDisplay(turn.Summary) {
|
||||||
|
issues = append(issues, prefix+".summary is not display-normalized: "+diagnostics.Quote(turn.Summary))
|
||||||
|
}
|
||||||
|
for actionIndex, action := range turn.Actions {
|
||||||
|
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
|
||||||
|
if action.Declaration != identity.NormalizeDisplay(action.Declaration) {
|
||||||
|
issues = append(issues, actionPrefix+".declaration is not display-normalized: "+diagnostics.Quote(action.Declaration))
|
||||||
|
}
|
||||||
|
seenTargets := make(map[string]int, len(action.Targets))
|
||||||
|
for targetIndex, target := range action.Targets {
|
||||||
|
if target != identity.NormalizeDisplay(target) {
|
||||||
|
issues = append(issues, fmt.Sprintf("%s.targets[%d] is not display-normalized: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
|
||||||
|
}
|
||||||
|
key := identity.ComparisonKey(target)
|
||||||
|
if previous, exists := seenTargets[key]; exists {
|
||||||
|
issues = append(issues, fmt.Sprintf("%s.targets[%d] duplicates target %d under comparison identity", actionPrefix, targetIndex, previous))
|
||||||
|
} else {
|
||||||
|
seenTargets[key] = targetIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action.Resolution != nil && *action.Resolution != identity.NormalizeDisplay(*action.Resolution) {
|
||||||
|
issues = append(issues, actionPrefix+".resolution is not display-normalized: "+diagnostics.Quote(*action.Resolution))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
|
||||||
|
previous := turn.SourceRefs[refIndex-1]
|
||||||
|
current := turn.SourceRefs[refIndex]
|
||||||
|
if sourceRefLess(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
position, ok := earliestSourcePosition(doc, turn)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if previousPosition > position {
|
||||||
|
issues = append(issues, fmt.Sprintf("%s is out of chronological order: evidence position %d follows %d", prefix, position, previousPosition))
|
||||||
|
}
|
||||||
|
previousPosition = position
|
||||||
|
|
||||||
|
if key, ok := duplicateKey(turn); ok {
|
||||||
|
if previous, exists := seenIdentity[key]; exists {
|
||||||
|
issues = append(issues, fmt.Sprintf("%s duplicates combat turn %d under normalized identity", prefix, previous))
|
||||||
|
} else {
|
||||||
|
seenIdentity[key] = turnIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool {
|
||||||
|
for _, turn := range value.CombatTurns {
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
if source.ValidateRef(doc, ref) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRefLess(left, right source.SourceRef) bool {
|
||||||
|
if left.SourceID != right.SourceID {
|
||||||
|
return left.SourceID < right.SourceID
|
||||||
|
}
|
||||||
|
if left.StartUnitID != right.StartUnitID {
|
||||||
|
return left.StartUnitID < right.StartUnitID
|
||||||
|
}
|
||||||
|
return left.EndUnitID < right.EndUnitID
|
||||||
|
}
|
||||||
|
|
||||||
|
func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) {
|
||||||
|
if doc == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
earliest := 0
|
||||||
|
found := false
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
if source.ValidateRef(doc, ref) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index, ok := source.UnitIndex(doc, ref.StartUnitID)
|
||||||
|
if !ok || (found && index >= earliest) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
earliest = index
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return earliest, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateKey(turn dnd.CombatTurn) (string, bool) {
|
||||||
|
if len(turn.SourceRefs) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var key strings.Builder
|
||||||
|
writeKeyString(&key, identity.ComparisonKey(turn.Actor))
|
||||||
|
writeKeyString(&key, string(turn.TurnKind))
|
||||||
|
if turn.Round == nil {
|
||||||
|
key.WriteByte('0')
|
||||||
|
} else {
|
||||||
|
key.WriteByte('1')
|
||||||
|
writeKeyInt(&key, *turn.Round)
|
||||||
|
}
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
writeKeyString(&key, ref.SourceID)
|
||||||
|
writeKeyInt(&key, ref.StartUnitID)
|
||||||
|
writeKeyInt(&key, ref.EndUnitID)
|
||||||
|
}
|
||||||
|
return key.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
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(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], 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 }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package invariants
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"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 TestValidatorApprovesNormalizedCombatTurns(t *testing.T) {
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: normalizedList()})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*dnd.CombatTurnList, *source.SourceDocument)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "actor display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actor = " Aria " }, want: "actor is not display-normalized"},
|
||||||
|
{name: "summary display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
value.CombatTurns[0].Summary = "Aria attacks"
|
||||||
|
}, want: "summary is not display-normalized"},
|
||||||
|
{name: "declaration display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
value.CombatTurns[0].Actions[0].Declaration = "Aria attacks"
|
||||||
|
}, want: "declaration is not display-normalized"},
|
||||||
|
{name: "target display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
value.CombatTurns[0].Actions[0].Targets = []string{" Goblin"}
|
||||||
|
}, want: "targets[0] is not display-normalized"},
|
||||||
|
{name: "duplicate target identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
value.CombatTurns[0].Actions[0].Targets = []string{"Goblin", " goblin"}
|
||||||
|
}, want: "duplicates target"},
|
||||||
|
{name: "resolution display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
resolution := " hit "
|
||||||
|
value.CombatTurns[0].Actions[0].Resolution = &resolution
|
||||||
|
}, want: "resolution is not display-normalized"},
|
||||||
|
{name: "reference order", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||||
|
}, want: "not in canonical order"},
|
||||||
|
{name: "duplicate reference", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
value.CombatTurns[0].SourceRefs = append(value.CombatTurns[0].SourceRefs, value.CombatTurns[0].SourceRefs[0])
|
||||||
|
}, want: "duplicates the previous reference"},
|
||||||
|
{name: "chronology", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]}
|
||||||
|
}, want: "out of chronological order"},
|
||||||
|
{name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||||
|
duplicate := value.CombatTurns[0]
|
||||||
|
duplicate.Summary = "different prose"
|
||||||
|
value.CombatTurns = append(value.CombatTurns, duplicate)
|
||||||
|
}, want: "duplicates combat turn"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
value := normalizedList()
|
||||||
|
doc := invariantDocument()
|
||||||
|
test.mutate(&value, doc)
|
||||||
|
err := Validate(doc, value)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
|
||||||
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||||
|
t.Fatalf("Validator result = %#v, %v; want normalized-invariant rejection", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorDefersShapeAndSourceReferenceFailures(t *testing.T) {
|
||||||
|
doc := invariantDocument()
|
||||||
|
shapeInvalid := normalizedList()
|
||||||
|
shapeInvalid.CombatTurns[0].Actor = " "
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: shapeInvalid})
|
||||||
|
if err != nil || !result.Approved {
|
||||||
|
t.Fatalf("shape-invalid result = %#v, %v; want deferral", result, err)
|
||||||
|
}
|
||||||
|
sourceInvalid := normalizedList()
|
||||||
|
sourceInvalid.CombatTurns[0].SourceRefs[0].StartUnitID = 999
|
||||||
|
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: sourceInvalid})
|
||||||
|
if err != nil || !result.Approved {
|
||||||
|
t.Fatalf("source-invalid result = %#v, %v; want deferral", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||||
|
value := normalizedList()
|
||||||
|
value.CombatTurns = make([]dnd.CombatTurn, 30)
|
||||||
|
for index := range value.CombatTurns {
|
||||||
|
value.CombatTurns[index] = normalizedList().CombatTurns[0]
|
||||||
|
value.CombatTurns[index].Actor = strings.Repeat("火", 300)
|
||||||
|
}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: value})
|
||||||
|
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||||
|
t.Fatalf("bounded result = %#v, %v; want bounded rejection", result, err)
|
||||||
|
}
|
||||||
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||||
|
t.Fatalf("CheckpointFingerprints() = %#v, want policy fingerprint", got)
|
||||||
|
}
|
||||||
|
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||||
|
t.Fatalf("Spec() = %#v", spec)
|
||||||
|
}
|
||||||
|
registry := pipeline.NewValidatorRegistry()
|
||||||
|
if err := Register(registry); err != nil {
|
||||||
|
t.Fatalf("Register() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
|
t.Fatal("DecodeOptions() accepted unknown option")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(New(Options{}).CheckpointFingerprints(), []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) {
|
||||||
|
t.Fatal("policy fingerprint changed unexpectedly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedList() dnd.CombatTurnList {
|
||||||
|
resolution := "hit"
|
||||||
|
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1),
|
||||||
|
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}},
|
||||||
|
Summary: "Aria attacks", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invariantDocument() *source.SourceDocument {
|
||||||
|
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPointer(value int) *int { return &value }
|
||||||
|
|
||||||
|
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
|
||||||
|
turn.SourceRefs = []source.SourceRef{ref}
|
||||||
|
return turn
|
||||||
|
}
|
||||||
143
internal/modules/dnd/validate/combatturns/shape/validator.go
Normal file
143
internal/modules/dnd/validate/combatturns/shape/validator.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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/combat-turns/shape"
|
||||||
|
ReasonCode = "invalid_combat_turn_shape"
|
||||||
|
policy = "dnd.combat_turns.validator.shape.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
type Validator struct{}
|
||||||
|
|
||||||
|
var _ contracts.TypedValidator[dnd.CombatTurnList] = (*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.CombatTurnList]) (contracts.ValidationResult, error) {
|
||||||
|
if err := Validate(req.Value); err != nil {
|
||||||
|
return rejection(err.Error()), nil
|
||||||
|
}
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Validate(value dnd.CombatTurnList) error {
|
||||||
|
issues := issuesFor(value)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn shape", issues))
|
||||||
|
}
|
||||||
|
|
||||||
|
func issuesFor(value dnd.CombatTurnList) []string {
|
||||||
|
if value.CombatTurns == nil {
|
||||||
|
return []string{"combat_turns must be present"}
|
||||||
|
}
|
||||||
|
issues := make([]string, 0)
|
||||||
|
for turnIndex, turn := range value.CombatTurns {
|
||||||
|
prefix := fmt.Sprintf("combat_turns[%d]", turnIndex)
|
||||||
|
if strings.TrimSpace(turn.Actor) == "" {
|
||||||
|
issues = append(issues, prefix+".actor must not be empty: "+diagnostics.Quote(turn.Actor))
|
||||||
|
}
|
||||||
|
if !validTurnKind(turn.TurnKind) {
|
||||||
|
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind)))
|
||||||
|
}
|
||||||
|
if turn.Round != nil && *turn.Round <= 0 {
|
||||||
|
issues = append(issues, prefix+".round must be positive or null")
|
||||||
|
}
|
||||||
|
if len(turn.Actions) == 0 {
|
||||||
|
issues = append(issues, prefix+".actions must contain at least one action")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(turn.Summary) == "" {
|
||||||
|
issues = append(issues, prefix+".summary must not be empty: "+diagnostics.Quote(turn.Summary))
|
||||||
|
}
|
||||||
|
if len(turn.SourceRefs) == 0 {
|
||||||
|
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||||
|
}
|
||||||
|
for actionIndex, action := range turn.Actions {
|
||||||
|
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
|
||||||
|
if !validActionCategory(action.Category) {
|
||||||
|
issues = append(issues, actionPrefix+".category is unsupported: "+diagnostics.Quote(string(action.Category)))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(action.Declaration) == "" {
|
||||||
|
issues = append(issues, actionPrefix+".declaration must not be empty: "+diagnostics.Quote(action.Declaration))
|
||||||
|
}
|
||||||
|
if action.Targets == nil {
|
||||||
|
issues = append(issues, actionPrefix+".targets must be present")
|
||||||
|
} else {
|
||||||
|
for targetIndex, target := range action.Targets {
|
||||||
|
if strings.TrimSpace(target) == "" {
|
||||||
|
issues = append(issues, fmt.Sprintf("%s.targets[%d] must not be empty: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" {
|
||||||
|
issues = append(issues, actionPrefix+".resolution must not be empty or null: "+diagnostics.Quote(*action.Resolution))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTurnKind(value dnd.CombatTurnKind) bool {
|
||||||
|
switch value {
|
||||||
|
case dnd.CombatTurnKindTurn, dnd.CombatTurnKindReaction, dnd.CombatTurnKindLegendaryAction, dnd.CombatTurnKindLairAction, dnd.CombatTurnKindOther:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validActionCategory(value dnd.CombatActionCategory) bool {
|
||||||
|
switch value {
|
||||||
|
case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther:
|
||||||
|
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.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], 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 }
|
||||||
|
|
||||||
|
func rejection(message string) contracts.ValidationResult {
|
||||||
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package shape
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"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 TestValidatorApprovesWellFormedCombatTurnList(t *testing.T) {
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: validCombatTurnList()})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsEveryOwnedShapeBoundary(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*dnd.CombatTurnList)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "missing combat turns", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns = nil }, want: "combat_turns must be present"},
|
||||||
|
{name: "empty actor", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }, want: "actor must not be empty"},
|
||||||
|
{name: "unsupported turn kind", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unknown" }, want: "turn_kind is unsupported"},
|
||||||
|
{name: "non-positive round", mutate: func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }, want: "round must be positive"},
|
||||||
|
{name: "missing actions", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }, want: "actions must contain"},
|
||||||
|
{name: "empty summary", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }, want: "summary must not be empty"},
|
||||||
|
{name: "missing source refs", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }, want: "source_refs must contain"},
|
||||||
|
{name: "unsupported category", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unknown" }, want: "category is unsupported"},
|
||||||
|
{name: "empty declaration", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }, want: "declaration must not be empty"},
|
||||||
|
{name: "missing targets", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }, want: "targets must be present"},
|
||||||
|
{name: "empty target", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }, want: "targets[0] must not be empty"},
|
||||||
|
{name: "empty resolution", mutate: func(value *dnd.CombatTurnList) {
|
||||||
|
resolution := " "
|
||||||
|
value.CombatTurns[0].Actions[0].Resolution = &resolution
|
||||||
|
}, want: "resolution must not be empty"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
value := validCombatTurnList()
|
||||||
|
test.mutate(&value)
|
||||||
|
if err := Validate(value); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: value})
|
||||||
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||||
|
t.Fatalf("Validator result = %#v, %v; want shape rejection", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorBoundsAggregateDiagnostics(t *testing.T) {
|
||||||
|
value := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 30)}
|
||||||
|
for index := range value.CombatTurns {
|
||||||
|
value.CombatTurns[index].Actor = strings.Repeat("火", 300)
|
||||||
|
value.CombatTurns[index].TurnKind = "invalid"
|
||||||
|
}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: value})
|
||||||
|
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||||
|
t.Fatalf("bounded result = %#v, %v; want safe bounded diagnostics", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||||
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||||
|
t.Fatalf("CheckpointFingerprints() = %#v, want shape policy", got)
|
||||||
|
}
|
||||||
|
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||||
|
t.Fatalf("Spec() = %#v, want deterministic shape validator", Spec())
|
||||||
|
}
|
||||||
|
registry := pipeline.NewValidatorRegistry()
|
||||||
|
if err := Register(registry); err != nil {
|
||||||
|
t.Fatalf("Register() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
|
t.Fatal("DecodeOptions() accepted unknown option")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCombatTurnList() dnd.CombatTurnList {
|
||||||
|
round := 2
|
||||||
|
resolution := "The goblin is wounded."
|
||||||
|
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: &round,
|
||||||
|
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
|
||||||
|
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package sourcerefs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Key = "extract/dnd/combat-turns/source_refs"
|
||||||
|
ReasonCode = "invalid_combat_turn_source_refs"
|
||||||
|
policy = "dnd.combat_turns.validator.source_refs.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
type Validator struct{}
|
||||||
|
|
||||||
|
var _ contracts.TypedValidator[dnd.CombatTurnList] = (*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.CombatTurnList]) (contracts.ValidationResult, error) {
|
||||||
|
if err := combatshape.Validate(req.Value); err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
issues := sourceRefIssues(req.Source, req.Value)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
return contracts.ValidationResult{
|
||||||
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("invalid combat turn source references", issues),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRefIssues(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
||||||
|
issues := make([]string, 0)
|
||||||
|
for turnIndex, turn := range value.CombatTurns {
|
||||||
|
for refIndex, ref := range turn.SourceRefs {
|
||||||
|
if err := source.ValidateRef(doc, ref); err != nil {
|
||||||
|
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], 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 }
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package sourcerefs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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 TestValidatorApprovesValidSourceReferences(t *testing.T) {
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: validCombatTurnList()})
|
||||||
|
if err != nil || !result.Approved || result.ReasonCode != "" {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want approval", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorRejectsInvalidSourceIdentityExistenceAndOrder(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ref source.SourceRef
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "missing document", ref: source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, want: "source document must not be nil"},
|
||||||
|
{name: "wrong source identity", ref: source.SourceRef{SourceID: "other", StartUnitID: 1, EndUnitID: 1}, want: "does not match document"},
|
||||||
|
{name: "missing start", ref: source.SourceRef{SourceID: "session", StartUnitID: 99, EndUnitID: 99}, want: "start_unit_id 99 was not found"},
|
||||||
|
{name: "backward range", ref: source.SourceRef{SourceID: "session", StartUnitID: 3, EndUnitID: 1}, want: "appears after end_unit_id"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
value := validCombatTurnList()
|
||||||
|
value.CombatTurns[0].SourceRefs[0] = test.ref
|
||||||
|
req := contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value}
|
||||||
|
if test.name == "missing document" {
|
||||||
|
req.Source = nil
|
||||||
|
}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), req)
|
||||||
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want source-reference rejection containing %q", result, err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorDefersMalformedShape(t *testing.T) {
|
||||||
|
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
|
t.Fatalf("shape deferral = %#v, %v; want approval without source warning", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||||
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||||
|
t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got)
|
||||||
|
}
|
||||||
|
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||||
|
t.Fatalf("Spec() = %#v, want deterministic source-reference validator", Spec())
|
||||||
|
}
|
||||||
|
registry := pipeline.NewValidatorRegistry()
|
||||||
|
if err := Register(registry); err != nil {
|
||||||
|
t.Fatalf("Register() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
|
t.Fatal("DecodeOptions() accepted unknown option")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCombatTurnList() dnd.CombatTurnList {
|
||||||
|
resolution := "The goblin is wounded."
|
||||||
|
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||||
|
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
|
||||||
|
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDocument() *source.SourceDocument {
|
||||||
|
return &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{
|
||||||
|
{ID: 1, Kind: "message", Text: "Aria attacks."},
|
||||||
|
{ID: 2, Kind: "message", Text: "The goblin reels."},
|
||||||
|
{ID: 3, Kind: "message", Text: "Borin retreats."},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package sourcerelatedness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"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/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
|
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Key = "extract/dnd/combat-turns/source_relatedness"
|
||||||
|
WarningReasonCode = "combat_turn_not_near_source"
|
||||||
|
policy = "dnd.combat_turns.validator.source_relatedness.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
type Validator struct{}
|
||||||
|
|
||||||
|
var _ contracts.TypedValidator[dnd.CombatTurnList] = (*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.CombatTurnList]) (contracts.ValidationResult, error) {
|
||||||
|
if combatshape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings := make([]contracts.Warning, 0)
|
||||||
|
for turnIndex, turn := range req.Value.CombatTurns {
|
||||||
|
citedText := citedTextKey(req.Source, turn.SourceRefs)
|
||||||
|
issues := make([]string, 0)
|
||||||
|
if !actorAppearsInCitedText(citedText, turn.Actor) {
|
||||||
|
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)))
|
||||||
|
}
|
||||||
|
for actionIndex, action := range turn.Actions {
|
||||||
|
if !declarationAppearsInCitedText(citedText, action.Declaration) {
|
||||||
|
issues = append(issues, fmt.Sprintf("action %d declaration %s was not found in cited source text", actionIndex, diagnostics.Quote(action.Declaration)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(issues) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
warnings = append(warnings, contracts.Warning{
|
||||||
|
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
|
||||||
|
ReasonCode: WarningReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("combat turn not near source", issues),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool {
|
||||||
|
for _, turn := range value.CombatTurns {
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
if source.ValidateRef(doc, ref) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string {
|
||||||
|
if doc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
included := make([]bool, len(doc.Units))
|
||||||
|
for _, ref := range refs {
|
||||||
|
start, _ := source.UnitIndex(doc, ref.StartUnitID)
|
||||||
|
end, _ := source.UnitIndex(doc, ref.EndUnitID)
|
||||||
|
for index := start; index <= end && index < len(included); index++ {
|
||||||
|
included[index] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := make([]string, 0)
|
||||||
|
for index, unit := range doc.Units {
|
||||||
|
if included[index] {
|
||||||
|
parts = append(parts, unit.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identity.ComparisonKey(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func actorAppearsInCitedText(citedText string, actor string) bool {
|
||||||
|
key := identity.ComparisonKey(actor)
|
||||||
|
return key != "" && strings.Contains(citedText, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func declarationAppearsInCitedText(citedText string, declaration string) bool {
|
||||||
|
citedTokens := tokenSet(citedText)
|
||||||
|
for _, token := range comparisonTokens(declaration) {
|
||||||
|
if utf8.RuneCountInString(token) >= 4 {
|
||||||
|
if _, ok := citedTokens[token]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonTokens(value string) []string {
|
||||||
|
value = identity.ComparisonKey(value)
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.FieldsFunc(value, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenSet(value string) map[string]struct{} {
|
||||||
|
tokens := comparisonTokens(value)
|
||||||
|
set := make(map[string]struct{}, len(tokens))
|
||||||
|
for _, token := range tokens {
|
||||||
|
set[token] = struct{}{}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], 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 }
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package sourcerelatedness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"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 TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t *testing.T) {
|
||||||
|
resolution := "The goblin is hit."
|
||||||
|
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn,
|
||||||
|
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "O'Rin attacks", Targets: []string{"unmentioned target"}, Resolution: &resolution}},
|
||||||
|
Summary: "O'Rin attacks.", SourceRefs: []source.SourceRef{
|
||||||
|
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
|
||||||
|
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{
|
||||||
|
{ID: 1, Kind: "message", Text: "o’rin\u2003thorn advances."},
|
||||||
|
{ID: 2, Kind: "message", Text: "O’rin attacks the goblin."},
|
||||||
|
}}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want Unicode-related approval without target warning", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) {
|
||||||
|
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction,
|
||||||
|
Actions: []dnd.CombatAction{
|
||||||
|
{Category: dnd.CombatActionCategoryOther, Declaration: "hit", Targets: []string{}, Resolution: nil},
|
||||||
|
{Category: dnd.CombatActionCategoryOther, Declaration: "unseen monster", Targets: []string{}, Resolution: nil},
|
||||||
|
},
|
||||||
|
Summary: "An unrelated event.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
|
}}}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err)
|
||||||
|
}
|
||||||
|
warning := result.Warnings[0]
|
||||||
|
if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !strings.Contains(warning.Message, "action 0") || !strings.Contains(warning.Message, "action 1") || !utf8.ValidString(warning.Message) {
|
||||||
|
t.Fatalf("warning = %#v, want one safely quoted bounded warning", warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
||||||
|
invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
|
t.Fatalf("shape deferral = %#v, %v; want approval without warning", result, err)
|
||||||
|
}
|
||||||
|
invalidRange := validCombatTurnList()
|
||||||
|
invalidRange.CombatTurns[0].SourceRefs[0].StartUnitID = 99
|
||||||
|
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidRange})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
|
t.Fatalf("invalid-range deferral = %#v, %v; want approval without warning", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorIgnoresReferenceMaterialAndRegistersPolicy(t *testing.T) {
|
||||||
|
value := validCombatTurnList()
|
||||||
|
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Aria attacks")}}}}}
|
||||||
|
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party waits."}}}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, References: references, Value: value})
|
||||||
|
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||||
|
t.Fatalf("reference-only relatedness = %#v, %v; want warning from transcript-only evidence", result, err)
|
||||||
|
}
|
||||||
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||||
|
t.Fatalf("CheckpointFingerprints() = %#v, want relatedness policy", got)
|
||||||
|
}
|
||||||
|
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||||
|
t.Fatalf("Spec() = %#v, want deterministic relatedness validator", Spec())
|
||||||
|
}
|
||||||
|
registry := pipeline.NewValidatorRegistry()
|
||||||
|
if err := Register(registry); err != nil {
|
||||||
|
t.Fatalf("Register() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
|
t.Fatal("DecodeOptions() accepted unknown option")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCombatTurnList() dnd.CombatTurnList {
|
||||||
|
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||||
|
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||||
|
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"absent target"}, Resolution: nil}},
|
||||||
|
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func relatednessDocument() *source.SourceDocument {
|
||||||
|
return &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party waits."}}}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/diagnostics"
|
|
||||||
domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/diagnostics"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
358
internal/modules/integration/dnd_combat_runner_test.go
Normal file
358
internal/modules/integration/dnd_combat_runner_test.go
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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"
|
||||||
|
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||||
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||||
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
|
"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/seriatim/input/transcript"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
catalog := moduleCatalog(registries)
|
||||||
|
npcPayload := combatTestNPCPayload(t)
|
||||||
|
configValue := combatOnlyConfig()
|
||||||
|
effective, err := configValue.Resolve(config.ResolveInput{
|
||||||
|
PipelineID: "dnd-combat-fixture",
|
||||||
|
Catalog: catalog,
|
||||||
|
ReferenceOverrides: []pipeline.ReferenceBinding{
|
||||||
|
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: npcPayload.path, BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: npcPayload.path, BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
materialized, warnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
|
||||||
|
if err != nil || len(warnings) != 0 {
|
||||||
|
t.Fatalf("MaterializeReferences() error = %v warnings = %#v, want no warnings", err, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeCombatLLMClient{responses: []string{
|
||||||
|
combatTestInvalidResponse(),
|
||||||
|
combatTestTurnResponse("The Greencloak", "turn", "watches", "The Greencloak", 1, 1),
|
||||||
|
combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2),
|
||||||
|
combatTestTurnResponse("Hooded Guard", "turn", "attacks", "The Greencloak", 3, 3),
|
||||||
|
}}
|
||||||
|
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
fingerprints := prepared.CheckpointFingerprints()
|
||||||
|
assertCombatFingerprint(t, fingerprints, "extract:combat:"+combatextract.Key+":npc_registry")
|
||||||
|
assertCombatFingerprint(t, fingerprints, "normalize:combat:"+combatnormalize.Key+":npc_registry")
|
||||||
|
if len(fingerprints) == 0 {
|
||||||
|
t.Fatal("checkpoint fingerprints = empty, want production combat identities")
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||||
|
Prepared: prepared,
|
||||||
|
RawInput: readNPCFixture(t),
|
||||||
|
ExtractWorkers: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 4 || len(output.Rejected) != 0 {
|
||||||
|
t.Fatalf("LLM requests = %d rejected = %#v, want one retry and no durable rejection", len(client.requests), output.Rejected)
|
||||||
|
}
|
||||||
|
if output.Manifest.ValidationStatus != "approved" || len(output.NormalizeOutputs) != 1 || len(output.OutputFiles) == 0 {
|
||||||
|
t.Fatalf("manifest/output = %#v / %#v, want approved combat JSON output", output.Manifest, output.OutputFiles)
|
||||||
|
}
|
||||||
|
serialized := output.NormalizeOutputs[0]
|
||||||
|
if serialized.LaneID != "combat" || serialized.NormalizerKey != combatnormalize.Key || serialized.Artifact.Schema.ID != combatcodec.SchemaID {
|
||||||
|
t.Fatalf("serialized combat output = %#v, want production combat lane schema", serialized)
|
||||||
|
}
|
||||||
|
value, err := combatcodec.New().Decode(serialized.Artifact.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode(combat output) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(value.CombatTurns) != 3 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[0].Actions[0].Targets[0] != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" {
|
||||||
|
t.Fatalf("normalized combat output = %#v, want ordered canonical actors and targets", value)
|
||||||
|
}
|
||||||
|
for _, turn := range value.CombatTurns {
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
if ref.SourceID != "npc-session" {
|
||||||
|
t.Fatalf("combat evidence ref = %#v, want current transcript source", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasCombatWarning(output.Warnings, "combat_turn_not_near_source") || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeTargetCanonicalized) {
|
||||||
|
t.Fatalf("warnings = %#v, want relatedness and registry normalization warnings", output.Warnings)
|
||||||
|
}
|
||||||
|
if len(output.Manifest.References) != 2 {
|
||||||
|
t.Fatalf("manifest references = %#v, want separate extract and normalize provenance", output.Manifest.References)
|
||||||
|
}
|
||||||
|
lane := output.Manifest.ArtifactLanes[0]
|
||||||
|
if lane.ID != "combat" || lane.Extractor != combatextract.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != combatnormalize.Key {
|
||||||
|
t.Fatalf("manifest lane = %#v, want complete combat composition", lane)
|
||||||
|
}
|
||||||
|
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
|
||||||
|
if !ok || extractorMetadata["npc_count"] != 2 || extractorMetadata["npc_registry_digest"] == "" {
|
||||||
|
t.Fatalf("extractor metadata = %#v, want registry digest and count", lane.Metadata)
|
||||||
|
}
|
||||||
|
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
|
||||||
|
if !ok || normalizerMetadata["npc_count"] != 2 || normalizerMetadata["normalization_policy"] != combatnormalize.NormalizationPolicy {
|
||||||
|
t.Fatalf("normalizer metadata = %#v, want registry and normalization policy", lane.Metadata)
|
||||||
|
}
|
||||||
|
var combatFile *contracts.OutputFile
|
||||||
|
for index := range output.OutputFiles {
|
||||||
|
if output.OutputFiles[index].Name == "lanes/combat.json" {
|
||||||
|
combatFile = &output.OutputFiles[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if combatFile == nil || combatFile.ContentType != combatcodec.MediaType {
|
||||||
|
t.Fatalf("output files = %#v, want lanes/combat.json JSON payload", output.OutputFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequentialNPCOutputGroundsCombatAtBothStageLocalReferences(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
catalog := moduleCatalog(registries)
|
||||||
|
configValue := loadSequentialCombatPipelineConfig(t)
|
||||||
|
npcEffective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npcs", Catalog: catalog})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve(NPC) error = %v", err)
|
||||||
|
}
|
||||||
|
npcClient := &fakeNPCProductionLLMClient{response: npcProductionResponse{NPCs: []npcProductionRecord{
|
||||||
|
{Name: "Mira Thorn", Aliases: []string{"The Greencloak", "Mira"}, Description: "A ranger.", Relationships: []npcProductionRelationship{}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 2}}},
|
||||||
|
{Name: "Hooded Guard", Aliases: []string{}, Description: "A sentry.", Relationships: []npcProductionRelationship{}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}},
|
||||||
|
}}}
|
||||||
|
npcOutput, err := runPreparedPipeline(t, registries, npcEffective.ResolvedPipeline, npcClient, pipeline.RunInput{RawInput: readNPCFixture(t)})
|
||||||
|
if err != nil || len(npcOutput.NormalizeOutputs) != 1 {
|
||||||
|
t.Fatalf("NPC run error = %v output = %#v, want one normalized NPC lane", err, npcOutput.NormalizeOutputs)
|
||||||
|
}
|
||||||
|
npcPayload := npcOutput.NormalizeOutputs[0].Artifact.Content
|
||||||
|
if _, err := npccodec.New().Decode(npcPayload); err != nil {
|
||||||
|
t.Fatalf("Decode(NPC output) error = %v", err)
|
||||||
|
}
|
||||||
|
npcPath := filepath.Join(t.TempDir(), "npc-run", "lanes", "npcs.json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(npcPath), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(npcPath, npcPayload, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
combatEffective, err := configValue.Resolve(config.ResolveInput{
|
||||||
|
PipelineID: "dnd-combat",
|
||||||
|
Catalog: catalog,
|
||||||
|
ReferenceOverrides: []pipeline.ReferenceBinding{
|
||||||
|
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: npcPath, BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: npcPath, BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve(combat) error = %v", err)
|
||||||
|
}
|
||||||
|
materialized, warnings, err := pipeline.MaterializeReferences(combatEffective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
|
||||||
|
if err != nil || len(warnings) != 0 {
|
||||||
|
t.Fatalf("MaterializeReferences() error = %v warnings = %#v", err, warnings)
|
||||||
|
}
|
||||||
|
client := &fakeCombatLLMClient{responses: []string{combatTestTurnResponse("The Greencloak", "turn", "watches", "Hooded Guard", 1, 1)}}
|
||||||
|
output, err := runPreparedPipeline(t, registries, materialized, client, pipeline.RunInput{RawInput: readNPCFixture(t)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run(combat) error = %v", err)
|
||||||
|
}
|
||||||
|
value, err := combatcodec.New().Decode(output.NormalizeOutputs[0].Artifact.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode(combat output) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(value.CombatTurns) != 1 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[0].Actions[0].Targets[0] != "Hooded Guard" {
|
||||||
|
t.Fatalf("combat value = %#v, want canonical actor and target", value)
|
||||||
|
}
|
||||||
|
for _, ref := range value.CombatTurns[0].SourceRefs {
|
||||||
|
if ref.SourceID != "npc-session" {
|
||||||
|
t.Fatalf("combat source ref = %#v, want transcript evidence only", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(output.Manifest.References) != 2 {
|
||||||
|
t.Fatalf("manifest references = %#v, want both stage-local NPC provenance entries", output.Manifest.References)
|
||||||
|
}
|
||||||
|
for _, provenance := range output.Manifest.References {
|
||||||
|
if provenance.SlotName != "npcs" || provenance.LaneID != "combat" || !strings.Contains(provenance.OriginURI, "npcs.json") || provenance.BindingSource != contracts.ReferenceBindingSourceCLI {
|
||||||
|
t.Fatalf("NPC provenance = %#v, want combat stage-local CLI binding", provenance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(client.requests) != 1 || string(client.requests[0].Inputs[combatextract.NPCRegistryReferenceSlot].Content) != string(npcPayload) || client.requests[0].Inputs[combatextract.NPCRegistryReferenceSlot].OriginURI != "" {
|
||||||
|
t.Fatalf("combat NPC prompt input = %#v, want canonical payload without path provenance", client.requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombatPreparationRejectsMalformedOrOversizedNPCReferencesBeforeExecution(t *testing.T) {
|
||||||
|
registries := productionNPCRegistries(t)
|
||||||
|
catalog := moduleCatalog(registries)
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
}{
|
||||||
|
{name: "malformed", content: []byte(`{"npcs":[`)},
|
||||||
|
{name: "oversized", content: append([]byte(`{"npcs":[]}`), make([]byte, npcregistry.MaxBytes+1)...)},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "npcs.json")
|
||||||
|
if err := os.WriteFile(path, test.content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := combatOnlyConfig()
|
||||||
|
effective, err := cfg.Resolve(config.ResolveInput{
|
||||||
|
PipelineID: "dnd-combat",
|
||||||
|
Catalog: catalog,
|
||||||
|
ReferenceOverrides: []pipeline.ReferenceBinding{
|
||||||
|
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: path, BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: path, BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want path binding to resolve before content preparation", err)
|
||||||
|
}
|
||||||
|
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
|
||||||
|
if test.name == "oversized" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "limit") {
|
||||||
|
t.Fatalf("MaterializeReferences() error = %v, want bounded oversized-reference failure", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MaterializeReferences() error = %v, want materialization before typed preparation", err)
|
||||||
|
}
|
||||||
|
client := &fakeCombatLLMClient{}
|
||||||
|
if _, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client}); err == nil {
|
||||||
|
t.Fatal("Prepare() error = nil, want NPC registry preparation failure")
|
||||||
|
}
|
||||||
|
if len(client.requests) != 0 {
|
||||||
|
t.Fatalf("LLM requests = %d, want no pipeline execution after preparation failure", len(client.requests))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type combatNPCPayload struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func combatTestNPCPayload(t *testing.T) combatNPCPayload {
|
||||||
|
t.Helper()
|
||||||
|
value := dnd.NPCList{NPCs: []dnd.NPC{
|
||||||
|
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak", "Mira"}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}},
|
||||||
|
{ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", Aliases: []string{}, Description: "A sentry.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}},
|
||||||
|
}}
|
||||||
|
content, err := npccodec.New().Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode(NPC fixture) error = %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "npc-run", "lanes", "npcs.json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return combatNPCPayload{path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func combatOnlyConfig() config.Config {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Pipelines["dnd-combat"] = pipeline.PipelineProfile{
|
||||||
|
Input: pipeline.Binding(transcript.Key),
|
||||||
|
Chunk: pipeline.ModuleBinding{Module: pipeline.DefaultChunkModule, Options: map[string]any{"max_units": 2}},
|
||||||
|
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||||
|
"combat": {
|
||||||
|
Extract: pipeline.ModuleBinding{Module: combatextract.Key, Retries: 2},
|
||||||
|
Normalize: pipeline.Binding(combatnormalize.Key),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.Pipelines["dnd-combat-fixture"] = cfg.Pipelines["dnd-combat"]
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSequentialCombatPipelineConfig(t *testing.T) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
fileConfig, err := config.LoadFileConfig(repositoryPathForIntegration("examples", "dnd-npc-combat-sequential.config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFileConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg := config.Default()
|
||||||
|
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
|
||||||
|
t.Fatalf("ApplyFileConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositoryPathForIntegration(parts ...string) string {
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
return filepath.Join(append([]string{filepath.Dir(file), "..", "..", ".."}, parts...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeCombatLLMClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
responses []string
|
||||||
|
requests []contracts.StructuredCompletionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeCombatLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
client.mu.Lock()
|
||||||
|
client.requests = append(client.requests, req)
|
||||||
|
index := len(client.requests) - 1
|
||||||
|
client.mu.Unlock()
|
||||||
|
if index >= len(client.responses) {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("no combat fake response for call %d", index)
|
||||||
|
}
|
||||||
|
content := []byte(client.responses[index])
|
||||||
|
if err := json.Unmarshal(content, out); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate combat fake target: %w", err)
|
||||||
|
}
|
||||||
|
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "combat-fake"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func combatTestInvalidResponse() string {
|
||||||
|
return `{"combat_turns":[{"actor":"","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"watches","targets":[],"resolution":null}],"summary":"invalid candidate","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func combatTestTurnResponse(actor, turnKind, declaration, target string, round, unit int) string {
|
||||||
|
return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"round":%d,"actions":[{"category":"attack","declaration":%q,"targets":[%q],"resolution":"observed"}],"summary":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, round, declaration, target, declaration, unit, unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCombatWarning(warnings []contracts.Warning, reason string) bool {
|
||||||
|
for _, warning := range warnings {
|
||||||
|
if warning.ReasonCode == reason {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCombatFingerprint(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, fingerprint := range fingerprints {
|
||||||
|
if fingerprint.Name == name && strings.HasPrefix(fingerprint.Value, "sha256:") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("fingerprints = %#v, want %q with semantic digest", fingerprints, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ contracts.StructuredLLMClient = (*fakeCombatLLMClient)(nil)
|
||||||
Reference in New Issue
Block a user