Compare commits
14 Commits
db2adb52da
...
b6aad4fa98
| Author | SHA1 | Date | |
|---|---|---|---|
| b6aad4fa98 | |||
| 9c6af28d02 | |||
| 04eabdfcb9 | |||
| a8a99c1037 | |||
| e15007fffb | |||
| e6b7c61f45 | |||
| 42973215fa | |||
| ba1d112d1f | |||
| b722131d57 | |||
| a92d2c0885 | |||
| 02ec10d66b | |||
| 9dd57dbfa4 | |||
| c164a3fc69 | |||
| 8834df617f |
@@ -3,7 +3,7 @@
|
||||
Notarius is a Go CLI for turning source material into structured artifacts with
|
||||
configured extraction pipelines. The implemented D&D workflow reads Seriatim
|
||||
transcript JSON and can produce scene descriptions, item and currency events,
|
||||
NPC identities, combat turns, NPC interactions, and spell casts.
|
||||
NPC identities, combat turns, NPC interactions, enemy events, and spell casts.
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
||||
@@ -370,14 +370,28 @@ selected target declares them:
|
||||
| **players** | Optional text player context. |
|
||||
| **glossary** | Optional text campaign glossary. |
|
||||
| **spell_catalog** | Optional JSON spell-catalog overlay for spell extraction and normalization. See [spell-catalog overlays](integrations/dnd-spell-catalog-overlays.md). |
|
||||
| **npcs** | Normalized NPC registry. Optional for spells and combat turns; required for NPC interactions. |
|
||||
| **scene_descriptions** | Required normalized scene-description artifact for combat-turn extraction. |
|
||||
| **npcs** | Normalized NPC registry. Optional for spells and combat turns; required for NPC interactions and enemy-event extraction and normalization. |
|
||||
| **scene_descriptions** | Required normalized scene-description artifact for combat-turn and enemy-event extraction. |
|
||||
| **combat_turns** | Required normalized combat-turn artifact for enemy-event extraction. |
|
||||
| **npc_interactions** | Required normalized NPC-interaction artifact for enemy-event extraction. |
|
||||
|
||||
Enemy-event artifact slots have the following exact binding contract. Durable
|
||||
event semantics and wire shape remain in the
|
||||
[enemy-event artifact contract](integrations/dnd-enemy-event-artifacts.md).
|
||||
|
||||
| Slot | Accepted artifact kind | Media type | Maximum size | Required stage |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `npcs` | `dnd/npc-list` | `application/json` | 1,048,576 bytes | extract and normalize |
|
||||
| `scene_descriptions` | `dnd/scene-description-list` | `application/json` | 1,048,576 bytes | extract only |
|
||||
| `combat_turns` | `dnd/combat-turn-list` | `application/json` | 1,048,576 bytes | extract only |
|
||||
| `npc_interactions` | `dnd/npc-interaction-list` | `application/json` | 1,048,576 bytes | extract only |
|
||||
|
||||
Scene descriptions accept **party**, **players**, and **glossary**, but not
|
||||
**roster**. NPC interactions require **npcs** for both extraction and
|
||||
normalization. Combat turns require **scene_descriptions** for extraction; the
|
||||
normalized combat-turn module may use optional **npcs**. The complete example
|
||||
shows generated **npcs** and **scene_descriptions** bindings.
|
||||
normalized combat-turn module may use optional **npcs**. Enemy-event extraction
|
||||
requires all four JSON artifact slots; its normalizer requires **npcs**. The
|
||||
complete example shows the ordered generated bindings.
|
||||
|
||||
## Production Module Keys
|
||||
|
||||
@@ -385,9 +399,9 @@ shows generated **npcs** and **scene_descriptions** bindings.
|
||||
| --- | --- |
|
||||
| Input | **seriatim** |
|
||||
| Chunk | **generic**, **dnd/scenes** |
|
||||
| Extract | **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions** |
|
||||
| Extract | **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions**, **dnd/enemy-events** |
|
||||
| Merge | **appendorder** |
|
||||
| Normalize | **noop**, **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions** |
|
||||
| Normalize | **noop**, **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions**, **dnd/enemy-events** |
|
||||
| Output | **json** |
|
||||
|
||||
The D&D artifact contracts define each emitted schema:
|
||||
@@ -395,8 +409,9 @@ The D&D artifact contracts define each emitted schema:
|
||||
[NPCs](integrations/dnd-npc-artifacts.md),
|
||||
[NPC interactions](integrations/dnd-npc-interaction-artifacts.md),
|
||||
[combat turns](integrations/dnd-combat-turn-artifacts.md),
|
||||
[item events](integrations/dnd-item-event-artifacts.md), and
|
||||
[scene descriptions](integrations/dnd-scene-description-artifacts.md).
|
||||
[item events](integrations/dnd-item-event-artifacts.md),
|
||||
[scene descriptions](integrations/dnd-scene-description-artifacts.md), and
|
||||
[enemy events](integrations/dnd-enemy-event-artifacts.md).
|
||||
|
||||
## Production Validator Keys And Default Chains
|
||||
|
||||
@@ -411,6 +426,7 @@ Available validator keys are:
|
||||
| Item events | **extract/dnd/item-events/shape**, **extract/dnd/item-events/source_refs**, **extract/dnd/item-events/source_relatedness**, **normalize/dnd/item-events/invariants** |
|
||||
| NPC interactions | **extract/dnd/npc-interactions/shape**, **extract/dnd/npc-interactions/registry**, **extract/dnd/npc-interactions/source_refs**, **extract/dnd/npc-interactions/source_relatedness**, **normalize/dnd/npc-interactions/invariants** |
|
||||
| Scene descriptions | **extract/dnd/scene-descriptions/shape**, **extract/dnd/scene-descriptions/source_refs**, **extract/dnd/scene-descriptions/source_relatedness**, **normalize/dnd/scene-descriptions/invariants** |
|
||||
| Enemy events | **extract/dnd/enemy-events/shape**, **extract/dnd/enemy-events/engagements**, **extract/dnd/enemy-events/source_refs**, **extract/dnd/enemy-events/source_relatedness**, **normalize/dnd/enemy-events/invariants** |
|
||||
|
||||
When no override is configured, production D&D bindings use the following
|
||||
ordered chains. Each row lists extract then normalize; spell chains are the
|
||||
@@ -424,6 +440,7 @@ same at both stages.
|
||||
| Item events | generic/valid_json, extract/dnd/item-events/shape, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness | generic/valid_json, extract/dnd/item-events/shape, normalize/dnd/item-events/invariants, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness |
|
||||
| NPC interactions | generic/valid_json, extract/dnd/npc-interactions/shape, extract/dnd/npc-interactions/registry, extract/dnd/npc-interactions/source_refs, generic/valid_json_schema, extract/dnd/npc-interactions/source_relatedness | generic/valid_json, extract/dnd/npc-interactions/shape, extract/dnd/npc-interactions/registry, normalize/dnd/npc-interactions/invariants, extract/dnd/npc-interactions/source_refs, generic/valid_json_schema, extract/dnd/npc-interactions/source_relatedness |
|
||||
| Scene descriptions | generic/valid_json, extract/dnd/scene-descriptions/shape, extract/dnd/scene-descriptions/source_refs, generic/valid_json_schema, extract/dnd/scene-descriptions/source_relatedness | generic/valid_json, extract/dnd/scene-descriptions/shape, normalize/dnd/scene-descriptions/invariants, extract/dnd/scene-descriptions/source_refs, generic/valid_json_schema, extract/dnd/scene-descriptions/source_relatedness |
|
||||
| Enemy events | generic/valid_json, extract/dnd/enemy-events/shape, extract/dnd/enemy-events/engagements, extract/dnd/enemy-events/source_refs, generic/valid_json_schema, extract/dnd/enemy-events/source_relatedness | generic/valid_json, extract/dnd/enemy-events/shape, normalize/dnd/enemy-events/invariants, extract/dnd/enemy-events/source_refs, generic/valid_json_schema, extract/dnd/enemy-events/source_relatedness |
|
||||
|
||||
Chains are only registered for the D&D extract and normalize modules shown
|
||||
above; select an explicit override when a different compatible chain is
|
||||
|
||||
@@ -64,6 +64,8 @@ kind, and complete valid evidence. It does not infer turns, initiative, or
|
||||
actions from registry or scene data.
|
||||
|
||||
The [NPC-interaction artifact](dnd-npc-interaction-artifacts.md) records
|
||||
broader NPC occurrences. The [JSON output contract](json-output.md) defines
|
||||
publication, and [D&D module internals](../internal/dnd.md) describes routing
|
||||
and validation mechanics.
|
||||
broader NPC occurrences. The [enemy-event artifact](dnd-enemy-event-artifacts.md)
|
||||
uses combat turns as grounding only; turns do not establish an enemy event or
|
||||
its outcome. The [JSON output contract](json-output.md) defines publication,
|
||||
and [D&D module internals](../internal/dnd.md) describes routing and validation
|
||||
mechanics.
|
||||
|
||||
114
docs/integrations/dnd-enemy-event-artifacts.md
Normal file
114
docs/integrations/dnd-enemy-event-artifacts.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# D&D Enemy-Event Artifact
|
||||
|
||||
This contract defines the durable, source-grounded enemy-event occurrence list.
|
||||
It records enemies directly established as opposing the party and explicitly
|
||||
observed combat outcomes. It is an ordered observation artifact from which a
|
||||
consumer may derive a ledger; it is not a ledger, encounter roster, or terminal
|
||||
state model.
|
||||
|
||||
## Identity and compatibility
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| Artifact kind | `dnd/enemy-event-list` |
|
||||
| Schema ID | `notarius.dnd.enemy_events` |
|
||||
| Schema name | `notarius_dnd_enemy_events_v1` |
|
||||
| Schema version | `v1` |
|
||||
| Media type | `application/json` |
|
||||
|
||||
`v1` is a strict JSON object with required `events`; the array may be empty.
|
||||
Event and source-reference objects reject unknown fields. An incompatible shape
|
||||
change requires a new schema version.
|
||||
|
||||
## Wire shape
|
||||
|
||||
Every event has these required fields:
|
||||
|
||||
| Field | Contract |
|
||||
| --- | --- |
|
||||
| `name` | Non-empty display name or directly grounded collective subject label. |
|
||||
| `kind` | `engaged`, `killed`, `fled`, `captured`, or `incapacitated`. |
|
||||
| `source_refs` | One or more current-transcript evidence ranges. |
|
||||
|
||||
Each source reference has exactly `source_id`, `start_unit_id`, and
|
||||
`end_unit_id`. It identifies an inclusive current-transcript range; unit IDs
|
||||
are positive and the start may not follow the end.
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"name": "Ashfang",
|
||||
"kind": "engaged",
|
||||
"source_refs": [
|
||||
{"source_id": "session-7", "start_unit_id": 41, "end_unit_id": 42}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ashfang",
|
||||
"kind": "fled",
|
||||
"source_refs": [
|
||||
{"source_id": "session-7", "start_unit_id": 57, "end_unit_id": 58}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Event semantics and evidence
|
||||
|
||||
| Kind | Required evidence |
|
||||
| --- | --- |
|
||||
| `engaged` | The subject is directly established as actively opposing the party in combat. At most one engagement is emitted for one subject in one combat scene. |
|
||||
| `killed` | The transcript explicitly establishes that the subject died or was killed. Damage, defeat, disappearance, or combat ending is insufficient. |
|
||||
| `fled` | The subject explicitly escapes, retreats, or otherwise leaves combat to avoid continued engagement. Movement or absence from later turns is insufficient. |
|
||||
| `captured` | The subject is explicitly taken prisoner or secured under the party's control. A grapple or temporary restraint alone is insufficient. |
|
||||
| `incapacitated` | The subject is explicitly rendered unable to continue acting without being established as killed or captured. A missed turn is insufficient. |
|
||||
|
||||
The current transcript is the only event evidence. Campaign context and
|
||||
normalized NPC, scene-description, combat-turn, and NPC-interaction artifacts
|
||||
can ground names or control combat eligibility, but none may supply event
|
||||
evidence. An outcome may share evidence with an engagement, in which case both
|
||||
events are retained.
|
||||
|
||||
Extraction is limited to chunks with an exact combat-scene classification. An
|
||||
exact non-combat classification produces an accepted empty list. Missing or
|
||||
mismatched classification also produces an accepted empty list and a
|
||||
`scene_classification_unavailable` warning.
|
||||
|
||||
## Subjects, normalization, and order
|
||||
|
||||
A subject matching the normalized NPC registry uses that registry's canonical
|
||||
display name. Unmatched hostile creatures, summoned entities, and directly
|
||||
grounded groups remain valid subjects. An unnamed homogeneous group uses the
|
||||
narrowest transcript-grounded label, such as `Orcs`, `One orc`, or `Remaining
|
||||
orcs`; the artifact never invents synthetic member identities or quantities.
|
||||
Party members, allies, neutral observers, mentioned-but-absent enemies, hazards,
|
||||
traps, and environmental effects are excluded.
|
||||
|
||||
Normalization collapses surrounding and repeated internal whitespace in subject
|
||||
display values, canonicalizes recognized registry names, canonicalizes and
|
||||
deduplicates exact source ranges, then orders events by valid evidence
|
||||
chronology, normalized subject identity, display name, kind, and reference
|
||||
sequence. The deterministic kind tie order is `engaged`,
|
||||
`incapacitated`, `captured`, `fled`, then `killed`. Only entries with the same
|
||||
normalized name, kind, and complete canonical evidence sequence are collapsed.
|
||||
Different kinds, evidence, repeated engagement in separate scenes, and later
|
||||
outcomes remain separate. A later engagement for the same named subject is
|
||||
preserved after an earlier outcome because the artifact does not assert an
|
||||
irreversible state transition.
|
||||
|
||||
## Non-goals
|
||||
|
||||
The artifact has no NPC or scene ID, quantity, confidence, description,
|
||||
rationale, summary, current state, or inferred terminal outcome. It does not
|
||||
emit `active` or `unresolved`; consumers may derive an unresolved ledger view
|
||||
only when an engagement has no later explicit outcome. It never infers an
|
||||
outcome from turn absence, scene termination, initiative order, hit-point
|
||||
guesses, or other artifacts.
|
||||
|
||||
The [JSON output contract](json-output.md) defines publication. Configuration
|
||||
keys, required generated-reference slots, and validator-chain selection are
|
||||
defined in the [configuration reference](../config.md). Implementation and
|
||||
prompt-grounding mechanics are described in the
|
||||
[D&D module internals](../internal/dnd.md).
|
||||
@@ -60,10 +60,13 @@ relationship fields.
|
||||
Only individually identifiable NPC names with transcript evidence belong in
|
||||
this artifact. Groups, generic roles, invented labels, and descriptive
|
||||
enrichment are excluded. Its source references prove registry provenance; they
|
||||
do not become evidence for a spell, interaction, or combat occurrence.
|
||||
do not become evidence for a spell, interaction, combat, or enemy-event
|
||||
occurrence.
|
||||
|
||||
This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md)
|
||||
and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to
|
||||
resolve the canonical `name` in an [NPC interaction](dnd-npc-interaction-artifacts.md).
|
||||
The [enemy-event artifact](dnd-enemy-event-artifacts.md) also uses it only for
|
||||
subject grounding and canonical display names.
|
||||
The [JSON output contract](json-output.md) defines publication, and
|
||||
[D&D module internals](../internal/dnd.md) owns pipeline mechanics.
|
||||
|
||||
@@ -74,5 +74,8 @@ Only entries with the same canonical name, kind, and complete valid evidence
|
||||
sequence are collapsed; distinct categories or evidence remain separate.
|
||||
|
||||
See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action
|
||||
occurrences and the [JSON output contract](json-output.md) for publication.
|
||||
Pipeline mechanics are described in [D&D module internals](../internal/dnd.md).
|
||||
occurrences. The [enemy-event artifact](dnd-enemy-event-artifacts.md) consumes
|
||||
only `combat_opponent` interactions as grounding; they never establish an enemy
|
||||
event or outcome. The [JSON output contract](json-output.md) defines
|
||||
publication. Pipeline mechanics are described in
|
||||
[D&D module internals](../internal/dnd.md).
|
||||
|
||||
@@ -62,8 +62,9 @@ durable fields, or the same source range with different kind, title, or
|
||||
summary, is invalid. It does not merge adjacent ranges, alter prose, or infer
|
||||
missing scenes.
|
||||
|
||||
The [combat-turn artifact](dnd-combat-turn-artifacts.md) uses an exact matching
|
||||
The [combat-turn artifact](dnd-combat-turn-artifacts.md) and
|
||||
[enemy-event artifact](dnd-enemy-event-artifacts.md) use an exact matching
|
||||
`combat` scene only as eligibility control; scene title, summary, and source
|
||||
reference never become combat evidence. Publication is defined by the
|
||||
reference never become their evidence. Publication is defined by the
|
||||
[JSON output contract](json-output.md); implementation details live in
|
||||
[D&D module internals](../internal/dnd.md).
|
||||
|
||||
@@ -74,8 +74,9 @@ than infer a lane schema from its name. The current D&D payload contracts are
|
||||
[spells](dnd-spell-artifacts.md), [NPCs](dnd-npc-artifacts.md),
|
||||
[NPC interactions](dnd-npc-interaction-artifacts.md),
|
||||
[combat turns](dnd-combat-turn-artifacts.md),
|
||||
[item events](dnd-item-event-artifacts.md), and
|
||||
[scene descriptions](dnd-scene-description-artifacts.md).
|
||||
[item events](dnd-item-event-artifacts.md),
|
||||
[scene descriptions](dnd-scene-description-artifacts.md), and
|
||||
[enemy events](dnd-enemy-event-artifacts.md).
|
||||
|
||||
## `manifest.json`
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ selectable keys, bindings, reference syntax, and default validator chains.
|
||||
|
||||
## Durable Artifact Contracts
|
||||
|
||||
The six lanes have separate durable wire contracts. This guide deliberately
|
||||
The seven lanes have separate durable wire contracts. This guide deliberately
|
||||
does not repeat their JSON shapes or schemas.
|
||||
|
||||
| Lane | Durable contract |
|
||||
@@ -18,6 +18,7 @@ does not repeat their JSON shapes or schemas.
|
||||
| Item events | [item-event artifacts](../integrations/dnd-item-event-artifacts.md) |
|
||||
| NPC interactions | [NPC-interaction artifacts](../integrations/dnd-npc-interaction-artifacts.md) |
|
||||
| Scene descriptions | [scene-description artifacts](../integrations/dnd-scene-description-artifacts.md) |
|
||||
| Enemy events | [enemy-event artifacts](../integrations/dnd-enemy-event-artifacts.md) |
|
||||
|
||||
## Family Composition
|
||||
|
||||
@@ -101,14 +102,19 @@ relatedness validators report advisory evidence concerns. The configured order
|
||||
is documented in
|
||||
[Configuration](../config.md#production-validator-keys-and-default-chains).
|
||||
|
||||
Enemy-event extraction additionally rejects a second `engaged` observation for
|
||||
the same comparison identity within one scene-scoped result. Normalization may
|
||||
combine results from distinct scenes, so it intentionally does not apply that
|
||||
rule. Configuration owns the exact validator key and chain position.
|
||||
|
||||
Normalizers are deterministic for spells, combat turns, item events, NPC
|
||||
interactions, and scene descriptions. They canonicalize display values and
|
||||
evidence, use source-document order for stable output, and issue bounded
|
||||
warnings for changes or collapsed duplicates. The NPC normalizer is the
|
||||
intentional exception: it first produces a deterministic candidate set, then
|
||||
uses a bounded structured-LLM proposal to reconcile identity groups. Invalid
|
||||
or unusable proposals retain the deterministic result and surface retry or
|
||||
fallback diagnostics; the model does not directly replace durable records.
|
||||
interactions, scene descriptions, and enemy events. They canonicalize display
|
||||
values and evidence, use source-document order for stable output, and issue
|
||||
bounded warnings for changes or collapsed duplicates. The NPC normalizer is
|
||||
the intentional exception: it first produces a deterministic candidate set,
|
||||
then uses a bounded structured-LLM proposal to reconcile identity groups.
|
||||
Invalid or unusable proposals retain the deterministic result and surface retry
|
||||
or fallback diagnostics; the model does not directly replace durable records.
|
||||
|
||||
## Generated References And Grounding
|
||||
|
||||
@@ -122,7 +128,10 @@ NPC registries are names-only grounding projections: they may canonicalize
|
||||
actors for spells and combat turns and are required for NPC interactions, but
|
||||
they do not supply evidence. Scene-description registries are eligibility-only
|
||||
projections: they retain the current chunk’s classification data, not scene
|
||||
prose or evidence, and exist to route combat extraction.
|
||||
prose or evidence, and exist to route combat extraction. Enemy-event extraction
|
||||
also projects combat turns to `actor` and `turn_kind` and filters NPC
|
||||
interactions to `combat_opponent` names and kinds. These compact projections,
|
||||
like NPC grounding, are source-free guidance and never event evidence.
|
||||
|
||||
## Lane-Specific Rules
|
||||
|
||||
@@ -137,11 +146,15 @@ shared helper changes.
|
||||
| Item events | Uses campaign context for disambiguation but has no NPC-registry or scene-description dependency. |
|
||||
| NPC interactions | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. |
|
||||
| Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. |
|
||||
| Enemy events | Requires NPC, scene-description, combat-turn, and NPC-interaction artifacts. It calls the LLM only for an exact `combat` classification, records ordered observations rather than terminal state, and normalizes recognized names through the NPC registry while preserving grounded collective labels. |
|
||||
|
||||
The combat and scene-description contracts describe their exact handoff and
|
||||
empty-result behavior in more detail:
|
||||
[combat turns](../integrations/dnd-combat-turn-artifacts.md) and
|
||||
[scene descriptions](../integrations/dnd-scene-description-artifacts.md).
|
||||
The [enemy-event contract](../integrations/dnd-enemy-event-artifacts.md)
|
||||
defines its durable semantics; [Configuration](../config.md) owns its
|
||||
selectable bindings and validation chains.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
|
||||
@@ -7,24 +7,6 @@ not as committed release dates.
|
||||
|
||||
## Near-Term D&D Pipeline
|
||||
|
||||
### Combat Enemy Ledger
|
||||
|
||||
- Add a D&D artifact that identifies enemies faced during combat and supports
|
||||
an end-of-session encounter ledger.
|
||||
- Track each enemy's observed state using a small controlled vocabulary such as
|
||||
`active`, `killed`, `fled`, `captured`, or `incapacitated`, while preserving
|
||||
an explicit unresolved state when the transcript does not establish an
|
||||
outcome.
|
||||
- Preserve the evidence for enemy participation and state changes rather than
|
||||
inferring a terminal outcome from combat ending or an enemy disappearing
|
||||
from the conversation.
|
||||
- Define how repeated mentions, groups of unnamed enemies, summoned or allied
|
||||
creatures, and the same enemy appearing in multiple combats affect identity
|
||||
and ledger entries.
|
||||
- Evaluate whether the ledger should be extracted directly, derived from
|
||||
combat-turn artifacts, or use a sequential pipeline that consumes combat
|
||||
turns and the normalized NPC registry as grounding references.
|
||||
|
||||
### Location Extraction
|
||||
|
||||
- Add a D&D artifact for locations visited by the party or otherwise mentioned
|
||||
|
||||
649
docs/roadmap/implementation.md
Normal file
649
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# D&D Location Tracking Implementation Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the target state in [D&D Location Tracking](location.md): an
|
||||
evidence-grounded `dnd/locations` registry lane and a dependent
|
||||
`dnd/location-occurrences` lane, including conservative location identity,
|
||||
shared D&D entity-reconciliation infrastructure, production validation,
|
||||
generated-reference wiring, maintained examples, and current documentation.
|
||||
|
||||
This is an ordered implementation plan for a `gpt-5.6-terra` coding agent.
|
||||
Implement one stage per prompt, in order. Finish each stage's tests and leave
|
||||
the repository coherent before proceeding. Do not implement later-stage
|
||||
production registrations early merely to make an incomplete feature selectable.
|
||||
|
||||
All stages must follow:
|
||||
|
||||
- [Architecture Policy](../policy/architecture.md)
|
||||
- [Testing Policy](../policy/testing.md)
|
||||
- [Documentation Policy](../policy/documentation.md)
|
||||
- the D&D conventions in [D&D Module Internals](../internal/dnd.md)
|
||||
- the durable policy decisions in [the feature roadmap](location.md)
|
||||
|
||||
Use behavior-level tests. Do not add tests that merely freeze source layout,
|
||||
exact prompt wording, message counts, shared-prefix length, or other incidental
|
||||
implementation details. Keep tests deterministic, offline, and owned by the
|
||||
component whose behavior they exercise.
|
||||
|
||||
## Stage 1: Define Location Domain Types And Identity
|
||||
|
||||
### Goal
|
||||
|
||||
Establish the in-process contracts and deterministic identity policy on which
|
||||
both lanes depend.
|
||||
|
||||
### Work
|
||||
|
||||
- Extend `internal/modules/dnd/types.go` with:
|
||||
- `LocationListKind` = `dnd/location-list`;
|
||||
- `LocationOccurrenceListKind` = `dnd/location-occurrence-list`;
|
||||
- `LocationList`, `Location`, `LocationOccurrenceList`,
|
||||
`LocationOccurrence`, and `LocationOccurrenceKind`;
|
||||
- exact JSON members and the four occurrence constants specified in
|
||||
`location.md`.
|
||||
- Add `internal/modules/dnd/locations/identity`.
|
||||
- Implement display normalization and comparison normalization consistently
|
||||
with the existing NPC identity policy. Share a lower-level comparison helper
|
||||
only if doing so preserves NPC behavior exactly; otherwise keep the small
|
||||
policy-specific function explicit.
|
||||
- Implement the versioned compact-JSON-array ID derivation contract from
|
||||
`location.md`, including ID syntax checks and immutable list validation.
|
||||
- Identity validation must require correctly derived, unique IDs while allowing
|
||||
two records to have the same comparison name when their evidence anchors
|
||||
differ.
|
||||
- Add focused tests for Unicode normalization, whitespace, apostrophes,
|
||||
deterministic encoding, evidence ordering, same-name/different-anchor IDs,
|
||||
malformed IDs, missing evidence, and non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The types compile without production registration.
|
||||
- ID derivation exactly follows the documented five-element compact JSON input.
|
||||
- Same normalized name plus different earliest evidence yields different IDs.
|
||||
- Validation does not reject same-name records solely because their names
|
||||
match, and it reports duplicate or mismatched IDs deterministically.
|
||||
- `go test ./internal/modules/dnd/locations/... ./internal/modules/dnd/...` passes
|
||||
for the packages available at this stage.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 2: Add Durable Codecs And Schemas
|
||||
|
||||
### Goal
|
||||
|
||||
Create strict durable JSON ownership for both artifact kinds without exposing
|
||||
either lane as a selectable pipeline yet.
|
||||
|
||||
### Work
|
||||
|
||||
- Add `internal/modules/dnd/codec/locations` and
|
||||
`internal/modules/dnd/codec/locationoccurrences`, following the existing D&D
|
||||
candidate/approved codec pattern.
|
||||
- Add embedded Draft 2020-12 schemas with the IDs, names, root members, required
|
||||
fields, enums, source-reference shape, and `additionalProperties: false`
|
||||
contracts in `location.md`.
|
||||
- Keep both schemas at `v1`.
|
||||
- Support strict candidate decoding before semantic approval and strict durable
|
||||
encoding/decoding after approval.
|
||||
- Add representative valid fixtures and tests for schema metadata, defensive
|
||||
schema bytes, empty arrays, unknown fields, missing fields, invalid types,
|
||||
invalid enum values, malformed source references, invalid ID syntax, and
|
||||
round trips.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Each codec advertises the correct artifact kind and metadata count.
|
||||
- Candidate decoding preserves semantic mistakes for validators while
|
||||
rejecting structurally invalid JSON.
|
||||
- Approved encoding and decoding enforce the durable shape.
|
||||
- `go test ./internal/modules/dnd/codec/locations/... ./internal/modules/dnd/codec/locationoccurrences/...`
|
||||
passes offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 3: Extract Shared Entity-Reconciliation Infrastructure
|
||||
|
||||
### Goal
|
||||
|
||||
Create the D&D-shared, domain-safe proposal machinery needed by both NPC and
|
||||
location normalization, without changing NPC production behavior yet.
|
||||
|
||||
### Work
|
||||
|
||||
- Add `internal/modules/dnd/shared/entityreconcile`.
|
||||
- Move or generalize the reusable behavior currently owned by
|
||||
`internal/modules/dnd/normalize/npcs/context_material.go` and `proposal.go`:
|
||||
- assign deterministic opaque candidate keys such as `candidate-000001` in
|
||||
input order;
|
||||
- clone candidate names and source references;
|
||||
- build bounded transcript windows in source-document order;
|
||||
- omit candidates whose references cannot safely produce context;
|
||||
- coalesce overlapping or adjacent windows without mutating the source;
|
||||
- define the private `duplicate_groups` proposal with `members` and
|
||||
`canonical` candidate keys;
|
||||
- reject blank, unknown, repeated, ineligible, overlapping, too-small, or
|
||||
canonical-not-a-member groups; and
|
||||
- return defensive, immutable assessment data identifying only safe groups.
|
||||
- Keep LLM calls, retry decisions, artifact mutation, canonical-name policy,
|
||||
durable ID derivation, and warning wording out of this package.
|
||||
- Add a shared prompt instruction asset that states the key-copying and
|
||||
proposal-safety contract without NPC- or location-specific identity rules.
|
||||
- Add a shared private structured-response schema and loader/registration
|
||||
support with a stable `v1` key, ID, name, and fingerprint. Registering the
|
||||
schema more than once must not be required.
|
||||
- Add table-driven tests for context bounds, ordering, invalid references,
|
||||
coalescing, every unsafe proposal category, non-overlapping safe groups,
|
||||
deterministic keys, defensive copies, and non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The package has no dependency on `dnd.NPC`, `dnd.Location`, either
|
||||
normalizer, or a concrete LLM client.
|
||||
- Proposal values can identify duplicate candidates even when display names
|
||||
are equal.
|
||||
- The shared response contract cannot directly supply replacement records or
|
||||
evidence.
|
||||
- Existing NPC packages still compile before their migration.
|
||||
- `go test ./internal/modules/dnd/shared/...` passes offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium, but coherent and suitable for one implementation prompt. Do not combine
|
||||
it with the NPC migration.
|
||||
|
||||
## Stage 4: Migrate NPC Normalization To The Shared Helper
|
||||
|
||||
### Goal
|
||||
|
||||
Make the existing NPC normalizer the first production consumer of the shared
|
||||
entity-reconciliation contract while retaining its durable behavior.
|
||||
|
||||
### Work
|
||||
|
||||
- Refactor `internal/modules/dnd/normalize/npcs` to use opaque candidate keys,
|
||||
shared context construction, shared proposal assessment, the shared response
|
||||
schema, and the shared generic reconciliation instruction asset.
|
||||
- Retain NPC-owned responsibilities:
|
||||
- comparison-name preparation and deterministic duplicate handling;
|
||||
- the NPC-specific task and canonical display-name rules;
|
||||
- LLM invocation, bounded retry, fallback, warnings, and diagnostics;
|
||||
- application of safe groups, evidence union, NPC ID derivation, and output
|
||||
ordering.
|
||||
- Remove superseded NPC-private context/proposal code and private schema assets
|
||||
once no longer referenced.
|
||||
- Update NPC prompt metadata and checkpoint fingerprints for the intentional
|
||||
prompt/private-schema contract change.
|
||||
- Preserve public module keys, durable NPC schema, identity policy, validator
|
||||
chains, warning bounds, and fallback semantics.
|
||||
- Test equal display names as distinct keyed candidates, alias consolidation,
|
||||
rejected unsafe groups, retry exhaustion, private input ownership, redacted
|
||||
errors, deterministic fallback, and non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- No durable NPC artifact field or module key changes.
|
||||
- NPC normalization cannot confuse two candidates merely because their display
|
||||
names match.
|
||||
- Unsafe proposals leave a valid deterministic result and follow existing
|
||||
retry/fallback policy.
|
||||
- Obsolete NPC-only reconciliation helpers and schema are removed.
|
||||
- `go test ./internal/modules/dnd/normalize/npcs/... ./internal/modules/dnd/shared/...`
|
||||
passes offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium-to-large but bounded to one existing module. Suitable for one
|
||||
implementation prompt; do not add location normalization in this stage.
|
||||
|
||||
## Stage 5: Implement Location Extraction
|
||||
|
||||
### Goal
|
||||
|
||||
Add the LLM-backed extractor that produces evidence-grounded location
|
||||
candidates.
|
||||
|
||||
### Work
|
||||
|
||||
- Add `internal/modules/dnd/extract/locations` following current D&D extractor
|
||||
conventions: strict empty options, typed builder and registration function,
|
||||
`llm_backed` execution metadata, immutable inputs, redacted errors, prompt
|
||||
and response-schema fingerprints, and bounded diagnostics.
|
||||
- Add a private response schema containing only `name` and source ranges; the
|
||||
model must not produce durable IDs or prose.
|
||||
- Compose the prompt from existing shared D&D system, identity, campaign
|
||||
reference, transcript, and evidence assets plus module-owned task and
|
||||
instructions. Preserve the documented extraction-message ordering and cache
|
||||
controls.
|
||||
- Define physical-place inclusion and conservative omission exactly as in
|
||||
`location.md`, including generic labels, aliases, and nested places.
|
||||
- Map source ranges to the current source ID, canonicalize exact duplicate
|
||||
ranges, derive candidate location IDs in code, preserve semantically invalid
|
||||
candidates for validators where safe, and return deterministic ordering.
|
||||
- Add prompt-asset tests that verify shared asset reuse and rendered inputs by
|
||||
behavior, without asserting exact shared-prefix length or prompt wording.
|
||||
- Add extractor tests for empty output, mapping, evidence ownership, generic
|
||||
same-name locations with different anchors, invalid candidate preservation,
|
||||
client failures, registration metadata, fingerprints, and non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The extractor cannot manufacture source identities or accept campaign
|
||||
references as evidence.
|
||||
- The private model response does not contain a durable ID.
|
||||
- Same-name candidates with different evidence survive extraction as distinct
|
||||
candidates.
|
||||
- The package is testable through its local registration but is not yet added
|
||||
to the production D&D registrar.
|
||||
- `go test ./internal/modules/dnd/extract/locations/...` passes offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium and suitable for one implementation prompt.
|
||||
|
||||
## Stage 6: Implement Semantic Location Normalization
|
||||
|
||||
### Goal
|
||||
|
||||
Add conservative alias and repeated-place reconciliation without collapsing
|
||||
same-named or nested locations by default.
|
||||
|
||||
### Work
|
||||
|
||||
- Add `internal/modules/dnd/normalize/locations` as an `llm_backed` normalizer
|
||||
using the shared entity-reconciliation package and private response schema.
|
||||
- Deterministically clone and prepare the merged candidates first:
|
||||
- normalize display whitespace;
|
||||
- canonicalize and deduplicate source references;
|
||||
- remove only exact duplicates with the same comparison name and exact
|
||||
canonical evidence;
|
||||
- assign opaque reconciliation keys; and
|
||||
- retain same-name records with different evidence.
|
||||
- Use bounded transcript windows and a module-owned location task that permits
|
||||
grouping only when evidence clearly identifies one physical place. Explicitly
|
||||
prohibit grouping solely by equal names, proximity, nesting, or generic
|
||||
labels.
|
||||
- Validate proposals through the shared package. Apply only safe groups in
|
||||
deterministic code, choose the canonical name from the selected existing
|
||||
candidate, union evidence, and derive the final evidence-anchored ID.
|
||||
- Retain the deterministic candidate set on unusable proposals and follow the
|
||||
existing NPC retry/fallback and bounded-warning conventions.
|
||||
- Publish prompt, response-schema, identity-policy, normalization-policy, and
|
||||
semantic-context fingerprints.
|
||||
- Test aliases, repeated appearances, same-name distinct places, parent/child
|
||||
locations, invalid and overlapping proposals, proposal retries, fallback,
|
||||
ordering, ID recomputation, warning bounds, idempotent deterministic
|
||||
application, and non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The model proposes groups but cannot directly replace durable locations.
|
||||
- A failed or ambiguous proposal cannot lose a valid candidate.
|
||||
- Same-name locations remain distinct unless an approved evidence-backed group
|
||||
joins them.
|
||||
- Final IDs are derived only after group evidence is unioned.
|
||||
- `go test ./internal/modules/dnd/normalize/locations/...` passes offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium-to-large but scoped to one normalizer and suitable for one implementation
|
||||
prompt.
|
||||
|
||||
## Stage 7: Add The Immutable Location Registry
|
||||
|
||||
### Goal
|
||||
|
||||
Provide safe generated-reference resolution and an unambiguous prompt
|
||||
projection for downstream occurrence extraction.
|
||||
|
||||
### Work
|
||||
|
||||
- Add `internal/modules/dnd/locations/registry`, modeled on the immutable NPC
|
||||
registry and its operation-time resolver.
|
||||
- Define `ReferenceSlot = "locations"`, a 1,048,576-byte limit, and exactly one
|
||||
accepted `application/json` location-list item when bound.
|
||||
- Validate durable decoding and location identity before constructing a
|
||||
registry.
|
||||
- Store canonical durable bytes and semantic digests without retaining mutable
|
||||
caller-owned content. Return defensive copies from all accessors.
|
||||
- Produce a compact, source-free prompt projection containing ordered
|
||||
`{id, name}` pairs. Do not include source references or generated-reference
|
||||
provenance.
|
||||
- Support exact lookup by ID and verify the matching canonical name; do not
|
||||
provide an ambiguous name-only lookup as the occurrence linkage mechanism.
|
||||
- Preserve the established seeded/operation resolver behavior and concurrency-
|
||||
safe semantic caching.
|
||||
- Test absent, empty, malformed, oversized, wrong-media-type, invalid-identity,
|
||||
and valid registries; projections; ID lookup; defensive copies; raw and
|
||||
semantic cache reuse; and concurrent resolution.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Distinct same-name records are both representable and addressable by ID.
|
||||
- Registry evidence cannot appear in the prompt projection.
|
||||
- Malformed static references fail during construction and malformed generated
|
||||
references fail at operation resolution through existing boundaries.
|
||||
- `go test ./internal/modules/dnd/locations/registry/...` passes offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium and suitable for one implementation prompt.
|
||||
|
||||
## Stage 8: Implement Location-Occurrence Extraction
|
||||
|
||||
### Goal
|
||||
|
||||
Add the dependent LLM-backed lane that classifies source-grounded location
|
||||
occurrences.
|
||||
|
||||
### Work
|
||||
|
||||
- Add `internal/modules/dnd/extract/locationoccurrences` with module key
|
||||
`dnd/location-occurrences`, `llm_backed` execution metadata, strict empty
|
||||
options, typed construction, and a required `locations` reference slot.
|
||||
- Resolve the immutable registry at construction and for each operation using
|
||||
the established generated-reference pattern.
|
||||
- Add a private response schema requiring `location_id`, `name`, `kind`, and
|
||||
source ranges. Restrict kinds to `visited`, `planned`, `recalled`, and
|
||||
`mentioned`.
|
||||
- Reuse the shared D&D extraction prompt assets and ordering. Place the compact
|
||||
location registry after the shared transcript/evidence material and before
|
||||
module task/instructions, consistent with current generated grounding.
|
||||
- Encode the exact classification rules, precedence, multi-fact behavior, and
|
||||
conservative omission policy from `location.md`.
|
||||
- Map evidence only to the current source. Copy candidate IDs and names without
|
||||
silently repairing unknown or mismatched values so deterministic validators
|
||||
retain ownership of those diagnostics.
|
||||
- Canonically order output and exact duplicates without dropping distinct
|
||||
kinds or independent evidence.
|
||||
- Test every kind, precedence, multiple supported facts, no-location and no-
|
||||
occurrence outputs, required registry failures, same-name ID selection,
|
||||
source-free prompt projection, current-transcript evidence, prompt/profile
|
||||
metadata, client failures, non-mutation, and local registration.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Construction and operation specs declare `locations` as required and accept
|
||||
only `dnd/location-list` JSON.
|
||||
- The model sees IDs and names but no registry evidence.
|
||||
- Registry context never becomes occurrence evidence.
|
||||
- The extractor remains locally testable but is not production-selectable yet.
|
||||
- `go test ./internal/modules/dnd/extract/locationoccurrences/...` passes
|
||||
offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium and suitable for one implementation prompt.
|
||||
|
||||
## Stage 9: Implement Deterministic Occurrence Normalization
|
||||
|
||||
### Goal
|
||||
|
||||
Canonicalize occurrence records against the exact location registry without a
|
||||
second LLM call.
|
||||
|
||||
### Work
|
||||
|
||||
- Add `internal/modules/dnd/normalize/locationoccurrences` as a deterministic
|
||||
normalizer with the same required `locations` slot.
|
||||
- Clone all inputs. Normalize source ranges, names, ordering, and exact
|
||||
duplicates.
|
||||
- For a known `location_id`, replace display-name variation with the registry's
|
||||
exact canonical name. Do not perform a name-only guess.
|
||||
- Preserve an unknown ID or otherwise invalid record for validator diagnostics
|
||||
and emit bounded warnings where current D&D normalizer conventions require
|
||||
them.
|
||||
- Sort using the complete order defined in `location.md`.
|
||||
- Publish normalization and registry-projection fingerprints consistent with
|
||||
the other registry-backed normalizers.
|
||||
- Test all kind values, canonical name replacement, same-name distinct IDs,
|
||||
exact-duplicate removal, distinct evidence retention, stable ordering,
|
||||
unknown IDs, malformed registry resolution, warnings, idempotence, and
|
||||
non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- No LLM client or prompt assets are required.
|
||||
- Canonicalization is exclusively ID-based.
|
||||
- Invalid records are not silently redirected to a different location.
|
||||
- `go test ./internal/modules/dnd/normalize/locationoccurrences/...` passes
|
||||
offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 10: Add Location Registry Validators
|
||||
|
||||
### Goal
|
||||
|
||||
Give `dnd/location-list` the complete validator ownership expected of a
|
||||
production D&D artifact.
|
||||
|
||||
### Work
|
||||
|
||||
- Add location validator packages under
|
||||
`internal/modules/dnd/validate/locations` for:
|
||||
- extraction shape;
|
||||
- source-reference bounds/current-chunk ownership;
|
||||
- advisory source relatedness; and
|
||||
- normalized identity derivation and ID uniqueness.
|
||||
- Use shared D&D citation, unit-reference, diagnostic, and matching helpers
|
||||
where their contracts apply.
|
||||
- The identity validator must allow repeated comparison names and validate the
|
||||
evidence-anchored derivation policy instead of importing NPC uniqueness
|
||||
assumptions.
|
||||
- Keep diagnostics indexed, aggregated, bounded, stable, and free of raw prompt
|
||||
or reference content.
|
||||
- Add tests for accepted values, every owned failure, same-name distinct
|
||||
locations, malformed/unreadable citations, advisory relatedness, bounds,
|
||||
registration metadata, fingerprints, nil safety where applicable, and
|
||||
non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Validator responsibilities do not overlap merely to increase test coverage.
|
||||
- Relatedness remains advisory and uses only cited current-transcript text.
|
||||
- Validators do not repair or mutate artifacts.
|
||||
- `go test ./internal/modules/dnd/validate/locations/...` passes offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium and suitable for one implementation prompt.
|
||||
|
||||
## Stage 11: Add Location-Occurrence Validators
|
||||
|
||||
### Goal
|
||||
|
||||
Give `dnd/location-occurrence-list` complete structural, registry, ordering,
|
||||
evidence, and advisory validation.
|
||||
|
||||
### Work
|
||||
|
||||
- Add validator packages under
|
||||
`internal/modules/dnd/validate/locationoccurrences` for:
|
||||
- extraction shape and supported kinds;
|
||||
- required registry membership and exact `location_id`/`name` pairing;
|
||||
- normalized ordering and exact-duplicate invariants;
|
||||
- source-reference bounds/current-chunk ownership; and
|
||||
- advisory source relatedness.
|
||||
- Reuse the immutable location resolver rather than decoding caller-owned
|
||||
references independently in each validator.
|
||||
- Ensure same-name registry records remain distinguishable by ID.
|
||||
- Keep registry context out of evidence checks.
|
||||
- Add focused tests for each kind, unknown IDs, mismatched names, same-name
|
||||
locations, ordering, duplicates, malformed required references, invalid
|
||||
evidence, advisory diagnostics, registration metadata, fingerprints,
|
||||
diagnostic bounds, and non-mutation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- An ID/name mismatch is rejected even when another registry record has the
|
||||
supplied name.
|
||||
- Missing or malformed required registry references fail at the established
|
||||
boundary.
|
||||
- Validators remain deterministic and do not alter occurrence records or the
|
||||
registry.
|
||||
- `go test ./internal/modules/dnd/validate/locationoccurrences/...` passes
|
||||
offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium and suitable for one implementation prompt.
|
||||
|
||||
## Stage 12: Compose The Production D&D Family
|
||||
|
||||
### Goal
|
||||
|
||||
Make both lanes selectable as one coherent production addition after all
|
||||
component contracts are present.
|
||||
|
||||
### Work
|
||||
|
||||
- Extend `internal/modules/dnd/register` to register, in dependency-safe order:
|
||||
- both codecs;
|
||||
- both extractors;
|
||||
- typed append-order mergers;
|
||||
- the LLM-backed location normalizer;
|
||||
- the deterministic occurrence normalizer;
|
||||
- all validators;
|
||||
- shared reconciliation schema assets and both new prompt manifests;
|
||||
- evidence projectors; and
|
||||
- extract and normalize default validator chains.
|
||||
- Ensure both LLM-backed modules select the maintained `dnd-extraction`
|
||||
fallback profile and inherit the existing profile policy.
|
||||
- Define chain order consistently with existing D&D artifacts: generic JSON,
|
||||
shape, registry/identity or normalized invariants at the appropriate stage,
|
||||
source references, durable JSON Schema, then advisory relatedness.
|
||||
- Update registrar tests for artifact kinds, keys, execution classes, reference
|
||||
slot requirements, builder construction, assets, profile use, evidence
|
||||
projection, chain contents/order, duplicate registration, and failure
|
||||
propagation.
|
||||
- Update any integration-level artifact-kind allowlists or typed registries
|
||||
required by the framework; do not add module-specific orchestration logic.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- One D&D registration call exposes both complete lanes and no partial
|
||||
registration succeeds silently.
|
||||
- Catalog inspection reports correct artifact kinds, stages, execution classes,
|
||||
reference slots, profiles, and fingerprints.
|
||||
- Both artifact kinds support evidence projection without registry evidence
|
||||
leakage.
|
||||
- `go test ./internal/modules/dnd/register/... ./internal/modules/dnd/...` passes
|
||||
offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium-to-large but limited to composition and suitable for one implementation
|
||||
prompt.
|
||||
|
||||
## Stage 13: Add Maintained Pipeline And Handoff Coverage
|
||||
|
||||
### Goal
|
||||
|
||||
Exercise the feature through real configuration, ordered generated references,
|
||||
chunk operations, acceptance gates, and durable output.
|
||||
|
||||
### Work
|
||||
|
||||
- Update `examples/dnd-complete.config.yml`:
|
||||
- add `locations` to the first descriptive step with extract, append-order
|
||||
merge, and LLM-backed normalize bindings;
|
||||
- add a generated `locations` reference to the next step;
|
||||
- add `location-occurrences` to that step with extract, append-order merge,
|
||||
and deterministic normalize bindings; and
|
||||
- add both lanes to evidence-context output where appropriate.
|
||||
- Keep the minimal example minimal unless its stated purpose requires a
|
||||
location lane; do not turn it into a second complete example.
|
||||
- Extend maintained example-loading/config-validation tests.
|
||||
- Add integration tests that prove:
|
||||
- the normalized accepted registry is handed off in memory;
|
||||
- the occurrence lane cannot run before its producer;
|
||||
- missing, cyclic, wrong-kind, wrong-media-type, rejected, or unaccepted
|
||||
producers are rejected at the existing boundaries;
|
||||
- same-name locations remain distinguishable by ID through the handoff;
|
||||
- registry evidence never becomes occurrence evidence;
|
||||
- retries and checkpoints honor prompt, schema, identity, and generated-
|
||||
reference fingerprints; and
|
||||
- output contains both durable artifact envelopes and evidence context.
|
||||
- Use recording/fake structured clients only; no network-dependent tests.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The complete example loads through the real config path and exercises all
|
||||
registered D&D lanes.
|
||||
- Ordered handoff failure semantics match the framework's existing fail-whole-
|
||||
run policy.
|
||||
- Integration tests cover behavior rather than duplicating package internals.
|
||||
- `go test ./internal/modules/integration/... ./internal/config/...` and any
|
||||
example-specific test targets pass offline.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium-to-large but coherent as one end-to-end integration prompt.
|
||||
|
||||
## Stage 14: Publish Current Documentation And Perform Final Verification
|
||||
|
||||
### Goal
|
||||
|
||||
Make the implemented feature discoverable and retire fulfilled future-work
|
||||
language without leaving development-history documentation behind.
|
||||
|
||||
### Work
|
||||
|
||||
- Create canonical integration contracts:
|
||||
- `docs/integrations/dnd-location-artifacts.md`;
|
||||
- `docs/integrations/dnd-location-occurrence-artifacts.md`.
|
||||
- Update `docs/config.md` with both selectable keys, the `locations` reference
|
||||
slot and compatibility, execution classes, validators, default chains, and
|
||||
the complete-example link.
|
||||
- Update `docs/internal/dnd.md` with nine-lane composition, shared entity
|
||||
reconciliation, evidence-anchored identity, occurrence grounding, prompt
|
||||
asset reuse, and intentional lane differences. Link to integration contracts
|
||||
instead of duplicating their JSON shapes.
|
||||
- Update `docs/integrations/json-output.md`, `README.md`, and other current
|
||||
canonical inventories only where repository inspection shows that the new
|
||||
artifact kinds or maintained example must be listed.
|
||||
- Remove the fulfilled `Location Extraction` section from
|
||||
`docs/roadmap/future.md`. Keep the generic LLM-assisted deduplication item and
|
||||
clarify only if needed that the new D&D helper does not fulfill that broader
|
||||
feature.
|
||||
- Verify all relative links and search current documentation for stale
|
||||
seven-lane counts, missing keys, obsolete location-planning claims, and
|
||||
accidental claims that references are evidence.
|
||||
- Run formatting, focused tests, the full Go test suite, static analysis, and
|
||||
repository-provided config/example checks. Inspect `git diff --check` and
|
||||
confirm no unrelated files changed.
|
||||
- After implementation and verification are complete, leave `location.md` and
|
||||
this plan in place for the user's separate roadmap-retirement step; do not
|
||||
delete them unless explicitly asked.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Current documentation describes the implemented contracts and only
|
||||
implemented behavior outside `docs/roadmap/`.
|
||||
- The roadmap no longer presents completed location tracking as future work.
|
||||
- Links, examples, module inventories, and default-chain tables agree with
|
||||
production registration.
|
||||
- `go test ./internal/modules/dnd/...` passes.
|
||||
- `go test ./internal/modules/integration/...` passes.
|
||||
- `go test ./...` passes.
|
||||
- `go vet ./...` passes.
|
||||
- Repository-provided configuration/example validation passes.
|
||||
- `git diff --check` reports no errors.
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Medium and suitable for one implementation prompt.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The feature roadmap fixes the artifact shapes, identity scope and
|
||||
derivation, occurrence categories, classification precedence, reference
|
||||
dependency, reconciliation safety boundary, pipeline placement, and non-goals
|
||||
needed to implement every stage without an additional product decision.
|
||||
337
docs/roadmap/location.md
Normal file
337
docs/roadmap/location.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# D&D Location Tracking
|
||||
|
||||
## Purpose
|
||||
|
||||
Add evidence-grounded D&D location tracking without turning a single extractor
|
||||
into both an entity registry and an event classifier. The target design follows
|
||||
the established NPC pattern: one lane identifies canonical location records and
|
||||
a later lane records how the party related to those locations in the transcript.
|
||||
|
||||
This roadmap defines the desired end state and policy choices. The ordered work
|
||||
needed to reach that state is in [the implementation plan](implementation.md).
|
||||
|
||||
## User Intent
|
||||
|
||||
- Record locations the party visits or that the session otherwise discusses.
|
||||
- Distinguish current physical presence from plans, recollections, and ordinary
|
||||
mentions.
|
||||
- Preserve transcript evidence for every durable record.
|
||||
- Reconcile aliases and repeated appearances conservatively.
|
||||
- Keep distinct places separate when they happen to share a generic name.
|
||||
- Keep the schemas minimal. Location description, hierarchy, participants, and
|
||||
narrative analysis belong in other artifacts or deterministic joins.
|
||||
|
||||
## Target Capability
|
||||
|
||||
The D&D module family will have two new lanes:
|
||||
|
||||
1. `dnd/locations` produces a session-scoped registry of physical places.
|
||||
2. `dnd/location-occurrences` consumes the normalized location registry and
|
||||
produces an ordered list of source-grounded relationships between the party
|
||||
and those places.
|
||||
|
||||
The normalized location artifact is handed to the occurrence lane through a
|
||||
required generated reference named `locations`. The occurrence lane must use
|
||||
that registry for identity grounding, but the current transcript remains its
|
||||
only evidence source.
|
||||
|
||||
## Durable Artifact Contracts
|
||||
|
||||
Both contracts remain at `v1`; Notarius is pre-release and does not need a
|
||||
compatibility layer for these new artifacts.
|
||||
|
||||
### Location registry
|
||||
|
||||
The location lane uses:
|
||||
|
||||
- artifact kind: `dnd/location-list`
|
||||
- module key: `dnd/locations`
|
||||
- schema ID: `notarius.dnd.locations`
|
||||
- schema name: `notarius_dnd_locations_v1`
|
||||
- media type: `application/json`
|
||||
- root member: `locations`
|
||||
|
||||
Each location contains exactly:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | Deterministic, session-scoped canonical location identity. |
|
||||
| `name` | string | Evidence-grounded display name or transcript-established label. |
|
||||
| `source_refs` | non-empty source-reference array | Current-transcript evidence that identifies the place. |
|
||||
|
||||
Locations are physical or spatial places: planes, regions, settlements,
|
||||
districts, buildings, rooms, landmarks, routes, and geographic features. A
|
||||
generic label such as `the tavern` is permitted only when the transcript uses
|
||||
it for a specific place. The extractor must not invent a qualifier merely to
|
||||
distinguish that place from another place with the same label.
|
||||
|
||||
The registry does not contain type, parent, description, summary, coordinates,
|
||||
participants, visit status, or occurrence data. Parent and child places are
|
||||
separate identities when the transcript identifies both; nesting alone is not
|
||||
a reason to merge them.
|
||||
|
||||
### Location occurrences
|
||||
|
||||
The occurrence lane uses:
|
||||
|
||||
- artifact kind: `dnd/location-occurrence-list`
|
||||
- module key: `dnd/location-occurrences`
|
||||
- schema ID: `notarius.dnd.location_occurrences`
|
||||
- schema name: `notarius_dnd_location_occurrences_v1`
|
||||
- media type: `application/json`
|
||||
- root member: `occurrences`
|
||||
|
||||
Each occurrence contains exactly:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `location_id` | string | An exact ID from the consumed normalized location registry. |
|
||||
| `name` | string | The canonical display name associated with `location_id`. |
|
||||
| `kind` | enum | `visited`, `planned`, `recalled`, or `mentioned`. |
|
||||
| `source_refs` | non-empty source-reference array | Current-transcript evidence for both the place and the classified occurrence. |
|
||||
|
||||
`location_id` is required even though existing NPC interactions currently use
|
||||
name-only grounding. Locations can legitimately share the same display name,
|
||||
so a name alone cannot provide an unambiguous cross-artifact link. The name is
|
||||
retained for readable standalone output and must exactly match the registry
|
||||
record selected by the ID after normalization.
|
||||
|
||||
## Identity Policy
|
||||
|
||||
Location identity is conservative and scoped to one source document. It is not
|
||||
a campaign-wide or cross-session world identity.
|
||||
|
||||
Display normalization trims surrounding whitespace and collapses internal
|
||||
Unicode whitespace. Comparison normalization uses the existing D&D entity
|
||||
rules: Unicode NFKC normalization, normalized apostrophes, collapsed
|
||||
whitespace, and Unicode case folding.
|
||||
|
||||
The canonical ID is:
|
||||
|
||||
~~~text
|
||||
location:sha256:<lowercase SHA-256 hex digest>
|
||||
~~~
|
||||
|
||||
The digest input is the UTF-8 encoding of compact JSON for this five-element
|
||||
array:
|
||||
|
||||
~~~text
|
||||
["dnd.locations.identity.v1", comparison_name, source_id, start_unit_id, end_unit_id]
|
||||
~~~
|
||||
|
||||
The source values come from the earliest reference after canonical reference
|
||||
sorting and exact deduplication. Compact JSON array encoding is part of the
|
||||
identity contract: it avoids delimiter ambiguity and must not be replaced
|
||||
without changing the policy version. A blank comparison name or missing valid
|
||||
source reference produces no manufactured ID and remains a validation error.
|
||||
|
||||
Including the evidence anchor prevents two unrelated places called `the
|
||||
tavern` from receiving the same ID. When semantic normalization safely groups
|
||||
aliases or repeated appearances, it first chooses an existing canonical display
|
||||
name and unions the evidence; it then derives the final ID from that name and
|
||||
the earliest unioned reference.
|
||||
|
||||
The normalizer may merge records only when transcript evidence clearly shows
|
||||
that they denote the same physical place. It must not merge records solely
|
||||
because:
|
||||
|
||||
- their comparison names are equal;
|
||||
- they are near one another in the transcript;
|
||||
- one is spatially nested inside the other; or
|
||||
- their labels are both generic.
|
||||
|
||||
Distinct normalized records may therefore have the same comparison name, but
|
||||
their IDs must be unique and correctly derived. Exact duplicates with the same
|
||||
comparison name and canonical evidence may be collapsed deterministically.
|
||||
|
||||
## Occurrence Semantics
|
||||
|
||||
Each occurrence has one kind:
|
||||
|
||||
- `visited`: current-session gameplay establishes that one or more party
|
||||
members are physically present at the location, including an arrival,
|
||||
continuing presence, or departure.
|
||||
- `planned`: the party explicitly proposes, intends, or agrees to future travel
|
||||
to the location. Mere hypotheticals or speculation are not plans.
|
||||
- `recalled`: the transcript explicitly recounts or recaps the party being at
|
||||
the location before the current session's live events.
|
||||
- `mentioned`: the location is explicitly referenced but the occurrence does
|
||||
not meet a stronger definition. This includes lore, directions, third-party
|
||||
activity, non-actionable speculation, and out-of-character discussion.
|
||||
|
||||
An inferred but unstated place produces no location or occurrence. Uncertainty
|
||||
is handled by conservative omission rather than an `uncertain` enum value.
|
||||
|
||||
For one occurrence supported by overlapping evidence, classification precedence
|
||||
is `visited`, then `planned`, then `recalled`, then `mentioned`; `mentioned` is
|
||||
the fallback. A passage may produce multiple records when it independently
|
||||
supports separate facts, such as recalling an earlier visit while planning a
|
||||
return. Exact duplicates with the same ID, kind, and canonical evidence are
|
||||
collapsed. Different kinds or independently supported evidence remain.
|
||||
|
||||
Output is ordered by earliest evidence in source-document order, then by
|
||||
`location_id`, `name`, kind order (`visited`, `planned`, `recalled`,
|
||||
`mentioned`), and the remaining canonical reference sequence.
|
||||
|
||||
## Extraction, Normalization, And Evidence
|
||||
|
||||
### Location registry lane
|
||||
|
||||
The extractor is LLM-backed and follows the shared D&D extraction prompt and
|
||||
input conventions. It emits names and source ranges through a private response
|
||||
schema; deterministic mapping supplies the current source ID and derives
|
||||
candidate IDs. Campaign references may disambiguate terminology but never
|
||||
become durable evidence.
|
||||
|
||||
The merger uses the typed append-order convention. The normalizer is LLM-backed:
|
||||
it deterministically prepares names and evidence, then asks the model only for
|
||||
duplicate groups. The model may identify groups and choose a canonical member,
|
||||
but it may not create, delete, rewrite, or directly replace durable records.
|
||||
Code validates the proposal, applies non-overlapping safe groups, unions
|
||||
evidence, derives final IDs, orders output, and emits bounded warnings.
|
||||
|
||||
Malformed, unknown, overlapping, or ambiguous proposal groups are rejected.
|
||||
The normalizer uses the existing bounded retry behavior and falls back to the
|
||||
safe deterministic candidate set if no usable proposal is obtained.
|
||||
|
||||
### Location occurrence lane
|
||||
|
||||
The extractor is LLM-backed and requires exactly one validated `locations`
|
||||
reference. The prompt projection contains only ordered `{id, name}` pairs; it
|
||||
omits registry evidence and reference provenance. The model must copy both
|
||||
values from one projected record and cite current-transcript source ranges for
|
||||
the occurrence.
|
||||
|
||||
The occurrence normalizer is deterministic. It canonicalizes names by exact
|
||||
registry ID, normalizes evidence and ordering, and removes exact duplicates.
|
||||
Unknown IDs and mismatched ID/name pairs remain inspectable validation failures
|
||||
rather than being guessed or silently reassigned.
|
||||
|
||||
The occurrence lane cannot add a missing location to the registry. If the
|
||||
location extractor omitted a place, the correct behavior is to omit its
|
||||
occurrence and improve the upstream extraction later.
|
||||
|
||||
## Shared Entity Reconciliation
|
||||
|
||||
Adding a second LLM-assisted entity registry demonstrates a concrete shared
|
||||
need in the D&D domain. The existing NPC normalization context-window and
|
||||
proposal-safety logic will move to
|
||||
`internal/modules/dnd/shared/entityreconcile` and serve both NPC and location
|
||||
normalizers.
|
||||
|
||||
The shared package owns:
|
||||
|
||||
- deterministic opaque candidate keys;
|
||||
- source-window construction and canonical prompt materials;
|
||||
- a common private duplicate-group response contract;
|
||||
- validation of unknown, repeated, overlapping, malformed, or ineligible
|
||||
candidate keys; and
|
||||
- immutable assessment results identifying safe groups.
|
||||
|
||||
It does not call the LLM, choose domain-specific canonical names, derive
|
||||
durable IDs, mutate domain artifacts, or format domain warnings. Those
|
||||
responsibilities remain in each normalizer.
|
||||
|
||||
NPC normalization will migrate to the shared key-based proposal contract
|
||||
without changing its durable NPC behavior. Its prompt and private response
|
||||
schema fingerprints are expected to change, so stale NPC normalization
|
||||
checkpoints will invalidate normally.
|
||||
|
||||
The two normalizers will reuse an exactly identical shared reconciliation
|
||||
instruction asset and private response schema. Module-owned task text will
|
||||
continue to define the different NPC and location identity rules. This keeps
|
||||
shared prompt content identical without pretending the two domains have the
|
||||
same semantic merge policy.
|
||||
|
||||
This helper is intentionally D&D-specific. It does not implement the broader
|
||||
domain-neutral replacement-element normalizer still described in
|
||||
[future work](future.md).
|
||||
|
||||
## Reference Contract And Pipeline Placement
|
||||
|
||||
The generated reference slot is named `locations` and accepts exactly one JSON
|
||||
artifact of kind `dnd/location-list`, with the established 1 MiB limit. It is
|
||||
required by both extraction and normalization for
|
||||
`dnd/location-occurrences`. Static file bindings remain valid where the
|
||||
framework permits them, but the maintained complete example uses a generated
|
||||
same-run artifact.
|
||||
|
||||
The complete D&D pipeline places `locations` in the first descriptive step
|
||||
alongside the independent NPC, item-event, and scene-description lanes. It
|
||||
places `location-occurrences` in the next step and binds the accepted normalized
|
||||
location artifact from the first step. The occurrence lane has no mandatory
|
||||
NPC or scene-description dependency.
|
||||
|
||||
No current downstream lane is changed to consume location artifacts. Future
|
||||
narrative reports or joins may use the canonical IDs after defining their own
|
||||
contracts.
|
||||
|
||||
## Validation And Production Defaults
|
||||
|
||||
The location registry receives production validators for:
|
||||
|
||||
- required shape and supported ID syntax;
|
||||
- current-document and current-chunk source ranges;
|
||||
- normalized identity derivation and ID uniqueness; and
|
||||
- advisory source relatedness.
|
||||
|
||||
The location occurrence artifact receives production validators for:
|
||||
|
||||
- required shape and the four supported kinds;
|
||||
- registry membership and exact ID/name correspondence;
|
||||
- normalized ordering and exact-duplicate invariants;
|
||||
- current-document and current-chunk source ranges; and
|
||||
- advisory source relatedness.
|
||||
|
||||
Validators remain immutable and diagnostic. Durable JSON Schema validation
|
||||
stays in the production chains after semantic shape and source-reference
|
||||
checks, consistent with the existing D&D lanes.
|
||||
|
||||
## Documentation End State
|
||||
|
||||
Implementation will add canonical integration documents for both durable
|
||||
artifacts and update current-state documentation to cover:
|
||||
|
||||
- both module and artifact keys;
|
||||
- the `locations` generated-reference slot;
|
||||
- production validators and default chains;
|
||||
- D&D family composition, reconciliation, identity, and grounding behavior;
|
||||
- the complete maintained pipeline example; and
|
||||
- JSON output and evidence-context support.
|
||||
|
||||
After the feature is implemented, the fulfilled Location Extraction section is
|
||||
removed from `future.md`. Historical implementation narration remains in
|
||||
version control rather than current documentation.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Campaign-wide or cross-session canonical location IDs.
|
||||
- A location ontology, hierarchy, map, coordinates, or containment graph.
|
||||
- Location descriptions, summaries, participants, ownership, or encounter
|
||||
analysis.
|
||||
- Inferring a location that the transcript does not identify.
|
||||
- Automatically creating registry records from occurrence output.
|
||||
- Changing NPC-interaction artifacts to use NPC IDs.
|
||||
- Making other lanes consume location references.
|
||||
- A generic domain-neutral LLM deduplication framework.
|
||||
- Long-term artifact-version migration machinery.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Both durable contracts are minimal, strict, versioned, and registered.
|
||||
- Location IDs are deterministic under the documented policy and do not force
|
||||
same-named places to collapse.
|
||||
- Alias and repeat reconciliation is proposal-only, conservatively validated,
|
||||
and safe on retry exhaustion.
|
||||
- Occurrences use one of the four defined kinds and carry an unambiguous
|
||||
registry ID/name pair plus current-transcript evidence.
|
||||
- Missing, malformed, oversized, or incompatible `locations` references fail
|
||||
through the established configuration or operation boundaries.
|
||||
- Both lanes have typed mergers, normalizers, evidence projectors, validators,
|
||||
default chains, prompt/profile metadata, and registration coverage consistent
|
||||
with the D&D family.
|
||||
- NPC normalization retains its durable behavior after adopting the shared
|
||||
reconciliation helper.
|
||||
- The maintained complete example loads and exercises the generated handoff.
|
||||
- Focused D&D and integration tests pass offline, and current documentation
|
||||
describes only implemented behavior once the work is complete.
|
||||
@@ -40,6 +40,7 @@ pipelines:
|
||||
- spells
|
||||
- combat-turns
|
||||
- npc-interactions
|
||||
- enemy-events
|
||||
steps:
|
||||
# Establish session-wide reference artifacts alongside independent item events.
|
||||
- id: describe-session
|
||||
@@ -101,3 +102,28 @@ pipelines:
|
||||
retries: 2
|
||||
merge: appendorder
|
||||
normalize: dnd/npc-interactions
|
||||
- id: track-enemies
|
||||
references:
|
||||
npcs:
|
||||
artifact:
|
||||
step: describe-session
|
||||
lane: npcs
|
||||
scene_descriptions:
|
||||
artifact:
|
||||
step: describe-session
|
||||
lane: scene-descriptions
|
||||
combat_turns:
|
||||
artifact:
|
||||
step: extract-events
|
||||
lane: combat-turns
|
||||
npc_interactions:
|
||||
artifact:
|
||||
step: extract-events
|
||||
lane: npc-interactions
|
||||
artifacts:
|
||||
enemy-events:
|
||||
extract:
|
||||
module: dnd/enemy-events
|
||||
retries: 2
|
||||
merge: appendorder
|
||||
normalize: dnd/enemy-events
|
||||
|
||||
275
internal/cli/dnd_enemy_events_contract_test.go
Normal file
275
internal/cli/dnd_enemy_events_contract_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||
itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||
npcinteractions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
)
|
||||
|
||||
func TestProductionEnemyEventConfigurationResolvesGeneratedHandoffs(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
cfg := loadMaintainedExample(t, repositoryPath("examples", "dnd-complete.config.yml"))
|
||||
effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
|
||||
ConfigPath: repositoryPath("examples", "dnd-complete.config.yml"),
|
||||
WorkingDir: repositoryPath("examples"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeReferences() error = %v, want nil", err)
|
||||
}
|
||||
lane := referenceContractLane(t, materialized, "enemy-events")
|
||||
if lane.ArtifactKind != dnd.EnemyEventListKind || lane.Extract.Module != enemyevents.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != enemyeventnormalize.Key {
|
||||
t.Fatalf("enemy event lane = %#v, want typed production composition", lane)
|
||||
}
|
||||
for slot, want := range map[string]struct{ step, lane string }{
|
||||
"npcs": {step: "describe-session", lane: "npcs"},
|
||||
"scene_descriptions": {step: "describe-session", lane: "scene-descriptions"},
|
||||
"combat_turns": {step: "extract-events", lane: "combat-turns"},
|
||||
"npc_interactions": {step: "extract-events", lane: "npc-interactions"},
|
||||
} {
|
||||
binding, found := generatedReferenceBinding(lane.ExtractReferences.Bindings, slot)
|
||||
if !found || binding.Artifact.Step != want.step || binding.Artifact.Lane != want.lane {
|
||||
t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane)
|
||||
}
|
||||
}
|
||||
if binding, found := generatedReferenceBinding(lane.NormalizeReferences.Bindings, "npcs"); !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "npcs" {
|
||||
t.Fatalf("enemy event normalizer NPC reference = %#v, want generated NPC artifact", binding)
|
||||
}
|
||||
|
||||
catalog := catalogFromRegistries(components.registries)
|
||||
extractSpec, ok := catalog.Extractors.Spec(enemyevents.Key)
|
||||
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.enemy_events"}) {
|
||||
t.Fatalf("enemy event extractor spec = %#v, want source and artifact capabilities", extractSpec)
|
||||
}
|
||||
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(enemyeventnormalize.Key, dnd.EnemyEventListKind)
|
||||
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
|
||||
t.Fatalf("enemy event normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
|
||||
}
|
||||
for _, slot := range []string{"npcs", "scene_descriptions", "combat_turns", "npc_interactions"} {
|
||||
if !hasReferenceSlot(extractSpec.ReferenceSlots, slot) {
|
||||
t.Fatalf("enemy event extractor slots = %#v, want %q", extractSpec.ReferenceSlots, slot)
|
||||
}
|
||||
}
|
||||
if !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") {
|
||||
t.Fatalf("enemy event normalizer slots = %#v, want NPC registry", normalizeSpec.ReferenceSlots)
|
||||
}
|
||||
|
||||
profile := cfg.Pipelines["dnd-session"]
|
||||
profile.Steps[2].References["npcs"] = pipeline.GeneratedReference("track-enemies", "enemy-events")
|
||||
cfg.Pipelines["dnd-session"] = profile
|
||||
if _, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session")); err == nil || !strings.Contains(err.Error(), "earlier step") {
|
||||
t.Fatalf("Resolve() error = %v, want future generated-reference rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t *testing.T) {
|
||||
t.Chdir(repositoryPath())
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
configPath := completeExampleConfigWithTemporaryCache(t)
|
||||
client := &enemyEventLLMClient{}
|
||||
options := productionCLIOptions(t)
|
||||
options.Now = func() time.Time { return time.Unix(1700000000, 0).UTC() }
|
||||
options.RunIDGenerator = func(time.Time) (string, error) { return productionRunID, nil }
|
||||
options.UserCacheDir = func() (string, error) { return "", errors.New("user cache must not be used") }
|
||||
options.LLMClientFactory = func(context.Context, config.Config, string, LLMRuntimeOverrides) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
return client, nil, nil
|
||||
}
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session",
|
||||
"--config", configPath,
|
||||
"--input", repositoryPath("examples", "dnd-complete-transcript.json"),
|
||||
"--chunk_cache", "bypass", "--output-dir", outputRoot, "--session-id", "enemy-event-session",
|
||||
}, &stdout, &stderr, options)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
runRoot := filepath.Join(outputRoot, productionRunID)
|
||||
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
|
||||
var enemyOutput exampleOutputIndexEntry
|
||||
for _, entry := range index.OutputFiles {
|
||||
if entry.LaneID == "enemy-events" {
|
||||
enemyOutput = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if enemyOutput.File != "lanes/enemy-events.json" || enemyOutput.SchemaID != "notarius.dnd.enemy_events" || enemyOutput.SchemaVersion != "v1" {
|
||||
t.Fatalf("enemy event output = %#v, want typed enemy-event JSON", enemyOutput)
|
||||
}
|
||||
value := readProductionJSON[dnd.EnemyEventList](t, filepath.Join(runRoot, enemyOutput.File))
|
||||
if len(value.Events) != 1 || value.Events[0].Name != "Kesh" || value.Events[0].Kind != dnd.EnemyEventKindFled || len(value.Events[0].SourceRefs) != 1 || value.Events[0].SourceRefs[0].SourceID != "session-ravenfall" || value.Events[0].SourceRefs[0].StartUnitID != 10 {
|
||||
t.Fatalf("enemy event artifact = %#v, want source-linked Kesh fleeing event", value)
|
||||
}
|
||||
|
||||
evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json"))
|
||||
if !containsString(evidence.SelectedLanes, "enemy-events") || !evidenceHasLane(evidence, "enemy-events") {
|
||||
t.Fatalf("evidence context = %#v, want direct enemy-event evidence", evidence)
|
||||
}
|
||||
|
||||
requests := client.requestsFor(enemyevents.PromptID)
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("enemy event requests = %#v, want only the combat scene request", requests)
|
||||
}
|
||||
request := requests[0]
|
||||
if request.SessionID != "enemy-event-session" {
|
||||
t.Fatalf("enemy event session = %q, want shared session", request.SessionID)
|
||||
}
|
||||
for slot, required := range map[string]string{
|
||||
"npcs": "Kesh",
|
||||
"combat_turns": "Kesh",
|
||||
"npc_interactions": "Kesh",
|
||||
} {
|
||||
input, ok := request.Inputs[slot]
|
||||
if !ok || !strings.Contains(string(input.Content), required) || strings.Contains(string(input.Content), "source_refs") || strings.Contains(string(input.Content), "start_unit_id") {
|
||||
t.Fatalf("enemy event %s prompt input = %q, want compact source-free grounding", slot, input.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func completeExampleConfigWithTemporaryCache(t *testing.T) string {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(repositoryPath("examples", "dnd-complete.config.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cacheRoot := t.TempDir()
|
||||
updated := strings.Replace(string(content), "directory: ./notarius-cache/chunk-plans", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "chunk-plans")), 1)
|
||||
updated = strings.Replace(updated, "directory: ./notarius-cache/checkpoints", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "checkpoints")), 1)
|
||||
for relative, absolute := range map[string]string{
|
||||
"./dnd-party.txt": repositoryPath("examples", "dnd-party.txt"),
|
||||
"./dnd-glossary.txt": repositoryPath("examples", "dnd-glossary.txt"),
|
||||
"./dnd-spell-catalog.json": repositoryPath("examples", "dnd-spell-catalog.json"),
|
||||
} {
|
||||
updated = strings.ReplaceAll(updated, relative, fmt.Sprintf("%q", absolute))
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "dnd-complete.config.yml")
|
||||
if err := os.WriteFile(path, []byte(updated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
type enemyEventLLMClient struct {
|
||||
mu sync.Mutex
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
combatScene := strings.Contains(string(request.Inputs["transcript"].Content), "Roll initiative")
|
||||
var content []byte
|
||||
switch request.PromptID {
|
||||
case scenes.PromptID:
|
||||
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":6},{"start_unit_id":7,"end_unit_id":11}]}`)
|
||||
case npcs.PromptID:
|
||||
if combatScene {
|
||||
content = []byte(`{"npcs":[{"name":"Kesh","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
|
||||
} else {
|
||||
content = []byte(`{"npcs":[]}`)
|
||||
}
|
||||
case npcnormalize.PromptID:
|
||||
content = []byte(`{"duplicate_groups":[]}`)
|
||||
case scenedescriptions.PromptID:
|
||||
kind, title := "narrative", "Arrival"
|
||||
if combatScene {
|
||||
kind, title = "combat", "Raiders attack"
|
||||
}
|
||||
content = []byte(fmt.Sprintf(`{"kind":%q,"title":%q,"summary":"session scene"}`, kind, title))
|
||||
case spells.PromptID:
|
||||
content = []byte(`{"spell_casts":[]}`)
|
||||
case itemevents.PromptID:
|
||||
content = []byte(`{"events":[]}`)
|
||||
case combat.PromptID:
|
||||
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
|
||||
case npcinteractions.PromptID:
|
||||
if combatScene {
|
||||
content = []byte(`{"interactions":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
|
||||
} else {
|
||||
content = []byte(`{"interactions":[]}`)
|
||||
}
|
||||
case enemyevents.PromptID:
|
||||
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
|
||||
default:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
|
||||
}
|
||||
if err := json.Unmarshal(content, output); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
|
||||
}
|
||||
client.mu.Lock()
|
||||
client.requests = append(client.requests, request)
|
||||
client.mu.Unlock()
|
||||
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: request.ProfileID}, nil
|
||||
}
|
||||
|
||||
func (client *enemyEventLLMClient) requestsFor(promptID string) []contracts.StructuredCompletionRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
var requests []contracts.StructuredCompletionRequest
|
||||
for _, request := range client.requests {
|
||||
if request.PromptID == promptID {
|
||||
requests = append(requests, request)
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evidenceHasLane(value evidencecontext.Document, laneID string) bool {
|
||||
for _, context := range value.Contexts {
|
||||
for _, reference := range context.EvidenceRefs {
|
||||
if reference.LaneID == laneID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) {
|
||||
for _, binding := range bindings {
|
||||
if binding.SlotName == slotName && binding.Artifact != nil {
|
||||
return binding, true
|
||||
}
|
||||
}
|
||||
return pipeline.ReferenceBinding{}, false
|
||||
}
|
||||
@@ -54,8 +54,8 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
|
||||
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
|
||||
}
|
||||
if example.name == "complete" {
|
||||
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells" {
|
||||
t.Fatalf("complete example steps and lanes = %v, want every D&D extractor in the documented two-step composition", got)
|
||||
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells|track-enemies:enemy-events" {
|
||||
t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
|
||||
}
|
||||
spellLane := referenceContractLane(t, materialized, "spells")
|
||||
if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
|
||||
@@ -71,6 +71,18 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
|
||||
t.Fatalf("item event lane unexpectedly depends on generated scene descriptions: %#v", itemEventLane)
|
||||
}
|
||||
}
|
||||
enemyEventLane := referenceContractLane(t, materialized, "enemy-events")
|
||||
for slot, want := range map[string]struct{ step, lane string }{
|
||||
"npcs": {step: "describe-session", lane: "npcs"},
|
||||
"scene_descriptions": {step: "describe-session", lane: "scene-descriptions"},
|
||||
"combat_turns": {step: "extract-events", lane: "combat-turns"},
|
||||
"npc_interactions": {step: "extract-events", lane: "npc-interactions"},
|
||||
} {
|
||||
binding, found := generatedReferenceBinding(enemyEventLane.ExtractReferences.Bindings, slot)
|
||||
if !found || binding.Artifact.Step != want.step || binding.Artifact.Lane != want.lane {
|
||||
t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
|
||||
@@ -29,12 +29,15 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents"
|
||||
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||
@@ -47,9 +50,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
|
||||
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
|
||||
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
|
||||
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key})
|
||||
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key, enemyeventextract.Key})
|
||||
assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
|
||||
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key})
|
||||
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key, enemyeventnormalize.Key})
|
||||
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
|
||||
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||
"extract/dnd/spells/catalog",
|
||||
@@ -64,17 +67,23 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
"extract/dnd/item-events/source_refs",
|
||||
"extract/dnd/item-events/source_relatedness",
|
||||
"normalize/dnd/item-events/invariants",
|
||||
"extract/dnd/enemy-events/shape",
|
||||
"extract/dnd/enemy-events/engagements",
|
||||
"extract/dnd/enemy-events/source_refs",
|
||||
"extract/dnd/enemy-events/source_relatedness",
|
||||
"normalize/dnd/enemy-events/invariants",
|
||||
"generic/always_accept",
|
||||
"generic/always_reject",
|
||||
"generic/valid_json",
|
||||
"generic/valid_json_schema",
|
||||
})
|
||||
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
|
||||
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
|
||||
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
|
||||
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
|
||||
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
|
||||
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
|
||||
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})
|
||||
assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
|
||||
assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
|
||||
|
||||
wantChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
@@ -132,6 +141,17 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) {
|
||||
t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain)
|
||||
}
|
||||
enemyEventExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/shape"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/engagements"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, enemyeventextract.Key); !reflect.DeepEqual(got, enemyEventExtractChain) {
|
||||
t.Fatalf("enemy event extract validator chain = %#v, want %#v", got, enemyEventExtractChain)
|
||||
}
|
||||
|
||||
assetNames := productionAssetNames(t, components.assets.PromptFS)
|
||||
requiredAssets := []string{
|
||||
@@ -162,6 +182,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
"dnd.item_events/sharedassets/common-dnd-system.md",
|
||||
"dnd.item_events/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.item_events/task.md",
|
||||
"dnd.enemy_events/dnd.enemy_events.yaml",
|
||||
"dnd.enemy_events/grounding.md",
|
||||
"dnd.enemy_events/instructions.md",
|
||||
"dnd.enemy_events/task.md",
|
||||
}
|
||||
assertProductionContains(t, "production prompt assets", assetNames, requiredAssets)
|
||||
|
||||
@@ -180,6 +204,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
{stage: pipeline.StageExtract, key: "dnd/item-events", want: contracts.ExecutionClassLLMBacked},
|
||||
{stage: pipeline.StageExtract, key: "dnd/npc-interactions", want: contracts.ExecutionClassLLMBacked},
|
||||
{stage: pipeline.StageExtract, key: "dnd/scene-descriptions", want: contracts.ExecutionClassLLMBacked},
|
||||
{stage: pipeline.StageExtract, key: enemyeventextract.Key, want: contracts.ExecutionClassLLMBacked},
|
||||
{stage: pipeline.StageMerge, key: "appendorder", want: contracts.ExecutionClassDeterministic},
|
||||
{stage: pipeline.StageNormalize, key: "noop", want: contracts.ExecutionClassDeterministic},
|
||||
{stage: pipeline.StageNormalize, key: "dnd/spells", want: contracts.ExecutionClassDeterministic},
|
||||
@@ -188,6 +213,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
{stage: pipeline.StageNormalize, key: "dnd/item-events", want: contracts.ExecutionClassDeterministic},
|
||||
{stage: pipeline.StageNormalize, key: "dnd/npc-interactions", want: contracts.ExecutionClassDeterministic},
|
||||
{stage: pipeline.StageNormalize, key: "dnd/scene-descriptions", want: contracts.ExecutionClassDeterministic},
|
||||
{stage: pipeline.StageNormalize, key: enemyeventnormalize.Key, want: contracts.ExecutionClassDeterministic},
|
||||
{stage: pipeline.StageOutput, key: "json", want: contracts.ExecutionClassDeterministic},
|
||||
} {
|
||||
got, ok := catalog.ExecutionClass(test.stage, test.key)
|
||||
@@ -211,6 +237,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID {
|
||||
t.Fatalf("item event codec spec = %#v, ok=%t, want typed D&D item-event codec", itemEventCodecSpec, ok)
|
||||
}
|
||||
enemyEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.EnemyEventListKind)
|
||||
if !ok || enemyEventCodecSpec.Kind != dnd.EnemyEventListKind || enemyEventCodecSpec.Schema.ID != enemyeventcodec.SchemaID {
|
||||
t.Fatalf("enemy event codec spec = %#v, ok=%t, want typed D&D enemy-event codec", enemyEventCodecSpec, ok)
|
||||
}
|
||||
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.enemy_events",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["events"],
|
||||
"properties": {
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "kind", "source_refs"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"kind": {"type": "string", "enum": ["engaged", "killed", "fled", "captured", "incapacitated"]},
|
||||
"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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
182
internal/modules/dnd/codec/enemyevents/codec.go
Normal file
182
internal/modules/dnd/codec/enemyevents/codec.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Package enemyevents encodes durable D&D enemy-event artifacts.
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaID = "notarius.dnd.enemy_events"
|
||||
SchemaName = "notarius_dnd_enemy_events_v1"
|
||||
SchemaVersion = "v1"
|
||||
MediaType = "application/json"
|
||||
)
|
||||
|
||||
//go:embed assets/schemas/dnd_enemy_events.v1.json
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
func New() *Codec { return &Codec{} }
|
||||
|
||||
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.EnemyEventListKind }
|
||||
|
||||
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_enemy_events.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.EnemyEventList) map[string]any {
|
||||
return map[string]any{"event_count": len(value.Events)}
|
||||
}
|
||||
|
||||
func (c *Codec) Encode(value dnd.EnemyEventList) ([]byte, error) {
|
||||
if err := validateRequiredValueFields(value); err != nil {
|
||||
return nil, fmt.Errorf("encode dnd enemy event list: %w", err)
|
||||
}
|
||||
return c.EncodeCandidate(value)
|
||||
}
|
||||
|
||||
// EncodeCandidate provides the durable representation before semantic
|
||||
// validators have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.EnemyEventList) ([]byte, error) {
|
||||
return candidatejson.EncodeCandidate("dnd enemy event list", cloneList(value))
|
||||
}
|
||||
|
||||
func (c *Codec) Decode(content []byte) (dnd.EnemyEventList, error) {
|
||||
value, err := c.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
return dnd.EnemyEventList{}, err
|
||||
}
|
||||
if err := validateRequiredJSONFields(content); err != nil {
|
||||
return dnd.EnemyEventList{}, fmt.Errorf("decode dnd enemy event list: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.EnemyEventList, error) {
|
||||
value, err := candidatejson.DecodeCandidate[dnd.EnemyEventList]("dnd enemy event list", content)
|
||||
if err != nil {
|
||||
return dnd.EnemyEventList{}, err
|
||||
}
|
||||
return cloneList(value), nil
|
||||
}
|
||||
|
||||
func validateRequiredValueFields(value dnd.EnemyEventList) error {
|
||||
if value.Events == nil {
|
||||
return fmt.Errorf("events must be present")
|
||||
}
|
||||
for index, event := range value.Events {
|
||||
if event.SourceRefs == nil {
|
||||
return fmt.Errorf("events[%d].source_refs must be present", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRequiredJSONFields(content []byte) error {
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(content, &root); err != nil || root == nil {
|
||||
return fmt.Errorf("must be a JSON object")
|
||||
}
|
||||
events, err := requiredArray(root, "events", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for eventIndex, rawEvent := range events {
|
||||
path := fmt.Sprintf("events[%d]", eventIndex)
|
||||
var event map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawEvent, &event); err != nil || event == nil {
|
||||
return fmt.Errorf("%s must be an object", path)
|
||||
}
|
||||
for _, field := range []string{"name", "kind"} {
|
||||
if err := requireField(event, field, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
refs, err := requiredArray(event, "source_refs", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for refIndex, rawRef := range refs {
|
||||
refPath := fmt.Sprintf("%s.source_refs[%d]", path, refIndex)
|
||||
var ref map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawRef, &ref); err != nil || ref == nil {
|
||||
return fmt.Errorf("%s must be an object", refPath)
|
||||
}
|
||||
for _, field := range []string{"source_id", "start_unit_id", "end_unit_id"} {
|
||||
if err := requireField(ref, field, refPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredArray(object map[string]json.RawMessage, field, path string) ([]json.RawMessage, error) {
|
||||
raw, err := requiredField(object, field, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var values []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, fmt.Errorf("%s must be an array", fieldPath(path, field))
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func requireField(object map[string]json.RawMessage, field, path string) error {
|
||||
_, err := requiredField(object, field, path)
|
||||
return err
|
||||
}
|
||||
|
||||
func requiredField(object map[string]json.RawMessage, field, path string) (json.RawMessage, error) {
|
||||
raw, ok := object[field]
|
||||
if !ok || string(raw) == "null" {
|
||||
return nil, fmt.Errorf("%s must be present", fieldPath(path, field))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func fieldPath(path, field string) string {
|
||||
if path == "" {
|
||||
return field
|
||||
}
|
||||
return path + "." + field
|
||||
}
|
||||
|
||||
func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList {
|
||||
if value.Events == nil {
|
||||
return dnd.EnemyEventList{}
|
||||
}
|
||||
cloned := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, len(value.Events))}
|
||||
for index, event := range value.Events {
|
||||
cloned.Events[index] = event
|
||||
if event.SourceRefs != nil {
|
||||
cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
99
internal/modules/dnd/codec/enemyevents/codec_test.go
Normal file
99
internal/modules/dnd/codec/enemyevents/codec_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func validList() dnd.EnemyEventList {
|
||||
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(1, 2)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: refs(5, 6)},
|
||||
}}
|
||||
}
|
||||
|
||||
func refs(start, end int) []source.SourceRef {
|
||||
return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}}
|
||||
}
|
||||
|
||||
func TestCodecRoundTripAndIdentity(t *testing.T) {
|
||||
codec := New()
|
||||
value := validList()
|
||||
content, err := codec.Encode(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
decoded, err := codec.Decode(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value)
|
||||
}
|
||||
|
||||
schema := codec.Schema()
|
||||
if codec.Kind() != dnd.EnemyEventListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
||||
t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema)
|
||||
}
|
||||
registry := pipeline.NewArtifactCodecRegistry()
|
||||
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec, ok := registry.Spec(dnd.EnemyEventListKind)
|
||||
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsStrictJSONAndMissingRequiredFields(t *testing.T) {
|
||||
validJSON := `{"events":[{"name":"Ashfang","kind":"engaged","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
tests := []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{"invalid JSON", `{`, "decode dnd enemy event list"},
|
||||
{"unknown root field", `{"events":[],"unexpected":true}`, "unknown field"},
|
||||
{"unknown event field", strings.Replace(validJSON, `"kind":"engaged"`, `"kind":"engaged","unexpected":true`, 1), "unknown field"},
|
||||
{"missing events", `{}`, "events must be present"},
|
||||
{"missing name", strings.Replace(validJSON, `"name":"Ashfang",`, "", 1), "events[0].name must be present"},
|
||||
{"missing kind", strings.Replace(validJSON, `"kind":"engaged",`, "", 1), "events[0].kind must be present"},
|
||||
{"missing refs", strings.Replace(validJSON, `,"source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]`, "", 1), "events[0].source_refs must be present"},
|
||||
{"missing source ID", strings.Replace(validJSON, `"source_id":"session",`, "", 1), "source_refs[0].source_id must be present"},
|
||||
{"trailing JSON", `{"events":[]} {}`, "multiple JSON values"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Decode() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
|
||||
codec := New()
|
||||
candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
|
||||
Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
|
||||
}}}
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := codec.Decode(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("Decode() = %#v, %v; want semantic candidate preservation", decoded, err)
|
||||
}
|
||||
decoded.Events[0].SourceRefs[0].SourceID = "changed"
|
||||
if candidate.Events[0].SourceRefs[0].SourceID != "" {
|
||||
t.Fatal("Decode() retained caller-owned source references")
|
||||
}
|
||||
|
||||
first := codec.Schema()
|
||||
first.JSONSchema[0] = '['
|
||||
if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
|
||||
t.Fatal("Schema() returned shared bytes")
|
||||
}
|
||||
}
|
||||
107
internal/modules/dnd/enemyevents/enemyevents.go
Normal file
107
internal/modules/dnd/enemyevents/enemyevents.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package enemyevents owns canonical ordering and identity policy for D&D
|
||||
// enemy-event artifacts.
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
// NormalizeDisplay returns the durable display form for an enemy subject.
|
||||
func NormalizeDisplay(value string) string { return identity.NormalizeDisplay(value) }
|
||||
|
||||
// ComparisonKey returns the shared D&D identity key for an enemy subject.
|
||||
func ComparisonKey(value string) string { return identity.ComparisonKey(NormalizeDisplay(value)) }
|
||||
|
||||
// SupportedKind reports whether kind belongs to the durable enemy-event vocabulary.
|
||||
func SupportedKind(kind dnd.EnemyEventKind) bool {
|
||||
_, ok := KindRank(kind)
|
||||
return ok
|
||||
}
|
||||
|
||||
// KindRank returns the explicit chronological tie-break order for event kinds.
|
||||
func KindRank(kind dnd.EnemyEventKind) (int, bool) {
|
||||
switch kind {
|
||||
case dnd.EnemyEventKindEngaged:
|
||||
return 0, true
|
||||
case dnd.EnemyEventKindIncapacitated:
|
||||
return 1, true
|
||||
case dnd.EnemyEventKindCaptured:
|
||||
return 2, true
|
||||
case dnd.EnemyEventKindFled:
|
||||
return 3, true
|
||||
case dnd.EnemyEventKindKilled:
|
||||
return 4, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// SourceRefsEqual reports whether two reference sequences are equal after
|
||||
// canonical ordering and exact duplicate removal.
|
||||
func SourceRefsEqual(order shared.SourceRefOrder, left, right []source.SourceRef) bool {
|
||||
left = order.Canonicalize(left)
|
||||
right = order.Canonicalize(right)
|
||||
if (left == nil) != (right == nil) || len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ExactEqual reports whether events have the same subject identity, kind, and
|
||||
// complete canonical evidence sequence.
|
||||
func ExactEqual(order shared.SourceRefOrder, left, right dnd.EnemyEvent) bool {
|
||||
return ComparisonKey(left.Name) == ComparisonKey(right.Name) &&
|
||||
left.Kind == right.Kind &&
|
||||
SourceRefsEqual(order, left.SourceRefs, right.SourceRefs)
|
||||
}
|
||||
|
||||
// Less defines the canonical stable event order. Invalid source references
|
||||
// remain comparable through SourceRefOrder's literal fallback so malformed
|
||||
// candidates can still be sorted for later validation.
|
||||
func Less(order shared.SourceRefOrder, left, right dnd.EnemyEvent) bool {
|
||||
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
||||
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
||||
if leftHasEvidence != rightHasEvidence {
|
||||
return leftHasEvidence
|
||||
}
|
||||
if leftHasEvidence && leftPosition != rightPosition {
|
||||
return leftPosition < rightPosition
|
||||
}
|
||||
if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey {
|
||||
return leftKey < rightKey
|
||||
}
|
||||
if leftName, rightName := NormalizeDisplay(left.Name), NormalizeDisplay(right.Name); leftName != rightName {
|
||||
return leftName < rightName
|
||||
}
|
||||
if leftRank, leftKnown := KindRank(left.Kind); leftKnown {
|
||||
if rightRank, rightKnown := KindRank(right.Kind); rightKnown && leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
} else if !rightKnown {
|
||||
return true
|
||||
}
|
||||
} else if _, rightKnown := KindRank(right.Kind); rightKnown {
|
||||
return false
|
||||
}
|
||||
if left.Kind != right.Kind {
|
||||
return left.Kind < right.Kind
|
||||
}
|
||||
return sourceRefsLess(order, order.Canonicalize(left.SourceRefs), order.Canonicalize(right.SourceRefs))
|
||||
}
|
||||
|
||||
func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef) bool {
|
||||
for index := 0; index < len(left) && index < len(right); index++ {
|
||||
if left[index] == right[index] {
|
||||
continue
|
||||
}
|
||||
return order.Less(left[index], right[index])
|
||||
}
|
||||
return len(left) < len(right)
|
||||
}
|
||||
90
internal/modules/dnd/enemyevents/enemyevents_test.go
Normal file
90
internal/modules/dnd/enemyevents/enemyevents_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func TestSupportedKindAndRank(t *testing.T) {
|
||||
tests := []struct {
|
||||
kind dnd.EnemyEventKind
|
||||
rank int
|
||||
}{
|
||||
{dnd.EnemyEventKindEngaged, 0},
|
||||
{dnd.EnemyEventKindIncapacitated, 1},
|
||||
{dnd.EnemyEventKindCaptured, 2},
|
||||
{dnd.EnemyEventKindFled, 3},
|
||||
{dnd.EnemyEventKindKilled, 4},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if rank, ok := KindRank(test.kind); !ok || rank != test.rank || !SupportedKind(test.kind) {
|
||||
t.Fatalf("kind %q = (%d, %t), supported %t", test.kind, rank, ok, SupportedKind(test.kind))
|
||||
}
|
||||
}
|
||||
if _, ok := KindRank("unknown"); ok || SupportedKind("unknown") {
|
||||
t.Fatal("unsupported kind was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessOrdersChronologyThenKind(t *testing.T) {
|
||||
order := testOrder()
|
||||
events := []dnd.EnemyEvent{
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindKilled, SourceRefs: refs(20)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: refs(20)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindCaptured, SourceRefs: refs(20)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindIncapacitated, SourceRefs: refs(20)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(20)},
|
||||
{Name: "Later", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(30)},
|
||||
{Name: "Earlier", Kind: dnd.EnemyEventKindKilled, SourceRefs: refs(10)},
|
||||
}
|
||||
sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) })
|
||||
want := []dnd.EnemyEventKind{
|
||||
dnd.EnemyEventKindKilled,
|
||||
dnd.EnemyEventKindEngaged,
|
||||
dnd.EnemyEventKindIncapacitated,
|
||||
dnd.EnemyEventKindCaptured,
|
||||
dnd.EnemyEventKindFled,
|
||||
dnd.EnemyEventKindKilled,
|
||||
dnd.EnemyEventKindEngaged,
|
||||
}
|
||||
for index, kind := range want {
|
||||
if events[index].Kind != kind {
|
||||
t.Fatalf("event %d kind = %q, want %q", index, events[index].Kind, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactEqualUsesSubjectIdentityAndCanonicalEvidence(t *testing.T) {
|
||||
order := testOrder()
|
||||
first := dnd.EnemyEvent{Name: " ASHFANG ", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
}}
|
||||
second := dnd.EnemyEvent{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||
}}
|
||||
if !ExactEqual(order, first, second) || !SourceRefsEqual(order, first.SourceRefs, second.SourceRefs) {
|
||||
t.Fatal("canonical duplicate identity was not recognized")
|
||||
}
|
||||
differentKind := second
|
||||
differentKind.Kind = dnd.EnemyEventKindKilled
|
||||
differentEvidence := second
|
||||
differentEvidence.SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}
|
||||
if ExactEqual(order, first, differentKind) || ExactEqual(order, first, differentEvidence) {
|
||||
t.Fatal("distinct event was treated as an exact duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func refs(unitID int) []source.SourceRef {
|
||||
return []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}}
|
||||
}
|
||||
|
||||
func testOrder() shared.SourceRefOrder {
|
||||
return shared.NewSourceRefOrder(&source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}})
|
||||
}
|
||||
6
internal/modules/dnd/extract/enemyevents/assets.go
Normal file
6
internal/modules/dnd/extract/enemyevents/assets.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package enemyevents
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed assets/schemas/dnd_enemy_events_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md
|
||||
var embeddedAssets embed.FS
|
||||
@@ -0,0 +1,55 @@
|
||||
id: dnd.enemy_events
|
||||
version: "v1"
|
||||
default_profile: dnd-extraction
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
content_type: application/json
|
||||
- name: players
|
||||
required: false
|
||||
content_type: text/plain
|
||||
- name: party
|
||||
required: false
|
||||
content_type: text/plain
|
||||
- name: glossary
|
||||
required: false
|
||||
content_type: text/plain
|
||||
- name: npcs
|
||||
required: true
|
||||
content_type: application/json
|
||||
- name: combat_turns
|
||||
required: true
|
||||
content_type: application/json
|
||||
- name: npc_interactions
|
||||
required: true
|
||||
content_type: application/json
|
||||
messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-identity.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-extraction-evidence.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-npcs.md
|
||||
- role: user
|
||||
content_file: ./grounding.md
|
||||
- role: user
|
||||
content_file: ./task.md
|
||||
- role: user
|
||||
content_file: ./instructions.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: dnd_enemy_events_llm.v1.json
|
||||
repair_attempts: 0
|
||||
@@ -0,0 +1,12 @@
|
||||
Compact combat grounding is supplied below. It can guide attention and
|
||||
disambiguation, but it is not evidence. Do not derive an event, subject,
|
||||
outcome, or source range from either list. The current transcript alone must
|
||||
directly establish every returned event.
|
||||
|
||||
Combat-turn grounding:
|
||||
|
||||
{{ input "combat_turns" }}
|
||||
|
||||
Named combat-opponent grounding:
|
||||
|
||||
{{ input "npc_interactions" }}
|
||||
@@ -0,0 +1,14 @@
|
||||
Exclude party members, allies, neutral observers, mentioned-but-absent enemies,
|
||||
hazards, traps, environmental effects, uncertain allegiance, table talk,
|
||||
planning, hypotheses, recaps outside this passage, and downstream inference.
|
||||
|
||||
Do not infer an engagement or outcome from initiative, turn absence, damage,
|
||||
defeat, movement, a scene ending, combat-opponent grounding, or any auxiliary
|
||||
artifact. Auxiliary inputs can guide attention but cannot prove or supply an
|
||||
event. Cite only narrow current-transcript ranges that establish each event.
|
||||
|
||||
Return the `events` array even when no enemy event is established. Every event
|
||||
must contain only `name`, `kind`, and `source_refs`. Use exactly one kind:
|
||||
`engaged`, `killed`, `fled`, `captured`, or `incapacitated`. Each source range
|
||||
uses integer `start_unit_id` and `end_unit_id`; omit `source_id` because
|
||||
Notarius assigns the current source identity.
|
||||
@@ -0,0 +1,20 @@
|
||||
Extract Dungeons & Dragons enemy events from the supplied combat transcript.
|
||||
|
||||
Return an `engaged` event only when the transcript directly establishes that a
|
||||
subject is actively opposing the party in combat. Return `killed`, `fled`,
|
||||
`captured`, or `incapacitated` only when the transcript explicitly establishes
|
||||
that outcome. An outcome may share evidence with an engagement, and a later
|
||||
engagement or outcome for the same subject remains a separate observation.
|
||||
Emit at most one engagement for the same subject in this combat scene.
|
||||
|
||||
For `killed`, direct death or killing is required. For `fled`, the subject must
|
||||
explicitly escape, retreat, or leave combat to avoid continued engagement. For
|
||||
`captured`, the subject must be explicitly taken prisoner or secured under the
|
||||
party's control. For `incapacitated`, the subject must be explicitly unable to
|
||||
continue acting without being established as killed or captured.
|
||||
|
||||
Use a normalized NPC registry spelling when the transcript identifies that
|
||||
named NPC. A hostile creature without a registry entry is allowed. For unnamed
|
||||
individuals or groups, use only the narrowest transcript-grounded label, such
|
||||
as `Orcs`, `One orc`, or `Remaining orcs`; never invent member names, IDs, or
|
||||
quantities.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.enemy_events.llm",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["events"],
|
||||
"properties": {
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "kind", "source_refs"],
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"kind": {"type": "string"},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_unit_id": {"type": "integer"},
|
||||
"end_unit_id": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
210
internal/modules/dnd/extract/enemyevents/extractor.go
Normal file
210
internal/modules/dnd/extract/enemyevents/extractor.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "dnd/enemy-events"
|
||||
mappingPolicy = "dnd.enemy_events.extract_mapping.v1"
|
||||
sceneGatePolicy = "dnd.enemy_events.scene_gate.v1"
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
"chunks",
|
||||
"source.transcript",
|
||||
}
|
||||
|
||||
var providedCapabilities = []string{
|
||||
"dnd.enemy_events",
|
||||
}
|
||||
|
||||
var _ contracts.Extractor[dnd.EnemyEventList] = (*Extractor)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
||||
|
||||
type Options struct{}
|
||||
|
||||
type Extractor struct {
|
||||
llm contracts.StructuredLLMClient
|
||||
grounding *groundingResolver
|
||||
|
||||
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]
|
||||
}
|
||||
grounding, err := newGroundingResolver(referenceSet)
|
||||
if err != nil {
|
||||
return nil, extractorErrorf("prepare grounding: %w", err)
|
||||
}
|
||||
promptSHA, err := promptAssetMetadata()
|
||||
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,
|
||||
grounding: grounding,
|
||||
promptSHA: promptSHA,
|
||||
responseSchemaSHA: responseSchema.SHA256,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *Extractor) Key() string { return Key }
|
||||
|
||||
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
|
||||
|
||||
func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": SchemaVersion,
|
||||
"prompt_sha256": e.promptSHA,
|
||||
"mapping_policy": mappingPolicy,
|
||||
"scene_gate_policy": sceneGatePolicy,
|
||||
"response_schema_key": string(ResponseSchemaKey),
|
||||
"response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName,
|
||||
"response_schema_version": SchemaVersion,
|
||||
"response_schema_sha256": e.responseSchemaSHA,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "prompt", Value: e.promptSHA},
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
{Name: "mapping_policy", Value: mappingPolicy},
|
||||
{Name: "scene_gate_policy", Value: sceneGatePolicy},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.EnemyEventList], error) {
|
||||
if e == nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("extractor must not be nil")
|
||||
}
|
||||
if e.llm == nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
if e.grounding == nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("grounding resolver must not be nil")
|
||||
}
|
||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
match, err := e.grounding.SceneMatch(req.References, req.Chunk)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve scene eligibility: %w", err)
|
||||
}
|
||||
switch match.State {
|
||||
case sceneregistry.MatchExact:
|
||||
if match.Kind != dnd.SceneKindCombat {
|
||||
return emptyResult(), nil
|
||||
}
|
||||
case sceneregistry.MatchMissing, sceneregistry.MatchMismatched:
|
||||
return unavailableSceneResult(), nil
|
||||
default:
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("unsupported scene eligibility match state %q", match.State)
|
||||
}
|
||||
grounding, err := e.grounding.Resolve(req.References)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve enemy-event grounding: %w", err)
|
||||
}
|
||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||
for name, input := range grounding.PromptInputs() {
|
||||
inputs[name] = input
|
||||
}
|
||||
var response extractionResponse
|
||||
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.EnemyEventList]{}, extractorErrorf("complete structured output: %w", err)
|
||||
}
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{
|
||||
Value: canonicalEnemyEventList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func emptyResult() contracts.TypedExtractionResult[dnd.EnemyEventList] {
|
||||
return contracts.TypedExtractionResult[dnd.EnemyEventList]{Value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}}
|
||||
}
|
||||
|
||||
func unavailableSceneResult() contracts.TypedExtractionResult[dnd.EnemyEventList] {
|
||||
result := emptyResult()
|
||||
result.Warnings = []contracts.Warning{{
|
||||
Scope: SceneDescriptionReferenceSlot,
|
||||
ReasonCode: "scene_classification_unavailable",
|
||||
Message: "No exact scene classification was available; enemy-event extraction was skipped.",
|
||||
}}
|
||||
return result
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageExtract,
|
||||
ExecutionClass: contracts.ExecutionClassLLMBacked,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.EnemyEventListKind,
|
||||
ReferenceSlots: referenceSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ExtractorRegistry) error {
|
||||
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.EnemyEventList], 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 enemy events extractor: "+format, args...)
|
||||
}
|
||||
229
internal/modules/dnd/extract/enemyevents/extractor_test.go
Normal file
229
internal/modules/dnd/extract/enemyevents/extractor_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package enemyevents
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestExtractMapsEnemyEventsInSourceOrder(t *testing.T) {
|
||||
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{
|
||||
{Name: "Ashfang", Kind: "killed", SourceRefs: []enemySourceRefResponse{{StartUnitID: 4, EndUnitID: 4}}},
|
||||
{Name: "Ashfang", Kind: "engaged", SourceRefs: []enemySourceRefResponse{{StartUnitID: 1, EndUnitID: 1}, {StartUnitID: 1, EndUnitID: 1}}},
|
||||
{Name: "Orcs", Kind: "fled", SourceRefs: []enemySourceRefResponse{{StartUnitID: 3, EndUnitID: 3}}},
|
||||
{Name: "One orc", Kind: "captured", SourceRefs: []enemySourceRefResponse{{StartUnitID: 2, EndUnitID: 2}}},
|
||||
{Name: "Ashfang", Kind: "incapacitated", SourceRefs: []enemySourceRefResponse{{StartUnitID: 2, EndUnitID: 2}}},
|
||||
}}}
|
||||
|
||||
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := []dnd.EnemyEventKind{result.Value.Events[0].Kind, result.Value.Events[1].Kind, result.Value.Events[2].Kind, result.Value.Events[3].Kind, result.Value.Events[4].Kind}; !reflect.DeepEqual(got, []dnd.EnemyEventKind{
|
||||
dnd.EnemyEventKindEngaged,
|
||||
dnd.EnemyEventKindCaptured,
|
||||
dnd.EnemyEventKindIncapacitated,
|
||||
dnd.EnemyEventKindFled,
|
||||
dnd.EnemyEventKindKilled,
|
||||
}) {
|
||||
t.Fatalf("event order = %#v", got)
|
||||
}
|
||||
if refs := result.Value.Events[0].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}) {
|
||||
t.Fatalf("canonical source refs = %#v", refs)
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
||||
}
|
||||
request := client.requests[0]
|
||||
if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "enemy-profile" || request.SessionID != "session-123" {
|
||||
t.Fatalf("LLM request identity = %#v", request)
|
||||
}
|
||||
if got := string(request.Inputs[CombatTurnReferenceSlot].Content); !strings.Contains(got, `"actor":"Ashfang"`) || strings.Contains(got, "source_ref") {
|
||||
t.Fatalf("combat grounding = %s", got)
|
||||
}
|
||||
if got := string(request.Inputs[NPCInteractionReferenceSlot].Content); !strings.Contains(got, `"kind":"combat_opponent"`) || strings.Contains(got, "Aria") {
|
||||
t.Fatalf("interaction grounding = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPreservesSemanticCandidatesAndResponseOwnership(t *testing.T) {
|
||||
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{{
|
||||
Name: " ", Kind: "unsupported", SourceRefs: []enemySourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
}}}}
|
||||
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := result.Value.Events[0]
|
||||
if event.Name != " " || event.Kind != "unsupported" || event.SourceRefs[0] != (source.SourceRef{SourceID: "combat-session", StartUnitID: 99}) {
|
||||
t.Fatalf("semantic candidate = %#v", event)
|
||||
}
|
||||
result.Value.Events[0].SourceRefs[0].StartUnitID = 7
|
||||
if client.response.Events[0].SourceRefs[0].StartUnitID != 99 {
|
||||
t.Fatal("mapped event aliases model-owned source references")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSkipsModelForIneligibleScenes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
kind dnd.SceneKind
|
||||
change func(*source.Chunk)
|
||||
wantWarning bool
|
||||
}{
|
||||
{name: "non-combat", kind: dnd.SceneKindNarrative},
|
||||
{name: "missing", kind: dnd.SceneKindCombat, change: func(chunk *source.Chunk) { chunk.ID = "other" }, wantWarning: true},
|
||||
{name: "mismatched", kind: dnd.SceneKindCombat, change: func(chunk *source.Chunk) { chunk.Ref.EndUnitID++ }, wantWarning: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &fakeEnemyEventsLLMClient{}
|
||||
request := enemyExtractionRequest(t)
|
||||
request.References = groundingReferences(t, "Ashfang", test.kind)
|
||||
if test.change != nil {
|
||||
test.change(request.Chunk)
|
||||
}
|
||||
result, err := newEnemyExtractor(t, client).Extract(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(client.requests) != 0 || result.Value.Events == nil || len(result.Value.Events) != 0 {
|
||||
t.Fatalf("result = %#v, calls = %d", result, len(client.requests))
|
||||
}
|
||||
if test.wantWarning {
|
||||
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != "scene_classification_unavailable" || result.Warnings[0].Scope != SceneDescriptionReferenceSlot {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
} else if len(result.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReportsRequiredGroundingAndProviderFailures(t *testing.T) {
|
||||
client := &fakeEnemyEventsLLMClient{}
|
||||
request := enemyExtractionRequest(t)
|
||||
request.References = withoutSlot(request.References, NPCRegistryReferenceSlot)
|
||||
if _, err := (&Extractor{llm: client, grounding: mustGroundingResolver(t, request.References)}).Extract(context.Background(), request); err == nil || !strings.Contains(err.Error(), "NPC registry") {
|
||||
t.Fatalf("Extract() error = %v, want required grounding context", err)
|
||||
}
|
||||
|
||||
provider := &fakeEnemyEventsLLMClient{err: errors.New("provider unavailable")}
|
||||
if _, err := newEnemyExtractor(t, provider).Extract(context.Background(), enemyExtractionRequest(t)); err == nil || !strings.Contains(err.Error(), "complete structured output") || !strings.Contains(err.Error(), "provider unavailable") {
|
||||
t.Fatalf("Extract() error = %v, want provider context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructorSpecOptionsAndSafeMetadata(t *testing.T) {
|
||||
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
|
||||
t.Fatalf("New(nil) error = %v", err)
|
||||
}
|
||||
if _, err := New(&fakeEnemyEventsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one") {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("DecodeOptions() error = %v", err)
|
||||
}
|
||||
|
||||
first := ModuleSpec()
|
||||
first.Requires[0] = "changed"
|
||||
first.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
||||
second := ModuleSpec()
|
||||
if second.Requires[0] != "chunks" || second.ArtifactKind != dnd.EnemyEventListKind || second.ExecutionClass != contracts.ExecutionClassLLMBacked || second.ReferenceSlots[0].AcceptedMediaTypes[0] == "changed" {
|
||||
t.Fatalf("ModuleSpec() reused mutable state: %#v", second)
|
||||
}
|
||||
registry := pipeline.NewExtractorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec, ok := registry.Spec(Key); !ok || spec.Key != Key || spec.ArtifactKind != dnd.EnemyEventListKind {
|
||||
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||
}
|
||||
|
||||
extractor := newEnemyExtractor(t, &fakeEnemyEventsLLMClient{}, groundingReferences(t, "Ashfang", dnd.SceneKindCombat))
|
||||
metadata := extractor.ManifestMetadata()
|
||||
encoded, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "Ashfang") || metadata["mapping_policy"] != mappingPolicy || metadata["scene_gate_policy"] != sceneGatePolicy {
|
||||
t.Fatalf("unsafe or incomplete metadata = %s", encoded)
|
||||
}
|
||||
fingerprints := extractor.CheckpointFingerprints()
|
||||
if len(fingerprints) != 4 || fingerprints[0].Value != metadata["prompt_sha256"] || fingerprints[1].Value != metadata["response_schema_sha256"] || fingerprints[2].Value != mappingPolicy || fingerprints[3].Value != sceneGatePolicy {
|
||||
t.Fatalf("fingerprints = %#v", fingerprints)
|
||||
}
|
||||
}
|
||||
|
||||
func newEnemyExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
||||
t.Helper()
|
||||
if len(references) == 0 {
|
||||
references = []contracts.ReferenceSet{groundingReferences(t, "Ashfang", dnd.SceneKindCombat)}
|
||||
}
|
||||
extractor, err := New(client, Options{}, references...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return extractor
|
||||
}
|
||||
|
||||
func mustGroundingResolver(t *testing.T, references contracts.ReferenceSet) *groundingResolver {
|
||||
t.Helper()
|
||||
resolver, err := newGroundingResolver(references)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolver
|
||||
}
|
||||
|
||||
func enemyExtractionRequest(t *testing.T) contracts.TypedExtractionRequest {
|
||||
t.Helper()
|
||||
chunk := combatChunk()
|
||||
chunk.Units = []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}
|
||||
chunk.MediaType = "application/json"
|
||||
chunk.Content = []byte(`{"id":"combat-scene","units":[1,2,3,4]}`)
|
||||
return contracts.TypedExtractionRequest{
|
||||
Source: &source.SourceDocument{ID: "combat-session", Units: append([]source.SourceUnit(nil), chunk.Units...)},
|
||||
Chunk: chunk,
|
||||
SourceInput: contracts.NewLLMInputMaterial("source", "application/json", chunk.Content, digest(chunk.Content), "file:///combat-session.json"),
|
||||
References: groundingReferences(t, "Ashfang", dnd.SceneKindCombat),
|
||||
LLMProfile: "enemy-profile",
|
||||
SessionID: "session-123",
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEnemyEventsLLMClient struct {
|
||||
response extractionResponse
|
||||
err error
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *fakeEnemyEventsLLMClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
client.requests = append(client.requests, cloneEnemyRequest(request))
|
||||
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 cloneEnemyRequest(request contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
||||
request.Inputs = request.Inputs.Clone()
|
||||
return request
|
||||
}
|
||||
293
internal/modules/dnd/extract/enemyevents/grounding.go
Normal file
293
internal/modules/dnd/extract/enemyevents/grounding.go
Normal file
@@ -0,0 +1,293 @@
|
||||
// Package enemyevents prepares validated D&D combat grounding for enemy-event
|
||||
// extraction.
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"sort"
|
||||
"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"
|
||||
combatturncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
|
||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
|
||||
SceneDescriptionReferenceSlot = sceneregistry.ReferenceSlot
|
||||
CombatTurnReferenceSlot = "combat_turns"
|
||||
NPCInteractionReferenceSlot = "npc_interactions"
|
||||
ReferenceMaxBytes = 1048576
|
||||
promptProjectionMediaType = "application/json"
|
||||
)
|
||||
|
||||
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: "Required normalized NPC registry used only for enemy-subject grounding, never as event evidence.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{npccodec.MediaType},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
|
||||
MaxBytes: ReferenceMaxBytes,
|
||||
},
|
||||
contracts.ReferenceSlot{
|
||||
Name: SceneDescriptionReferenceSlot,
|
||||
Description: "Required scene descriptions used only to determine exact combat eligibility, never as event evidence.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{scenecodec.MediaType},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.SceneDescriptionListKind},
|
||||
MaxBytes: ReferenceMaxBytes,
|
||||
},
|
||||
contracts.ReferenceSlot{
|
||||
Name: CombatTurnReferenceSlot,
|
||||
Description: "Required combat-turn artifact used only as source-free enemy-event grounding.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{combatturncodec.MediaType},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.CombatTurnListKind},
|
||||
MaxBytes: ReferenceMaxBytes,
|
||||
},
|
||||
contracts.ReferenceSlot{
|
||||
Name: NPCInteractionReferenceSlot,
|
||||
Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{interactioncodec.MediaType},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind},
|
||||
MaxBytes: ReferenceMaxBytes,
|
||||
},
|
||||
)
|
||||
sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name })
|
||||
return contracts.CloneReferenceSlots(slots)
|
||||
}
|
||||
|
||||
// groundingResolver retains only validated, compact construction-time views.
|
||||
// Per-operation generated references are decoded when supplied and never
|
||||
// become static metadata.
|
||||
type groundingResolver struct {
|
||||
npcs *npcregistry.Resolver
|
||||
scenes *sceneregistry.Resolver
|
||||
|
||||
combatTurns *contracts.LLMInputMaterial
|
||||
npcInteractions *contracts.LLMInputMaterial
|
||||
}
|
||||
|
||||
type grounding struct {
|
||||
npcInput contracts.LLMInputMaterial
|
||||
combatTurnInput contracts.LLMInputMaterial
|
||||
npcInteractionInput contracts.LLMInputMaterial
|
||||
}
|
||||
|
||||
func newGroundingResolver(references contracts.ReferenceSet) (*groundingResolver, error) {
|
||||
npcs, err := npcregistry.NewResolver(references)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare NPC registry grounding: %w", err)
|
||||
}
|
||||
scenes, err := sceneregistry.NewResolver(references)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare scene eligibility: %w", err)
|
||||
}
|
||||
combatTurns, err := prepareCombatTurnInput(references)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
npcInteractions, err := prepareNPCInteractionInput(references)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &groundingResolver{
|
||||
npcs: npcs,
|
||||
scenes: scenes,
|
||||
combatTurns: combatTurns,
|
||||
npcInteractions: npcInteractions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *groundingResolver) Resolve(references contracts.ReferenceSet) (grounding, error) {
|
||||
if r == nil {
|
||||
return grounding{}, fmt.Errorf("grounding resolver must not be nil")
|
||||
}
|
||||
npcs, err := r.npcs.Resolve(references)
|
||||
if err != nil {
|
||||
return grounding{}, fmt.Errorf("resolve NPC registry grounding: %w", err)
|
||||
}
|
||||
if !npcs.Bound() {
|
||||
return grounding{}, fmt.Errorf("NPC registry reference is required")
|
||||
}
|
||||
combatTurns, err := resolveInput(references, CombatTurnReferenceSlot, r.combatTurns, prepareCombatTurnInput)
|
||||
if err != nil {
|
||||
return grounding{}, err
|
||||
}
|
||||
npcInteractions, err := resolveInput(references, NPCInteractionReferenceSlot, r.npcInteractions, prepareNPCInteractionInput)
|
||||
if err != nil {
|
||||
return grounding{}, err
|
||||
}
|
||||
return grounding{
|
||||
npcInput: npcs.PromptInput(),
|
||||
combatTurnInput: combatTurns,
|
||||
npcInteractionInput: npcInteractions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *groundingResolver) SceneMatch(references contracts.ReferenceSet, chunk *source.Chunk) (sceneregistry.ChunkMatch, error) {
|
||||
if r == nil {
|
||||
return sceneregistry.ChunkMatch{}, fmt.Errorf("grounding resolver must not be nil")
|
||||
}
|
||||
scenes, err := r.resolveScenes(references)
|
||||
if err != nil {
|
||||
return sceneregistry.ChunkMatch{}, err
|
||||
}
|
||||
return scenes.Match(chunk), nil
|
||||
}
|
||||
|
||||
func (r *groundingResolver) resolveScenes(references contracts.ReferenceSet) (*sceneregistry.Registry, error) {
|
||||
scenes, err := r.scenes.Resolve(references)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve scene eligibility: %w", err)
|
||||
}
|
||||
if !scenes.Bound() {
|
||||
return nil, fmt.Errorf("scene descriptions reference is required")
|
||||
}
|
||||
return scenes, nil
|
||||
}
|
||||
|
||||
func resolveInput(references contracts.ReferenceSet, slot string, seeded *contracts.LLMInputMaterial, prepare func(contracts.ReferenceSet) (*contracts.LLMInputMaterial, error)) (contracts.LLMInputMaterial, error) {
|
||||
if _, ok := references.Slots[slot]; ok {
|
||||
value, err := prepare(references)
|
||||
if err != nil {
|
||||
return contracts.LLMInputMaterial{}, err
|
||||
}
|
||||
if value == nil {
|
||||
return contracts.LLMInputMaterial{}, fmt.Errorf("%s reference is required", slot)
|
||||
}
|
||||
return value.Clone(), nil
|
||||
}
|
||||
if seeded == nil {
|
||||
return contracts.LLMInputMaterial{}, fmt.Errorf("%s reference is required", slot)
|
||||
}
|
||||
return seeded.Clone(), nil
|
||||
}
|
||||
|
||||
func (g grounding) PromptInputs() contracts.LLMInputSet {
|
||||
return contracts.LLMInputSet{
|
||||
NPCRegistryReferenceSlot: g.npcInput.Clone(),
|
||||
CombatTurnReferenceSlot: g.combatTurnInput.Clone(),
|
||||
NPCInteractionReferenceSlot: g.npcInteractionInput.Clone(),
|
||||
}
|
||||
}
|
||||
|
||||
func prepareCombatTurnInput(references contracts.ReferenceSet) (*contracts.LLMInputMaterial, error) {
|
||||
item, ok, err := referenceItem(references, CombatTurnReferenceSlot, combatturncodec.MediaType)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
value, err := combatturncodec.New().Decode(item.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode combat-turn grounding: invalid approved combat-turn JSON")
|
||||
}
|
||||
content, err := json.Marshal(struct {
|
||||
CombatTurns []combatTurnProjection `json:"combat_turns"`
|
||||
}{CombatTurns: projectCombatTurns(value.CombatTurns)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode combat-turn grounding: %w", err)
|
||||
}
|
||||
return newPromptInput(CombatTurnReferenceSlot, content), nil
|
||||
}
|
||||
|
||||
func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.LLMInputMaterial, error) {
|
||||
item, ok, err := referenceItem(references, NPCInteractionReferenceSlot, interactioncodec.MediaType)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
value, err := interactioncodec.New().Decode(item.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode NPC-interaction grounding: invalid approved NPC-interaction JSON")
|
||||
}
|
||||
content, err := json.Marshal(struct {
|
||||
Interactions []npcInteractionProjection `json:"npc_interactions"`
|
||||
}{Interactions: projectNPCInteractions(value.Interactions)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
|
||||
}
|
||||
return newPromptInput(NPCInteractionReferenceSlot, content), nil
|
||||
}
|
||||
|
||||
func referenceItem(references contracts.ReferenceSet, slotName, expectedMediaType string) (contracts.ReferenceItem, bool, error) {
|
||||
slot, ok := references.Slots[slotName]
|
||||
if !ok {
|
||||
return contracts.ReferenceItem{}, false, nil
|
||||
}
|
||||
if len(slot.Items) == 0 {
|
||||
return contracts.ReferenceItem{}, false, nil
|
||||
}
|
||||
if len(slot.Items) != 1 {
|
||||
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q must contain exactly one item", slotName)
|
||||
}
|
||||
item := slot.Items[0]
|
||||
mediaType, _, err := mime.ParseMediaType(item.MediaType)
|
||||
if err != nil {
|
||||
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item media type is invalid", slotName)
|
||||
}
|
||||
if !strings.EqualFold(mediaType, expectedMediaType) {
|
||||
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item media type must be %s", slotName, expectedMediaType)
|
||||
}
|
||||
if len(item.Content) > ReferenceMaxBytes {
|
||||
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item is %d bytes, limit %d", slotName, len(item.Content), ReferenceMaxBytes)
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
type combatTurnProjection struct {
|
||||
Actor string `json:"actor"`
|
||||
TurnKind dnd.CombatTurnKind `json:"turn_kind"`
|
||||
}
|
||||
|
||||
func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection {
|
||||
if turns == nil {
|
||||
return nil
|
||||
}
|
||||
projection := make([]combatTurnProjection, len(turns))
|
||||
for index, turn := range turns {
|
||||
projection[index] = combatTurnProjection{Actor: turn.Actor, TurnKind: turn.TurnKind}
|
||||
}
|
||||
return projection
|
||||
}
|
||||
|
||||
type npcInteractionProjection struct {
|
||||
Name string `json:"name"`
|
||||
Kind dnd.NPCInteractionKind `json:"kind"`
|
||||
}
|
||||
|
||||
func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection {
|
||||
projection := make([]npcInteractionProjection, 0, len(interactions))
|
||||
for _, interaction := range interactions {
|
||||
if interaction.Kind == dnd.NPCInteractionKindCombatOpponent {
|
||||
projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind})
|
||||
}
|
||||
}
|
||||
return projection
|
||||
}
|
||||
|
||||
func newPromptInput(name string, content []byte) *contracts.LLMInputMaterial {
|
||||
sum := sha256.Sum256(content)
|
||||
material := contracts.NewLLMInputMaterial(name, promptProjectionMediaType, content, "sha256:"+hex.EncodeToString(sum[:]), "")
|
||||
return &material
|
||||
}
|
||||
279
internal/modules/dnd/extract/enemyevents/grounding_test.go
Normal file
279
internal/modules/dnd/extract/enemyevents/grounding_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
combatturncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
|
||||
)
|
||||
|
||||
func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
|
||||
slots := referenceSlots()
|
||||
if len(slots) != 8 {
|
||||
t.Fatalf("ReferenceSlots() count = %d, want 8", len(slots))
|
||||
}
|
||||
byName := make(map[string]contracts.ReferenceSlot, len(slots))
|
||||
for index, slot := range slots {
|
||||
if index > 0 && slots[index-1].Name > slot.Name {
|
||||
t.Fatalf("ReferenceSlots() is not sorted: %#v", slots)
|
||||
}
|
||||
byName[slot.Name] = slot
|
||||
}
|
||||
for _, want := range []struct {
|
||||
name string
|
||||
kind contracts.ArtifactKind
|
||||
}{
|
||||
{NPCRegistryReferenceSlot, dnd.NPCListKind},
|
||||
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
|
||||
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
|
||||
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind},
|
||||
} {
|
||||
slot, ok := byName[want.name]
|
||||
if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind {
|
||||
t.Fatalf("slot %q = %#v", want.name, slot)
|
||||
}
|
||||
}
|
||||
|
||||
slots[0].AcceptedMediaTypes[0] = "changed"
|
||||
if referenceSlots()[0].AcceptedMediaTypes[0] == "changed" {
|
||||
t.Fatal("ReferenceSlots() returned caller-owned storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundingProducesExactSourceFreePromptInputs(t *testing.T) {
|
||||
resolver, err := newGroundingResolver(groundingReferences(t, "Ashfang", dnd.SceneKindCombat))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := resolver.Resolve(contracts.ReferenceSet{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputs := resolved.PromptInputs()
|
||||
want := map[string]string{
|
||||
NPCRegistryReferenceSlot: `{"npcs":[{"name":"Ashfang"}]}`,
|
||||
CombatTurnReferenceSlot: `{"combat_turns":[{"actor":"Ashfang","turn_kind":"turn"},{"actor":"Aria","turn_kind":"reaction"}]}`,
|
||||
NPCInteractionReferenceSlot: `{"npc_interactions":[{"name":"Ashfang","kind":"combat_opponent"}]}`,
|
||||
}
|
||||
if len(inputs) != len(want) {
|
||||
t.Fatalf("PromptInputs() = %#v", inputs)
|
||||
}
|
||||
for name, content := range want {
|
||||
input, ok := inputs[name]
|
||||
if !ok || string(input.Content) != content || input.Digest != digest([]byte(content)) || input.OriginURI != "" || input.MediaType != "application/json" {
|
||||
t.Fatalf("PromptInputs()[%q] = %#v, want %q", name, input, content)
|
||||
}
|
||||
for _, forbidden := range []string{"source_ref", "npc-ashfang", "origin", "summary", "combat-session"} {
|
||||
if strings.Contains(string(input.Content), forbidden) {
|
||||
t.Fatalf("PromptInputs()[%q] leaked %q: %s", name, forbidden, input.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := inputs[SceneDescriptionReferenceSlot]; ok {
|
||||
t.Fatal("scene descriptions were rendered as prompt grounding")
|
||||
}
|
||||
|
||||
inputs[CombatTurnReferenceSlot] = contracts.NewLLMInputMaterial("changed", "text/plain", []byte("changed"), "", "")
|
||||
if next := resolved.PromptInputs()[CombatTurnReferenceSlot]; string(next.Content) != want[CombatTurnReferenceSlot] {
|
||||
t.Fatal("PromptInputs() did not return a defensive copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundingResolvesGeneratedReferencesAndSceneEligibility(t *testing.T) {
|
||||
prepared := groundingReferences(t, "Ashfang", dnd.SceneKindCombat)
|
||||
resolver, err := newGroundingResolver(prepared)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
generated := groundingReferences(t, "Grimjaw", dnd.SceneKindNarrative)
|
||||
resolved, err := resolver.Resolve(generated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(resolved.PromptInputs()[NPCRegistryReferenceSlot].Content); got != `{"npcs":[{"name":"Grimjaw"}]}` {
|
||||
t.Fatalf("generated NPC projection = %s", got)
|
||||
}
|
||||
match, err := resolver.SceneMatch(generated, combatChunk())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match != (sceneregistry.ChunkMatch{State: sceneregistry.MatchExact, Kind: dnd.SceneKindNarrative}) {
|
||||
t.Fatalf("generated scene match = %#v", match)
|
||||
}
|
||||
match, err = resolver.SceneMatch(generated, &source.Chunk{ID: "other", Ref: combatChunk().Ref})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match.State != sceneregistry.MatchMissing {
|
||||
t.Fatal("missing scene was not reported")
|
||||
}
|
||||
mismatched := combatChunk()
|
||||
mismatched.Ref.EndUnitID++
|
||||
match, err = resolver.SceneMatch(generated, mismatched)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match.State != sceneregistry.MatchMismatched {
|
||||
t.Fatal("mismatched scene was not reported")
|
||||
}
|
||||
|
||||
static, err := resolver.Resolve(contracts.ReferenceSet{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(static.PromptInputs()[NPCRegistryReferenceSlot].Content); got != `{"npcs":[{"name":"Ashfang"}]}` {
|
||||
t.Fatalf("static NPC projection changed after generated resolution: %s", got)
|
||||
}
|
||||
match, err = resolver.SceneMatch(contracts.ReferenceSet{}, combatChunk())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match != (sceneregistry.ChunkMatch{State: sceneregistry.MatchExact, Kind: dnd.SceneKindCombat}) {
|
||||
t.Fatalf("static scene match = %#v", match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) {
|
||||
valid := groundingReferences(t, "Ashfang", dnd.SceneKindCombat)
|
||||
for _, test := range []struct {
|
||||
slot string
|
||||
want string
|
||||
}{
|
||||
{NPCRegistryReferenceSlot, "NPC registry"},
|
||||
{SceneDescriptionReferenceSlot, "scene descriptions"},
|
||||
{CombatTurnReferenceSlot, CombatTurnReferenceSlot},
|
||||
{NPCInteractionReferenceSlot, NPCInteractionReferenceSlot},
|
||||
} {
|
||||
t.Run("missing "+test.slot, func(t *testing.T) {
|
||||
resolver, err := newGroundingResolver(withoutSlot(valid, test.slot))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.slot == SceneDescriptionReferenceSlot {
|
||||
_, err = resolver.SceneMatch(contracts.ReferenceSet{}, combatChunk())
|
||||
} else {
|
||||
_, err = resolver.Resolve(contracts.ReferenceSet{})
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("grounding error = %v, want missing %q reference", err, test.slot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
set contracts.ReferenceSet
|
||||
}{
|
||||
{"multiple items", replaceSlot(valid, CombatTurnReferenceSlot, contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{{MediaType: "application/json"}, {MediaType: "application/json"}}})},
|
||||
{"malformed durable content", replaceItem(valid, NPCInteractionReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: []byte(`{}`)})},
|
||||
{"wrong media type", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "text/plain", Content: valid.Slots[CombatTurnReferenceSlot].Items[0].Content})},
|
||||
{"oversize", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: make([]byte, ReferenceMaxBytes+1)})},
|
||||
}
|
||||
resolver, err := newGroundingResolver(valid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := resolver.Resolve(test.set); err == nil {
|
||||
t.Fatal("Resolve() error = nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
npcContent, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{{
|
||||
ID: identity.DeriveID(enemy),
|
||||
Name: enemy,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sceneContent, err := scenecodec.New().Encode(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{
|
||||
ID: "combat-scene",
|
||||
SourceRef: source.SourceRef{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 4},
|
||||
Kind: sceneKind,
|
||||
Title: "Combat session",
|
||||
Summary: "A detailed scene summary that must not reach the prompt.",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
turnContent, err := combatturncodec.New().Encode(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
||||
{Actor: enemy, TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{Actor: "Aria", TurnKind: dnd.CombatTurnKindReaction, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||
{Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{Name: "Aria", Kind: dnd.NPCInteractionKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
NPCRegistryReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(NPCRegistryReferenceSlot, npcContent)}},
|
||||
SceneDescriptionReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(SceneDescriptionReferenceSlot, sceneContent)}},
|
||||
CombatTurnReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(CombatTurnReferenceSlot, turnContent)}},
|
||||
NPCInteractionReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(NPCInteractionReferenceSlot, interactionContent)}},
|
||||
}}
|
||||
}
|
||||
|
||||
func newReferenceItem(slot string, content []byte) contracts.ReferenceItem {
|
||||
return contracts.ReferenceItem{
|
||||
SlotName: slot,
|
||||
MediaType: "application/json",
|
||||
Content: append([]byte(nil), content...),
|
||||
Digest: digest(content),
|
||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///private/reference.json"},
|
||||
Producer: contracts.ReferenceProducer{PipelineID: "prior", StepID: "extract", LaneID: "lane", ModuleKey: "dnd/example"},
|
||||
}
|
||||
}
|
||||
|
||||
func withoutSlot(set contracts.ReferenceSet, name string) contracts.ReferenceSet {
|
||||
cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(set.Slots)-1)}
|
||||
for slotName, slot := range set.Slots {
|
||||
if slotName != name {
|
||||
cloned.Slots[slotName] = slot
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func replaceSlot(set contracts.ReferenceSet, name string, value contracts.ResolvedReferenceSlot) contracts.ReferenceSet {
|
||||
cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(set.Slots))}
|
||||
for slotName, slot := range set.Slots {
|
||||
cloned.Slots[slotName] = slot
|
||||
}
|
||||
cloned.Slots[name] = value
|
||||
return cloned
|
||||
}
|
||||
|
||||
func replaceItem(set contracts.ReferenceSet, name string, item contracts.ReferenceItem) contracts.ReferenceSet {
|
||||
return replaceSlot(set, name, contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{item}})
|
||||
}
|
||||
|
||||
func combatChunk() *source.Chunk {
|
||||
return &source.Chunk{ID: "combat-scene", Ref: source.SourceRef{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 4}}
|
||||
}
|
||||
|
||||
func digest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
60
internal/modules/dnd/extract/enemyevents/mapping.go
Normal file
60
internal/modules/dnd/extract/enemyevents/mapping.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
type orderedEnemyEvent struct {
|
||||
value dnd.EnemyEvent
|
||||
earliest int
|
||||
hasEvidence bool
|
||||
}
|
||||
|
||||
func canonicalEnemyEventList(response extractionResponse, order shared.SourceRefOrder, sourceID string) dnd.EnemyEventList {
|
||||
if response.Events == nil {
|
||||
return dnd.EnemyEventList{}
|
||||
}
|
||||
ordered := make([]orderedEnemyEvent, len(response.Events))
|
||||
for index, event := range response.Events {
|
||||
refs := order.Canonicalize(sourceRefs(event.SourceRefs, sourceID))
|
||||
earliest, hasEvidence := order.EarliestValid(refs)
|
||||
ordered[index] = orderedEnemyEvent{
|
||||
value: dnd.EnemyEvent{
|
||||
Name: event.Name,
|
||||
Kind: dnd.EnemyEventKind(event.Kind),
|
||||
SourceRefs: refs,
|
||||
},
|
||||
earliest: earliest,
|
||||
hasEvidence: hasEvidence,
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ordered, func(left, right int) bool {
|
||||
if ordered[left].hasEvidence != ordered[right].hasEvidence {
|
||||
return ordered[left].hasEvidence
|
||||
}
|
||||
if !ordered[left].hasEvidence {
|
||||
return false
|
||||
}
|
||||
return ordered[left].earliest < ordered[right].earliest
|
||||
})
|
||||
events := make([]dnd.EnemyEvent, len(ordered))
|
||||
for index := range ordered {
|
||||
events[index] = ordered[index].value
|
||||
}
|
||||
return dnd.EnemyEventList{Events: events}
|
||||
}
|
||||
|
||||
func sourceRefs(refs []enemySourceRefResponse, sourceID string) []source.SourceRef {
|
||||
if refs == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]source.SourceRef, len(refs))
|
||||
for index, ref := range refs {
|
||||
values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||
}
|
||||
return values
|
||||
}
|
||||
16
internal/modules/dnd/extract/enemyevents/model.go
Normal file
16
internal/modules/dnd/extract/enemyevents/model.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package enemyevents
|
||||
|
||||
type extractionResponse struct {
|
||||
Events []enemyEventResponse `json:"events"`
|
||||
}
|
||||
|
||||
type enemyEventResponse struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
SourceRefs []enemySourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type enemySourceRefResponse struct {
|
||||
StartUnitID int `json:"start_unit_id"`
|
||||
EndUnitID int `json:"end_unit_id"`
|
||||
}
|
||||
54
internal/modules/dnd/extract/enemyevents/prompt_assets.go
Normal file
54
internal/modules/dnd/extract/enemyevents/prompt_assets.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package enemyevents
|
||||
|
||||
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 promptAssetRoot = "assets/prompts"
|
||||
|
||||
var promptAssetManifest = shared.PromptAssetManifest{
|
||||
ModuleDir: "dnd.enemy_events",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.enemy_events.yaml", Path: "assets/prompts/dnd.enemy_events.yaml"},
|
||||
{Name: "grounding.md", Path: "assets/prompts/grounding.md"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-extraction-evidence.md",
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-npcs.md",
|
||||
},
|
||||
}
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare enemy-event prompt assets: %w", err)
|
||||
}
|
||||
if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
|
||||
}
|
||||
|
||||
func promptAssetMetadata() (string, error) {
|
||||
promptAssetHashOnce.Do(func() {
|
||||
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||
})
|
||||
return promptAssetHash, promptAssetHashErr
|
||||
}
|
||||
|
||||
var (
|
||||
promptAssetHashOnce sync.Once
|
||||
promptAssetHash string
|
||||
promptAssetHashErr error
|
||||
)
|
||||
123
internal/modules/dnd/extract/enemyevents/prompt_assets_test.go
Normal file
123
internal/modules/dnd/extract/enemyevents/prompt_assets_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
func TestRegisterPromptAssetsAndPrepareEnemyEventPrompt(t *testing.T) {
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schemaFS, err := registry.SchemaFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fs.ReadFile(schemaFS, "dnd_enemy_events_llm.v1.json"); err != nil {
|
||||
t.Fatalf("response schema asset: %v", err)
|
||||
}
|
||||
|
||||
prepared := prepareEnemyEventPrompt(t)
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_enemy_events_llm.v1.json" || prepared.SelectedProfileID != "dnd-extraction" {
|
||||
t.Fatalf("prepared prompt = %#v", prepared)
|
||||
}
|
||||
transcriptIndex := renderedMessageIndex(t, prepared.Messages, "enemy-transcript")
|
||||
for _, sentinel := range []string{"enemy-npc", "enemy-turn", "enemy-opponent", "Extract Dungeons & Dragons enemy events"} {
|
||||
if index := renderedMessageIndex(t, prepared.Messages, sentinel); index <= transcriptIndex {
|
||||
t.Fatalf("message containing %q has index %d, want after transcript index %d", sentinel, index, transcriptIndex)
|
||||
}
|
||||
}
|
||||
instructionIndex := renderedMessageIndex(t, prepared.Messages, "Return the `events` array")
|
||||
if instructionIndex <= transcriptIndex {
|
||||
t.Fatalf("instruction index = %d, want after transcript index %d", instructionIndex, transcriptIndex)
|
||||
}
|
||||
if instructionIndex != len(prepared.Messages)-1 {
|
||||
t.Fatalf("instruction message index = %d, want final message", instructionIndex)
|
||||
}
|
||||
if cache := prepared.Messages[instructionIndex].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
|
||||
t.Fatalf("final instruction cache control = %#v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnemyEventPromptRequiresGroundingInputs(t *testing.T) {
|
||||
engine := newEnemyEventPromptEngine(t)
|
||||
for _, inputName := range []string{"npcs", "combat_turns", "npc_interactions"} {
|
||||
t.Run(inputName, func(t *testing.T) {
|
||||
inputs := enemyEventPromptInputs()
|
||||
delete(inputs, inputName)
|
||||
_, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: PromptID, PromptVersion: SchemaVersion, Inputs: inputs,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), inputName) {
|
||||
t.Fatalf("Prepare() error = %v, want required %q input", err, inputName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func prepareEnemyEventPrompt(t *testing.T) *promptkit.PreparedRun {
|
||||
t.Helper()
|
||||
prepared, err := newEnemyEventPromptEngine(t).Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: PromptID, PromptVersion: SchemaVersion, Inputs: enemyEventPromptInputs(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
func newEnemyEventPromptEngine(t *testing.T) *promptkit.Engine {
|
||||
t.Helper()
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := registry.PromptKitOptions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
||||
ID: "dnd-extraction", Endpoint: "http://127.0.0.1:1/v1", Model: "enemy-test-model",
|
||||
})))
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Timeout: time.Second}, options...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func enemyEventPromptInputs() map[string]promptkit.ArtifactRef {
|
||||
return map[string]promptkit.ArtifactRef{
|
||||
"transcript": promptkit.Inline(`{"value":"enemy-transcript"}`),
|
||||
"players": promptkit.Inline("enemy-player"),
|
||||
"party": promptkit.Inline("enemy-party"),
|
||||
"glossary": promptkit.Inline("enemy-glossary"),
|
||||
"npcs": promptkit.Inline(`{"npcs":[{"name":"enemy-npc"}]}`),
|
||||
"combat_turns": promptkit.Inline(`{"combat_turns":[{"actor":"enemy-turn","turn_kind":"turn"}]}`),
|
||||
"npc_interactions": promptkit.Inline(`{"npc_interactions":[{"name":"enemy-opponent","kind":"combat_opponent"}]}`),
|
||||
}
|
||||
}
|
||||
|
||||
func renderedMessageIndex(t *testing.T, messages []promptkit.RenderedMessage, sentinel string) int {
|
||||
t.Helper()
|
||||
index := -1
|
||||
occurrences := 0
|
||||
for messageIndex, message := range messages {
|
||||
count := strings.Count(message.Content, sentinel)
|
||||
if count > 0 {
|
||||
index = messageIndex
|
||||
occurrences += count
|
||||
}
|
||||
}
|
||||
if occurrences != 1 {
|
||||
t.Fatalf("message sentinel %q rendered %d times, want exactly once", sentinel, occurrences)
|
||||
}
|
||||
return index
|
||||
}
|
||||
21
internal/modules/dnd/extract/enemyevents/schema.go
Normal file
21
internal/modules/dnd/extract/enemyevents/schema.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package enemyevents
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
|
||||
const (
|
||||
PromptID = "dnd.enemy_events"
|
||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_enemy_events_llm")
|
||||
ResponseSchemaID = "notarius.dnd.enemy_events.llm"
|
||||
ResponseSchemaName = "notarius_dnd_enemy_events_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_enemy_events_llm.v1.json",
|
||||
})
|
||||
}
|
||||
97
internal/modules/dnd/extract/enemyevents/schema_test.go
Normal file
97
internal/modules/dnd/extract/enemyevents/schema_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
|
||||
t.Fatalf("schema = %#v", schema)
|
||||
}
|
||||
valid := validEnemyResponse()
|
||||
content, err := json.Marshal(valid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateEnemySchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("valid response rejected: %v", err)
|
||||
}
|
||||
semantic := validEnemyResponse()
|
||||
event := semantic["events"].([]any)[0].(map[string]any)
|
||||
event["name"] = ""
|
||||
event["kind"] = "unsupported"
|
||||
ref := event["source_refs"].([]any)[0].(map[string]any)
|
||||
ref["start_unit_id"] = 0
|
||||
ref["end_unit_id"] = -1
|
||||
content, err = json.Marshal(semantic)
|
||||
if err != nil || validateEnemySchema(content, schema.JSONSchema) != nil {
|
||||
t.Fatalf("validator-owned semantics were rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaRejectsInvalidStructure(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(map[string]any){
|
||||
func(event map[string]any) { delete(event, "name") },
|
||||
func(event map[string]any) { event["kind"] = 1 },
|
||||
func(event map[string]any) { event["unexpected"] = true },
|
||||
func(event map[string]any) { event["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session" },
|
||||
} {
|
||||
candidate := validEnemyResponse()
|
||||
mutate(candidate["events"].([]any)[0].(map[string]any))
|
||||
content, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateEnemySchema(content, schema.JSONSchema); err == nil {
|
||||
t.Fatal("private schema accepted structurally invalid response")
|
||||
}
|
||||
}
|
||||
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("schema defensive copy = %s, %v", second.JSONSchema, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validEnemyResponse() map[string]any {
|
||||
return map[string]any{"events": []any{map[string]any{
|
||||
"name": "Ashfang", "kind": "engaged", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
func validateEnemySchema(instanceContent, schemaContent []byte) error {
|
||||
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
if err := compiler.AddResource("schema.json", document); err != nil {
|
||||
return err
|
||||
}
|
||||
schema, err := compiler.Compile("schema.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return schema.Validate(instance)
|
||||
}
|
||||
297
internal/modules/dnd/normalize/enemyevents/normalizer.go
Normal file
297
internal/modules/dnd/normalize/enemyevents/normalizer.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Package enemyevents normalizes merged D&D enemy-event candidates.
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
|
||||
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/enemy-events"
|
||||
normalizationPolicy = "dnd.enemy_events.normalize.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeNameCanonicalized = "enemy_event_name_canonicalized"
|
||||
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||
ReasonCodeEventsReordered = "enemy_events_reordered"
|
||||
ReasonCodeDuplicateCollapsed = "duplicate_enemy_event_collapsed"
|
||||
ReasonCodeWarningsOmitted = "enemy_event_normalization_warnings_omitted"
|
||||
)
|
||||
|
||||
const (
|
||||
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
|
||||
NPCRegistryMaxBytes = npcregistry.MaxBytes
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{"merged"}
|
||||
var providedCapabilities = []string{"normalized"}
|
||||
|
||||
var _ contracts.Normalizer[dnd.EnemyEventList] = (*Normalizer)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||
|
||||
type Options struct{}
|
||||
|
||||
type Normalizer struct {
|
||||
npcResolver *npcregistry.Resolver
|
||||
}
|
||||
|
||||
func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) {
|
||||
if len(references) > 1 {
|
||||
return nil, normalizerErrorf("at most one reference set may be supplied")
|
||||
}
|
||||
var referenceSet contracts.ReferenceSet
|
||||
if len(references) == 1 {
|
||||
referenceSet = references[0]
|
||||
}
|
||||
resolver, err := npcregistry.NewResolver(referenceSet)
|
||||
if err != nil {
|
||||
return nil, normalizerErrorf("prepare NPC registry: %w", err)
|
||||
}
|
||||
return &Normalizer{npcResolver: resolver}, nil
|
||||
}
|
||||
|
||||
func (n *Normalizer) Key() string { return Key }
|
||||
|
||||
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
|
||||
|
||||
func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
if n == nil || n.npcResolver == nil {
|
||||
return nil
|
||||
}
|
||||
metadata := map[string]any{"normalization_policy": normalizationPolicy}
|
||||
if seeded := n.npcResolver.Seeded(); seeded.Bound() {
|
||||
metadata["npc_registry_digest"] = seeded.Digest()
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if n == nil || n.npcResolver == nil {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.EnemyEventList]) (contracts.TypedNormalizeResult[dnd.EnemyEventList], error) {
|
||||
if n == nil || n.npcResolver == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("normalizer must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||
}
|
||||
registry, err := n.npcResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("resolve NPC registry: %w", err)
|
||||
}
|
||||
if !registry.Bound() {
|
||||
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("NPC registry reference is required")
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
order := shared.NewSourceRefOrderFromIndex(index)
|
||||
value, warnings := normalizeList(req.MergeOutput.Value, order, registry)
|
||||
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{Value: value, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
type normalizedRecord struct {
|
||||
event dnd.EnemyEvent
|
||||
inputIndex int
|
||||
}
|
||||
|
||||
type nameCanonicalization struct {
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEventList, []contracts.Warning) {
|
||||
if input.Events == nil {
|
||||
return dnd.EnemyEventList{}, nil
|
||||
}
|
||||
records := make([]normalizedRecord, len(input.Events))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputEvent := range input.Events {
|
||||
event, nameChange, refsChanged := normalizeEvent(inputEvent, order, registry)
|
||||
records[index] = normalizedRecord{event: event, inputIndex: index}
|
||||
if nameChange != nil {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: eventScope(index),
|
||||
ReasonCode: ReasonCodeNameCanonicalized,
|
||||
Message: fmt.Sprintf("input index %d: subject canonicalized from %s to %s",
|
||||
index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)),
|
||||
})
|
||||
}
|
||||
if refsChanged {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: eventScope(index),
|
||||
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
||||
index, len(inputEvent.SourceRefs), len(event.SourceRefs)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(records, func(left, right int) bool {
|
||||
return enemyeventmodel.Less(order, records[left].event, records[right].event)
|
||||
})
|
||||
for position, record := range records {
|
||||
if position == record.inputIndex {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: eventScope(record.inputIndex),
|
||||
ReasonCode: ReasonCodeEventsReordered,
|
||||
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
||||
})
|
||||
}
|
||||
|
||||
output, duplicateWarnings := collapseDuplicates(records, order)
|
||||
warnings = append(warnings, duplicateWarnings...)
|
||||
return dnd.EnemyEventList{Events: output}, diagnostics.LimitWarnings(warnings, "enemy_events", ReasonCodeWarningsOmitted)
|
||||
}
|
||||
|
||||
func normalizeEvent(input dnd.EnemyEvent, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEvent, *nameCanonicalization, bool) {
|
||||
output := cloneEvent(input)
|
||||
output.Name = enemyeventmodel.NormalizeDisplay(input.Name)
|
||||
if canonical, ok := registry.Lookup(output.Name); ok {
|
||||
output.Name = enemyeventmodel.NormalizeDisplay(canonical.Name)
|
||||
}
|
||||
var nameChange *nameCanonicalization
|
||||
if input.Name != output.Name {
|
||||
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
|
||||
}
|
||||
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||
return output, nameChange, !rawSourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||
}
|
||||
|
||||
func cloneEvent(input dnd.EnemyEvent) dnd.EnemyEvent {
|
||||
output := input
|
||||
if input.SourceRefs != nil {
|
||||
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func rawSourceRefsEqual(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
|
||||
}
|
||||
|
||||
type duplicateGroup struct {
|
||||
retainedIndex int
|
||||
removed []int
|
||||
}
|
||||
|
||||
func collapseDuplicates(records []normalizedRecord, order shared.SourceRefOrder) ([]dnd.EnemyEvent, []contracts.Warning) {
|
||||
if len(records) == 0 {
|
||||
return make([]dnd.EnemyEvent, 0), nil
|
||||
}
|
||||
kept := make([]normalizedRecord, 0, len(records))
|
||||
groups := make([]duplicateGroup, 0)
|
||||
for _, record := range records {
|
||||
match := -1
|
||||
for index := range kept {
|
||||
if enemyeventmodel.ExactEqual(order, kept[index].event, record.event) {
|
||||
match = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if match < 0 {
|
||||
kept = append(kept, record)
|
||||
groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex})
|
||||
continue
|
||||
}
|
||||
groups[match].removed = append(groups[match].removed, record.inputIndex)
|
||||
}
|
||||
output := make([]dnd.EnemyEvent, len(kept))
|
||||
for index, record := range kept {
|
||||
output[index] = cloneEvent(record.event)
|
||||
}
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for _, group := range groups {
|
||||
if len(group.removed) == 0 {
|
||||
continue
|
||||
}
|
||||
issues := make([]string, len(group.removed))
|
||||
for index, removed := range group.removed {
|
||||
issues[index] = fmt.Sprintf("removed input index %d", removed)
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: eventScope(group.retainedIndex),
|
||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||
Message: diagnostics.Aggregate(
|
||||
fmt.Sprintf("duplicate enemy event collapsed; retained input index %d", group.retainedIndex), issues),
|
||||
})
|
||||
}
|
||||
return output, warnings
|
||||
}
|
||||
|
||||
func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) }
|
||||
|
||||
func referenceSlots() []contracts.ReferenceSlot {
|
||||
return contracts.CloneReferenceSlots([]contracts.ReferenceSlot{{
|
||||
Name: NPCRegistryReferenceSlot,
|
||||
Description: "Required normalized NPC registry used only to canonicalize enemy-subject names, never as event evidence.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{"application/json"},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
|
||||
MaxBytes: NPCRegistryMaxBytes,
|
||||
}})
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageNormalize,
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.EnemyEventListKind,
|
||||
ReferenceSlots: referenceSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.NormalizerRegistry) error {
|
||||
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.EnemyEventList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options, request.References)
|
||||
})
|
||||
}
|
||||
|
||||
func DecodeOptions(options map[string]any) (Options, error) {
|
||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||
return Options{}, normalizerErrorf("%w", err)
|
||||
}
|
||||
return Options{}, nil
|
||||
}
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func normalizerErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd enemy events normalizer: "+format, args...)
|
||||
}
|
||||
207
internal/modules/dnd/normalize/enemyevents/normalizer_test.go
Normal file
207
internal/modules/dnd/normalize/enemyevents/normalizer_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package enemyevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
|
||||
func TestNormalizeCanonicalizesSubjectsEvidenceOrderAndDuplicates(t *testing.T) {
|
||||
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}, {ID: 40}}}
|
||||
ref := func(unitID int) []source.SourceRef {
|
||||
return []source.SourceRef{{SourceID: document.ID, StartUnitID: unitID, EndUnitID: unitID}}
|
||||
}
|
||||
input := dnd.EnemyEventList{Events: []dnd.EnemyEvent{
|
||||
{Name: " áRIA ", Kind: dnd.EnemyEventKindKilled, SourceRefs: append(ref(30), ref(10)...)},
|
||||
{Name: " Remaining Orcs ", Kind: dnd.EnemyEventKindEngaged, SourceRefs: ref(20)},
|
||||
{Name: "Ária", Kind: dnd.EnemyEventKindKilled, SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: document.ID, StartUnitID: 30, EndUnitID: 30}}},
|
||||
{Name: "Ária", Kind: dnd.EnemyEventKindFled, SourceRefs: ref(40)},
|
||||
}}
|
||||
originalRefs := append([]source.SourceRef(nil), input.Events[0].SourceRefs...)
|
||||
normalizer := newNormalizer(t, npcReferences(t))
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := result.Value.Events
|
||||
if len(got) != 3 || got[0].Name != "Ária" || got[0].Kind != dnd.EnemyEventKindKilled || !reflect.DeepEqual(got[0].SourceRefs, []source.SourceRef{{SourceID: document.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: document.ID, StartUnitID: 10, EndUnitID: 10}}) || got[1].Name != "Remaining Orcs" || got[2].Kind != dnd.EnemyEventKindFled {
|
||||
t.Fatalf("normalized events = %#v", got)
|
||||
}
|
||||
for _, reason := range []string{ReasonCodeNameCanonicalized, ReasonCodeSourceRefsNormalized, ReasonCodeEventsReordered, ReasonCodeDuplicateCollapsed} {
|
||||
if !hasWarning(result.Warnings, reason) {
|
||||
t.Fatalf("warnings = %#v, missing %q", result.Warnings, reason)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(input.Events[0].SourceRefs, originalRefs) {
|
||||
t.Fatalf("Normalize() mutated input: %#v", input)
|
||||
}
|
||||
got[0].SourceRefs[0].StartUnitID = 999
|
||||
if input.Events[0].SourceRefs[0].StartUnitID == 999 {
|
||||
t.Fatal("normalized source references alias input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUsesExplicitKindOrderAndPreservesDistinctObservations(t *testing.T) {
|
||||
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
|
||||
ref := func(unitID int) []source.SourceRef {
|
||||
return []source.SourceRef{{SourceID: document.ID, StartUnitID: unitID, EndUnitID: unitID}}
|
||||
}
|
||||
input := dnd.EnemyEventList{Events: []dnd.EnemyEvent{
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindKilled, SourceRefs: ref(1)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: ref(1)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindCaptured, SourceRefs: ref(1)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindIncapacitated, SourceRefs: ref(1)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: ref(1)},
|
||||
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: ref(3)},
|
||||
}}
|
||||
result, err := newNormalizer(t, npcReferences(t)).Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []dnd.EnemyEventKind{
|
||||
dnd.EnemyEventKindEngaged,
|
||||
dnd.EnemyEventKindIncapacitated,
|
||||
dnd.EnemyEventKindCaptured,
|
||||
dnd.EnemyEventKindFled,
|
||||
dnd.EnemyEventKindKilled,
|
||||
dnd.EnemyEventKindEngaged,
|
||||
}
|
||||
for index, kind := range want {
|
||||
if result.Value.Events[index].Kind != kind {
|
||||
t.Fatalf("event %d kind = %q, want %q", index, result.Value.Events[index].Kind, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIsIdempotentAndPreservesEmptyRepresentation(t *testing.T) {
|
||||
normalizer := newNormalizer(t, npcReferences(t))
|
||||
for _, input := range []dnd.EnemyEventList{{}, {Events: []dnd.EnemyEvent{}}} {
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), input, contracts.ReferenceSet{}))
|
||||
if err != nil || (result.Value.Events == nil) != (input.Events == nil) {
|
||||
t.Fatalf("Normalize() = %#v, %v for %#v", result, err, input)
|
||||
}
|
||||
}
|
||||
input := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: " Remaining orcs ", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}
|
||||
first, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), input, contracts.ReferenceSet{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), first.Value, contracts.ReferenceSet{}))
|
||||
if err != nil || !reflect.DeepEqual(second.Value, first.Value) || len(second.Warnings) != 0 {
|
||||
t.Fatalf("second normalization = %#v, %v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequiresRegistryAndKeepsOperationContentOutOfMetadata(t *testing.T) {
|
||||
normalizer, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), dnd.EnemyEventList{}, contracts.ReferenceSet{})); err == nil || !strings.Contains(err.Error(), "NPC registry") {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
operationReferences := npcReferences(t)
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}, operationReferences))
|
||||
if err != nil || result.Value.Events == nil {
|
||||
t.Fatalf("Normalize() = %#v, %v", result, err)
|
||||
}
|
||||
if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil {
|
||||
t.Fatalf("operation registry leaked into metadata: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizerContractAndWarningBound(t *testing.T) {
|
||||
normalizer := newNormalizer(t, npcReferences(t))
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() accepted an unknown option")
|
||||
}
|
||||
first := ModuleSpec()
|
||||
first.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
||||
second := ModuleSpec()
|
||||
if second.Key != Key || second.Stage != pipeline.StageNormalize || second.ExecutionClass != contracts.ExecutionClassDeterministic || second.ArtifactKind != dnd.EnemyEventListKind || second.ReferenceSlots[0].AcceptedMediaTypes[0] != "application/json" {
|
||||
t.Fatalf("ModuleSpec() = %#v", second)
|
||||
}
|
||||
registry := pipeline.NewNormalizerRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec, ok := registry.Spec(Key); !ok || spec.ArtifactKind != dnd.EnemyEventListKind {
|
||||
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||
}
|
||||
metadata := normalizer.ManifestMetadata()
|
||||
if metadata["normalization_policy"] != normalizationPolicy || !strings.HasPrefix(metadata["npc_registry_digest"].(string), "sha256:") {
|
||||
t.Fatalf("metadata = %#v", metadata)
|
||||
}
|
||||
encoded, err := json.Marshal(metadata)
|
||||
if err != nil || strings.Contains(string(encoded), "Ária") {
|
||||
t.Fatalf("unsafe metadata = %s, %v", encoded, err)
|
||||
}
|
||||
|
||||
count := diagnostics.MaxWarnings + 5
|
||||
document := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
||||
input := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, count)}
|
||||
for index := range document.Units {
|
||||
document.Units[index].ID = index + 1
|
||||
input.Events[index] = dnd.EnemyEvent{Name: " ÁRIA ", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: count - index, EndUnitID: count - index}}}
|
||||
}
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{}))
|
||||
if err != nil || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted {
|
||||
t.Fatalf("warnings = %#v, %v", result.Warnings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newNormalizer(t *testing.T, references contracts.ReferenceSet) *Normalizer {
|
||||
t.Helper()
|
||||
normalizer, err := New(Options{}, references)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return normalizer
|
||||
}
|
||||
|
||||
func normalizeRequest(document *source.SourceDocument, value dnd.EnemyEventList, references contracts.ReferenceSet) contracts.TypedNormalizeRequest[dnd.EnemyEventList] {
|
||||
return contracts.TypedNormalizeRequest[dnd.EnemyEventList]{
|
||||
Source: document,
|
||||
MergeOutput: contracts.MergeArtifact[dnd.EnemyEventList]{Value: value},
|
||||
References: references,
|
||||
}
|
||||
}
|
||||
|
||||
func testDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
|
||||
}
|
||||
|
||||
func npcReferences(t *testing.T) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
value := dnd.NPCList{NPCs: []dnd.NPC{{
|
||||
ID: identity.DeriveID("Ária"), Name: "Ária",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
content, err := npccodec.New().Encode(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {
|
||||
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
|
||||
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
|
||||
}}}
|
||||
}
|
||||
|
||||
func hasWarning(warnings []contracts.Warning, reason string) bool {
|
||||
for _, warning := range warnings {
|
||||
if warning.ReasonCode == reason {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package register
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
@@ -18,6 +20,11 @@ import (
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||
enemyeventengagements "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/engagements"
|
||||
enemyeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/invariants"
|
||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
|
||||
enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness"
|
||||
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
|
||||
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
||||
itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs"
|
||||
@@ -127,6 +134,34 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "enemy events validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: enemyeventextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(enemyeventshape.Key),
|
||||
pipeline.Binding(enemyeventengagements.Key),
|
||||
pipeline.Binding(enemyeventrefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(enemyeventrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "enemy events normalize validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: enemyeventnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(enemyeventshape.Key),
|
||||
pipeline.Binding(enemyeventinvariants.Key),
|
||||
pipeline.Binding(enemyeventrefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(enemyeventrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "item events validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
|
||||
@@ -13,6 +13,9 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
|
||||
{name: "combat turns evidence", register: func() error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.CombatTurnListKind, combatTurnEvidence)
|
||||
}},
|
||||
{name: "enemy events evidence", register: func() error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence)
|
||||
}},
|
||||
{name: "item events evidence", register: func() error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
||||
}},
|
||||
@@ -49,6 +52,14 @@ func combatTurnEvidence(value dnd.CombatTurnList) []source.SourceRef {
|
||||
return append([]source.SourceRef(nil), refs...)
|
||||
}
|
||||
|
||||
func enemyEventEvidence(value dnd.EnemyEventList) []source.SourceRef {
|
||||
var refs []source.SourceRef
|
||||
for _, record := range value.Events {
|
||||
refs = append(refs, record.SourceRefs...)
|
||||
}
|
||||
return append([]source.SourceRef(nil), refs...)
|
||||
}
|
||||
|
||||
func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
|
||||
var refs []source.SourceRef
|
||||
for _, record := range value.Events {
|
||||
|
||||
@@ -70,6 +70,27 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendEnemyEventLists(values []dnd.EnemyEventList) (dnd.EnemyEventList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.Events != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.Events)
|
||||
}
|
||||
if !present {
|
||||
return dnd.EnemyEventList{}, nil
|
||||
}
|
||||
combined := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 0, count)}
|
||||
for _, value := range values {
|
||||
for _, event := range value.Events {
|
||||
combined.Events = append(combined.Events, cloneEnemyEvent(event))
|
||||
}
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
@@ -137,6 +158,12 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneEnemyEvent(value dnd.EnemyEvent) dnd.EnemyEvent {
|
||||
clone := value
|
||||
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent {
|
||||
clone := value
|
||||
if value.Quantity != nil {
|
||||
|
||||
@@ -6,18 +6,21 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents"
|
||||
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
||||
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
@@ -33,6 +36,7 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
||||
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
||||
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
||||
{name: "enemy events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, enemyeventcodec.New()) }},
|
||||
{name: "item events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }},
|
||||
{name: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }},
|
||||
{name: "scene descriptions codec", register: func() error {
|
||||
@@ -42,6 +46,7 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
|
||||
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
||||
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
||||
{name: "enemy events extractor", register: func() error { return enemyeventextract.Register(registries.Extractors) }},
|
||||
{name: "item events extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }},
|
||||
{name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }},
|
||||
{name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }},
|
||||
@@ -54,6 +59,9 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "combat-turn-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
||||
}},
|
||||
{name: "enemy-event-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists)
|
||||
}},
|
||||
{name: "item-event-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
|
||||
}},
|
||||
@@ -66,6 +74,7 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
||||
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
||||
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
||||
{name: "enemy events normalizer", register: func() error { return enemyeventnormalize.Register(registries.Normalizers) }},
|
||||
{name: "item events normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }},
|
||||
{name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }},
|
||||
{name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }},
|
||||
@@ -78,6 +87,9 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "combat-turn-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "enemy-event-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind)
|
||||
}},
|
||||
{name: "item-event-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
|
||||
}},
|
||||
@@ -97,6 +109,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error {
|
||||
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
|
||||
{name: "npc normalization prompt assets", register: func() error { return npcnormalize.RegisterPromptAssets(assets) }},
|
||||
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
|
||||
{name: "enemy events prompt assets", register: func() error { return enemyeventextract.RegisterPromptAssets(assets) }},
|
||||
{name: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }},
|
||||
{name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }},
|
||||
{name: "scene descriptions prompt assets", register: func() error { return scenedescriptionextract.RegisterPromptAssets(assets) }},
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
@@ -42,8 +43,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
||||
promptID string
|
||||
promptVersion string
|
||||
inputs map[string]promptkit.ArtifactRef
|
||||
npcInput bool
|
||||
spellCatalogInput bool
|
||||
inputSentinels []string
|
||||
}{
|
||||
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs},
|
||||
{name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: commonInputs},
|
||||
@@ -55,7 +55,18 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
||||
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
||||
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
||||
}),
|
||||
npcInput: true,
|
||||
inputSentinels: []string{npcSentinel},
|
||||
},
|
||||
{
|
||||
name: "enemy events",
|
||||
promptID: enemyeventextract.PromptID,
|
||||
promptVersion: enemyeventextract.SchemaVersion,
|
||||
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
||||
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
||||
"combat_turns": promptkit.Inline(`{"sentinel":"combat-turns-sentinel"}`),
|
||||
"npc_interactions": promptkit.Inline(`{"sentinel":"npc-interactions-sentinel"}`),
|
||||
}),
|
||||
inputSentinels: []string{npcSentinel, "combat-turns-sentinel", "npc-interactions-sentinel"},
|
||||
},
|
||||
{
|
||||
name: "npc interactions",
|
||||
@@ -64,7 +75,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
||||
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
||||
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
||||
}),
|
||||
npcInput: true,
|
||||
inputSentinels: []string{npcSentinel},
|
||||
},
|
||||
{
|
||||
name: "spells",
|
||||
@@ -74,8 +85,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
||||
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
||||
"spell_catalog": promptkit.Inline(`{"sentinel":"` + catalogSentinel + `"}`),
|
||||
}),
|
||||
npcInput: true,
|
||||
spellCatalogInput: true,
|
||||
inputSentinels: []string{npcSentinel, catalogSentinel},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -93,9 +103,6 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
||||
}
|
||||
transcriptIndex := renderedInputMessageIndex(t, prepared.Messages, transcriptSentinel)
|
||||
prefix := prepared.Messages[:transcriptIndex+1]
|
||||
if len(prefix) != 4 {
|
||||
t.Fatalf("messages through transcript = %d, want 4", len(prefix))
|
||||
}
|
||||
if len(prepared.Messages) <= len(prefix) {
|
||||
t.Fatalf("prepared prompt has %d messages, want lane-specific suffix after transcript", len(prepared.Messages))
|
||||
}
|
||||
@@ -104,11 +111,8 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
||||
} else if !reflect.DeepEqual(prefix, sharedPrefix) {
|
||||
t.Fatalf("rendered prefix = %#v, want %#v", prefix, sharedPrefix)
|
||||
}
|
||||
if testCase.npcInput {
|
||||
assertRenderedInputAfter(t, prepared.Messages, npcSentinel, transcriptIndex)
|
||||
}
|
||||
if testCase.spellCatalogInput {
|
||||
assertRenderedInputAfter(t, prepared.Messages, catalogSentinel, transcriptIndex)
|
||||
for _, sentinel := range testCase.inputSentinels {
|
||||
assertRenderedInputAfter(t, prepared.Messages, sentinel, transcriptIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
@@ -44,6 +46,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
"dnd.spells/dnd.spells.yaml",
|
||||
"dnd.npcs/dnd.npcs.yaml",
|
||||
"dnd.combat_turns/dnd.combat_turns.yaml",
|
||||
"dnd.enemy_events/dnd.enemy_events.yaml",
|
||||
"dnd.item_events/dnd.item_events.yaml",
|
||||
"dnd.npc_interactions/dnd.npc_interactions.yaml",
|
||||
"dnd.scene_descriptions/dnd.scene_descriptions.yaml",
|
||||
@@ -72,14 +75,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
t.Fatalf("normalization schema asset = %v, want registered private schema", err)
|
||||
}
|
||||
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
||||
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key})
|
||||
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule})
|
||||
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key})
|
||||
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule})
|
||||
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
|
||||
@@ -96,6 +100,11 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
"extract/dnd/combat-turns/source_refs",
|
||||
"extract/dnd/combat-turns/source_relatedness",
|
||||
"normalize/dnd/combat-turns/invariants",
|
||||
"extract/dnd/enemy-events/shape",
|
||||
"extract/dnd/enemy-events/engagements",
|
||||
"extract/dnd/enemy-events/source_refs",
|
||||
"extract/dnd/enemy-events/source_relatedness",
|
||||
"normalize/dnd/enemy-events/invariants",
|
||||
"extract/dnd/item-events/shape",
|
||||
"extract/dnd/item-events/source_refs",
|
||||
"extract/dnd/item-events/source_relatedness",
|
||||
@@ -168,6 +177,28 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
|
||||
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
|
||||
}
|
||||
enemyEventExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/shape"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/engagements"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/source_relatedness"),
|
||||
}
|
||||
enemyEventNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/shape"),
|
||||
pipeline.Binding("normalize/dnd/enemy-events/invariants"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/enemy-events/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, enemyeventextract.Key); !reflect.DeepEqual(got, enemyEventExtractChain) {
|
||||
t.Fatalf("enemy event extract validator chain = %#v, want %#v", got, enemyEventExtractChain)
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, enemyeventnormalize.Key); !reflect.DeepEqual(got, enemyEventNormalizeChain) {
|
||||
t.Fatalf("enemy event normalize validator chain = %#v, want %#v", got, enemyEventNormalizeChain)
|
||||
}
|
||||
itemEventExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/item-events/shape"),
|
||||
@@ -264,6 +295,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
"dnd.combat_turns/sharedassets/common-dnd-system.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.combat_turns/task.md",
|
||||
"dnd.enemy_events/dnd.enemy_events.yaml",
|
||||
"dnd.enemy_events/grounding.md",
|
||||
"dnd.enemy_events/instructions.md",
|
||||
"dnd.enemy_events/task.md",
|
||||
"dnd.item_events/dnd.item_events.yaml",
|
||||
"dnd.item_events/instructions.md",
|
||||
"dnd.item_events/sharedassets/common-dnd-extraction-evidence.md",
|
||||
@@ -289,6 +324,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
"dnd_spells_llm.v1.json",
|
||||
"dnd_npcs_llm.v1.json",
|
||||
"dnd_combat_turns_llm.v1.json",
|
||||
"dnd_enemy_events_llm.v1.json",
|
||||
"dnd_item_events_llm.v1.json",
|
||||
"dnd_npc_interactions_llm.v1.json",
|
||||
"dnd_scene_descriptions_llm.v1.json",
|
||||
@@ -314,6 +350,19 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize {
|
||||
t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok)
|
||||
}
|
||||
enemyEventExtractSpec, enemyEventExtractOK := registries.Extractors.Spec(enemyeventextract.Key)
|
||||
enemyEventNormalizeSpec, enemyEventNormalizeOK := registries.Normalizers.Spec(enemyeventnormalize.Key)
|
||||
if !enemyEventExtractOK || enemyEventExtractSpec.ArtifactKind != dnd.EnemyEventListKind || !enemyEventNormalizeOK || enemyEventNormalizeSpec.ArtifactKind != dnd.EnemyEventListKind || enemyEventNormalizeSpec.Stage != pipeline.StageNormalize {
|
||||
t.Fatalf("enemy event specs = %#v / %#v, present = %t / %t", enemyEventExtractSpec, enemyEventNormalizeSpec, enemyEventExtractOK, enemyEventNormalizeOK)
|
||||
}
|
||||
if len(enemyEventExtractSpec.ReferenceSlots) != 8 || len(enemyEventNormalizeSpec.ReferenceSlots) != 1 {
|
||||
t.Fatalf("enemy event reference slots = %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots)
|
||||
}
|
||||
npcRegistrySlot := referenceSlot(enemyEventExtractSpec.ReferenceSlots, "npcs")
|
||||
enemyNormalizeRegistrySlot := referenceSlot(enemyEventNormalizeSpec.ReferenceSlots, "npcs")
|
||||
if !npcRegistrySlot.Required || !enemyNormalizeRegistrySlot.Required || npcRegistrySlot.MaxBytes != enemyNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(npcRegistrySlot.AcceptedMediaTypes, enemyNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(npcRegistrySlot.AcceptedArtifactKinds, enemyNormalizeRegistrySlot.AcceptedArtifactKinds) {
|
||||
t.Fatalf("enemy-event NPC registry slots disagree: %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots)
|
||||
}
|
||||
itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key)
|
||||
itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key)
|
||||
if !itemEventExtractOK || itemEventExtractSpec.ArtifactKind != dnd.ItemEventListKind || !itemEventNormalizeOK || itemEventNormalizeSpec.ArtifactKind != dnd.ItemEventListKind || itemEventNormalizeSpec.Stage != pipeline.StageNormalize || len(itemEventNormalizeSpec.ReferenceSlots) != 0 {
|
||||
@@ -361,6 +410,9 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes
|
||||
{name: "combat turns", project: func() []source.SourceRef {
|
||||
return combatTurnEvidence(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||
}, want: []source.SourceRef{first, second}},
|
||||
{name: "enemy events", project: func() []source.SourceRef {
|
||||
return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||
}, want: []source.SourceRef{first, second}},
|
||||
{name: "item events", project: func() []source.SourceRef {
|
||||
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||
}, want: []source.SourceRef{first, second}},
|
||||
@@ -536,6 +588,30 @@ func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendEnemyEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
||||
input := []dnd.EnemyEventList{
|
||||
{},
|
||||
{Events: []dnd.EnemyEvent{}},
|
||||
{Events: []dnd.EnemyEvent{{Name: "first", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs}}},
|
||||
{Events: []dnd.EnemyEvent{{Name: "second", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}},
|
||||
}
|
||||
got, err := appendEnemyEventLists(input)
|
||||
if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) {
|
||||
t.Fatalf("appendEnemyEventLists() = %#v, error = %v", got, err)
|
||||
}
|
||||
got.Events[0].SourceRefs[0].StartUnitID = 999
|
||||
if input[2].Events[0].SourceRefs[0].StartUnitID != 1 {
|
||||
t.Fatal("merged enemy events share source-reference storage")
|
||||
}
|
||||
for _, values := range [][]dnd.EnemyEventList{nil, []dnd.EnemyEventList{{}, {}}} {
|
||||
result, err := appendEnemyEventLists(values)
|
||||
if err != nil || result.Events != nil {
|
||||
t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||
quantity := 3
|
||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
||||
@@ -578,6 +654,11 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) {
|
||||
t.Fatalf("appendCombatTurnLists() = %#v, %v; want present-empty source refs", turns, err)
|
||||
}
|
||||
|
||||
enemyEvents, err := appendEnemyEventLists([]dnd.EnemyEventList{{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{}}}}})
|
||||
if err != nil || enemyEvents.Events[0].SourceRefs == nil {
|
||||
t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err)
|
||||
}
|
||||
|
||||
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}})
|
||||
if err != nil || interactions.Interactions[0].SourceRefs == nil {
|
||||
t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err)
|
||||
|
||||
@@ -7,6 +7,11 @@ import (
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||
enemyeventengagements "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/engagements"
|
||||
enemyeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/invariants"
|
||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
|
||||
enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness"
|
||||
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
|
||||
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
||||
itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs"
|
||||
@@ -46,6 +51,11 @@ func registerValidators(registries pipeline.Registries) error {
|
||||
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
|
||||
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
|
||||
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
|
||||
{name: "enemy event shape validator", register: func() error { return enemyeventshape.Register(registries.Validators) }},
|
||||
{name: "enemy event engagement validator", register: func() error { return enemyeventengagements.Register(registries.Validators) }},
|
||||
{name: "enemy event source references validator", register: func() error { return enemyeventrefs.Register(registries.Validators) }},
|
||||
{name: "enemy event source relatedness validator", register: func() error { return enemyeventrelatedness.Register(registries.Validators) }},
|
||||
{name: "enemy event normalized invariants validator", register: func() error { return enemyeventinvariants.Register(registries.Validators) }},
|
||||
{name: "item event shape validator", register: func() error { return itemeventshape.Register(registries.Validators) }},
|
||||
{name: "item event source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }},
|
||||
{name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }},
|
||||
@@ -77,6 +87,12 @@ func registerValidators(registries pipeline.Registries) error {
|
||||
{name: "combat-turn-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "enemy-event-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
|
||||
}},
|
||||
{name: "enemy-event-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
|
||||
}},
|
||||
{name: "item-event-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
||||
}},
|
||||
|
||||
@@ -18,6 +18,8 @@ const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-l
|
||||
|
||||
const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list"
|
||||
|
||||
const EnemyEventListKind contracts.ArtifactKind = "dnd/enemy-event-list"
|
||||
|
||||
type SpellList struct {
|
||||
SpellCasts []SpellCast `json:"spell_casts"`
|
||||
}
|
||||
@@ -122,3 +124,23 @@ type ItemEvent struct {
|
||||
To string `json:"to,omitempty"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type EnemyEventKind string
|
||||
|
||||
const (
|
||||
EnemyEventKindEngaged EnemyEventKind = "engaged"
|
||||
EnemyEventKindKilled EnemyEventKind = "killed"
|
||||
EnemyEventKindFled EnemyEventKind = "fled"
|
||||
EnemyEventKindCaptured EnemyEventKind = "captured"
|
||||
EnemyEventKindIncapacitated EnemyEventKind = "incapacitated"
|
||||
)
|
||||
|
||||
type EnemyEventList struct {
|
||||
Events []EnemyEvent `json:"events"`
|
||||
}
|
||||
|
||||
type EnemyEvent struct {
|
||||
Name string `json:"name"`
|
||||
Kind EnemyEventKind `json:"kind"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package engagements prevents duplicate enemy engagements within one scene.
|
||||
package engagements
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/enemy-events/engagements"
|
||||
ReasonCode = "duplicate_enemy_engagement"
|
||||
policy = "dnd.enemy_events.validator.engagements.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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.EnemyEventList]) (contracts.ValidationResult, error) {
|
||||
if enemyeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, event := range req.Value.Events {
|
||||
if event.Kind != dnd.EnemyEventKindEngaged {
|
||||
continue
|
||||
}
|
||||
identity := enemyeventmodel.ComparisonKey(event.Name)
|
||||
if identity == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := seen[identity]; found {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("duplicate enemy engagement", []string{
|
||||
fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)),
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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,77 @@
|
||||
package engagements
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestValidatorEnforcesOneEngagementPerSubject(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value dnd.EnemyEventList
|
||||
approved bool
|
||||
}{
|
||||
{name: "empty", value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}, approved: true},
|
||||
{name: "one engagement", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1)), approved: true},
|
||||
{name: "same subject different evidence", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Ashfang", dnd.EnemyEventKindEngaged, 2))},
|
||||
{name: "whitespace and case variant", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event(" ASHFANG ", dnd.EnemyEventKindEngaged, 2))},
|
||||
{name: "different subjects", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Briar", dnd.EnemyEventKindEngaged, 2)), approved: true},
|
||||
{name: "different kinds", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Ashfang", dnd.EnemyEventKindFled, 2)), approved: true},
|
||||
{name: "malformed shape", value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}, {Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(2)}}}, approved: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: test.value})
|
||||
if err != nil || result.Approved != test.approved {
|
||||
t.Fatalf("Validate() = %#v, %v; want approved=%t", result, err, test.approved)
|
||||
}
|
||||
if !test.approved && result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("reason code = %q, want %q", result.ReasonCode, ReasonCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
validator := New(Options{})
|
||||
for _, unitID := range []int{1, 2} {
|
||||
value := events(event("Ashfang", dnd.EnemyEventKindEngaged, unitID))
|
||||
before := events(event("Ashfang", dnd.EnemyEventKindEngaged, unitID))
|
||||
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("separate scene validation = %#v, %v; want approved", result, err)
|
||||
}
|
||||
if !reflect.DeepEqual(value, before) {
|
||||
t.Fatalf("Validate() mutated separate scene value: got %#v, want %#v", value, before)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRegistersStrictOptionsAndPolicy(t *testing.T) {
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v", Spec())
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v", got)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("DecodeOptions() error = %v, want strict option rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func events(values ...dnd.EnemyEvent) dnd.EnemyEventList { return dnd.EnemyEventList{Events: values} }
|
||||
|
||||
func event(name string, kind dnd.EnemyEventKind, unitID int) dnd.EnemyEvent {
|
||||
return dnd.EnemyEvent{Name: name, Kind: kind, SourceRefs: refs(unitID)}
|
||||
}
|
||||
|
||||
func refs(unitID int) []source.SourceRef {
|
||||
return []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package invariants validates normalized D&D enemy-event artifacts.
|
||||
package invariants
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
|
||||
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"
|
||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "normalize/dnd/enemy-events/invariants"
|
||||
ReasonCode = "invalid_enemy_event_normalization"
|
||||
policy = "dnd.enemy_events.validator.normalized.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
|
||||
type Validator struct {
|
||||
npcResolver *npcregistry.Resolver
|
||||
}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*Validator)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
|
||||
if len(references) > 1 {
|
||||
return nil, fmt.Errorf("enemy event invariants validator accepts at most one reference set")
|
||||
}
|
||||
var referenceSet contracts.ReferenceSet
|
||||
if len(references) == 1 {
|
||||
referenceSet = references[0]
|
||||
}
|
||||
resolver, err := npcregistry.NewResolver(referenceSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare NPC registry: %w", err)
|
||||
}
|
||||
return &Validator{npcResolver: resolver}, nil
|
||||
}
|
||||
|
||||
func (v *Validator) Name() string { return Key }
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
|
||||
func (v *Validator) ManifestMetadata() map[string]any {
|
||||
if v == nil || v.npcResolver == nil {
|
||||
return nil
|
||||
}
|
||||
metadata := map[string]any{"policy": policy}
|
||||
if seeded := v.npcResolver.Seeded(); seeded.Bound() {
|
||||
metadata["npc_registry_digest"] = seeded.Digest()
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if v == nil || v.npcResolver == nil {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "policy", Value: policy},
|
||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
|
||||
if enemyeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
if !allSourceRefsValid(index, req.Value) {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
if v == nil || v.npcResolver == nil {
|
||||
return contracts.ValidationResult{}, fmt.Errorf("enemy event invariants validator must not be nil")
|
||||
}
|
||||
registry, err := v.npcResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
|
||||
}
|
||||
if !registry.Bound() {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid enemy event normalization: NPC registry reference is required"}, nil
|
||||
}
|
||||
issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), req.Value, registry)
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event normalization", issues)}, nil
|
||||
}
|
||||
|
||||
func allSourceRefsValid(index source.DocumentIndex, value dnd.EnemyEventList) bool {
|
||||
for _, event := range value.Events {
|
||||
for _, ref := range event.SourceRefs {
|
||||
if index.ValidateRef(ref) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func issuesFor(order shared.SourceRefOrder, value dnd.EnemyEventList, registry *npcregistry.Registry) []string {
|
||||
issues := make([]string, 0)
|
||||
for eventIndex, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
||||
if normalized := enemyeventmodel.NormalizeDisplay(event.Name); event.Name != normalized {
|
||||
issues = append(issues, prefix+".name is not whitespace-normalized: "+diagnostics.Quote(event.Name))
|
||||
}
|
||||
if canonical, ok := registry.Lookup(event.Name); ok && event.Name != canonical.Name {
|
||||
issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(event.Name))
|
||||
}
|
||||
for refIndex := 1; refIndex < len(event.SourceRefs); refIndex++ {
|
||||
previous := event.SourceRefs[refIndex-1]
|
||||
current := event.SourceRefs[refIndex]
|
||||
if order.Less(current, previous) {
|
||||
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
||||
} else if current == previous {
|
||||
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sort.SliceIsSorted(value.Events, func(left, right int) bool {
|
||||
return enemyeventmodel.Less(order, value.Events[left], value.Events[right])
|
||||
}) {
|
||||
issues = append(issues, "events are not in canonical order")
|
||||
}
|
||||
for eventIndex, event := range value.Events {
|
||||
for previousIndex := 0; previousIndex < eventIndex; previousIndex++ {
|
||||
if enemyeventmodel.ExactEqual(order, value.Events[previousIndex], event) {
|
||||
issues = append(issues, fmt.Sprintf("events[%d] duplicates event %d", eventIndex, previousIndex))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options, request.References)
|
||||
})
|
||||
}
|
||||
|
||||
func DecodeOptions(options map[string]any) (Options, error) {
|
||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||
return Options{}, err
|
||||
}
|
||||
return Options{}, nil
|
||||
}
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
@@ -0,0 +1,145 @@
|
||||
package invariants
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||
)
|
||||
|
||||
func TestValidatorApprovesNormalizedEventsAndGenericGroups(t *testing.T) {
|
||||
references := registryReferences(t, "Ária")
|
||||
value := normalizedList()
|
||||
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsNormalizationDrift(t *testing.T) {
|
||||
references := registryReferences(t, "Ária")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.EnemyEventList)
|
||||
want string
|
||||
}{
|
||||
{"registry display", func(value *dnd.EnemyEventList) { value.Events[0].Name = " ária " }, "canonical NPC display name"},
|
||||
{"generic whitespace", func(value *dnd.EnemyEventList) { value.Events[1].Name = " Remaining\tOrcs " }, "whitespace-normalized"},
|
||||
{"evidence order", func(value *dnd.EnemyEventList) {
|
||||
value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
}, "not in canonical order"},
|
||||
{"duplicate evidence", func(value *dnd.EnemyEventList) {
|
||||
value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0])
|
||||
}, "duplicates the previous reference"},
|
||||
{"event order", func(value *dnd.EnemyEventList) { value.Events[0], value.Events[1] = value.Events[1], value.Events[0] }, "events are not in canonical order"},
|
||||
{"exact duplicate", func(value *dnd.EnemyEventList) { value.Events = append(value.Events, value.Events[0]) }, "duplicates event"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
value := normalizedList()
|
||||
test.mutate(&value)
|
||||
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
|
||||
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersEarlierFailuresAndResolvesOperationRegistryWithoutMutation(t *testing.T) {
|
||||
references := registryReferences(t, "Ária")
|
||||
for _, value := range []dnd.EnemyEventList{
|
||||
{Events: []dnd.EnemyEvent{{Name: "Ária"}}},
|
||||
{Events: []dnd.EnemyEvent{{Name: "Ária", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
|
||||
} {
|
||||
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("deferral = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
value := normalizedList()
|
||||
before := cloneList(value)
|
||||
result, err := newValidator(t).Validate(context.Background(), request(references, value))
|
||||
if err != nil || !result.Approved || !reflect.DeepEqual(value, before) {
|
||||
t.Fatalf("operation registry = %#v, %v; value=%#v", result, err, value)
|
||||
}
|
||||
result, err = newValidator(t).Validate(context.Background(), request(contracts.ReferenceSet{}, normalizedList()))
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "required") {
|
||||
t.Fatalf("unbound registry = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorMetadataFingerprintsAndRegistrationAreSafe(t *testing.T) {
|
||||
references := registryReferences(t, "Ária")
|
||||
metadata, err := json.Marshal(newValidator(t, references).ManifestMetadata())
|
||||
if err != nil || strings.Contains(string(metadata), "Ária") {
|
||||
t.Fatalf("ManifestMetadata() = %s, %v", metadata, err)
|
||||
}
|
||||
if got := newValidator(t, references).CheckpointFingerprints(); len(got) != 2 || got[0].Value != policy || !strings.HasPrefix(got[1].Value, "sha256:") {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v", got)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v", Spec())
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() accepted unknown option")
|
||||
}
|
||||
}
|
||||
|
||||
func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator {
|
||||
t.Helper()
|
||||
validator, err := New(Options{}, references...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return validator
|
||||
}
|
||||
|
||||
func request(references contracts.ReferenceSet, value dnd.EnemyEventList) contracts.TypedValidationRequest[dnd.EnemyEventList] {
|
||||
return contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), References: references, Value: value}
|
||||
}
|
||||
|
||||
func document() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
|
||||
}
|
||||
|
||||
func normalizedList() dnd.EnemyEventList {
|
||||
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{
|
||||
{Name: "Ária", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
{Name: "Remaining Orcs", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList {
|
||||
clone := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, len(value.Events))}
|
||||
for index, event := range value.Events {
|
||||
clone.Events[index] = event
|
||||
clone.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
npcs := make([]dnd.NPC, len(names))
|
||||
for index, name := range names {
|
||||
npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: index + 1, EndUnitID: index + 1}}}
|
||||
}
|
||||
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{npcregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}}}}}
|
||||
}
|
||||
93
internal/modules/dnd/validate/enemyevents/shape/validator.go
Normal file
93
internal/modules/dnd/validate/enemyevents/shape/validator.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Package shape validates the candidate shape of D&D enemy-event artifacts.
|
||||
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"
|
||||
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/enemy-events/shape"
|
||||
ReasonCode = "invalid_enemy_event_shape"
|
||||
policy = "dnd.enemy_events.validator.shape.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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.EnemyEventList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.EnemyEventList) error {
|
||||
issues := issuesFor(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid enemy event shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.EnemyEventList) []string {
|
||||
if value.Events == nil {
|
||||
return []string{"events must be present"}
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for eventIndex, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
||||
if strings.TrimSpace(event.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
||||
}
|
||||
if !enemyeventmodel.SupportedKind(event.Kind) {
|
||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(event.Kind)))
|
||||
}
|
||||
if len(event.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||
}
|
||||
}
|
||||
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.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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,76 @@
|
||||
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 TestValidatorAcceptsEmptyAndWellFormedEventLists(t *testing.T) {
|
||||
for _, value := range []dnd.EnemyEventList{{Events: []dnd.EnemyEvent{}}, validEventList()} {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsOwnedShapeBoundaries(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.EnemyEventList)
|
||||
want string
|
||||
}{
|
||||
{"missing events", func(value *dnd.EnemyEventList) { value.Events = nil }, "events must be present"},
|
||||
{"blank name", func(value *dnd.EnemyEventList) { value.Events[0].Name = " \t" }, "name must not be empty"},
|
||||
{"unsupported kind", func(value *dnd.EnemyEventList) { value.Events[0].Kind = "unknown" }, "kind is unsupported"},
|
||||
{"missing evidence", func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs = nil }, "source_refs must contain"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
value := validEventList()
|
||||
test.mutate(&value)
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
|
||||
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorBoundsDiagnosticsAndRegistersStrictly(t *testing.T) {
|
||||
value := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 30)}
|
||||
for index := range value.Events {
|
||||
value.Events[index].Name = strings.Repeat("火", 300)
|
||||
value.Events[index].Kind = "invalid"
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{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("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v", got)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v", Spec())
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() accepted unknown option")
|
||||
}
|
||||
}
|
||||
|
||||
func validEventList() dnd.EnemyEventList {
|
||||
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
|
||||
Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package sourcerefs validates enemy-event evidence against the current source document.
|
||||
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"
|
||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/enemy-events/source_refs"
|
||||
ReasonCode = "invalid_enemy_event_source_refs"
|
||||
policy = "dnd.enemy_events.validator.source_refs.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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.EnemyEventList]) (contracts.ValidationResult, error) {
|
||||
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
|
||||
return contracts.ValidationResult{}, fmt.Errorf("enemy event source-reference validator requires the current extraction chunk")
|
||||
}
|
||||
if err := enemyeventshape.Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
var coverage *chunkCoverage
|
||||
if req.Stage == string(pipeline.StageExtract) {
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := sourceRefIssues(index, req.Source, coverage, req.Value)
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event source references", issues)}, nil
|
||||
}
|
||||
|
||||
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.EnemyEventList) []string {
|
||||
issues := make([]string, 0)
|
||||
for eventIndex, event := range value.Events {
|
||||
for refIndex, ref := range event.SourceRefs {
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(doc, ref) {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
sourceID string
|
||||
unitIDs map[int]struct{}
|
||||
}
|
||||
|
||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
||||
for _, unit := range chunk.Units {
|
||||
coverage.unitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
||||
return false
|
||||
}
|
||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !startOK || !endOK || start > end {
|
||||
return false
|
||||
}
|
||||
for position := start; position <= end; position++ {
|
||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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,86 @@
|
||||
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 TestValidatorAcceptsCurrentDocumentAndChunkEvidence(t *testing.T) {
|
||||
value := validEventList()
|
||||
chunk := &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Stage: string(pipeline.StageExtract), Source: document(), Chunk: chunk, Value: value})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsInvalidOrOutOfChunkEvidence(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ref source.SourceRef
|
||||
chunk *source.Chunk
|
||||
want string
|
||||
missing bool
|
||||
}{
|
||||
{"wrong source", source.SourceRef{SourceID: "other", StartUnitID: 1, EndUnitID: 1}, nil, "does not match document", false},
|
||||
{"unknown unit", source.SourceRef{SourceID: "session", StartUnitID: 99, EndUnitID: 99}, nil, "was not found", false},
|
||||
{"backward range", source.SourceRef{SourceID: "session", StartUnitID: 3, EndUnitID: 1}, nil, "appears after", false},
|
||||
{"outside chunk", source.SourceRef{SourceID: "session", StartUnitID: 2, EndUnitID: 3}, &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}, "outside the current extraction chunk", false},
|
||||
{"missing chunk", source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, nil, "current extraction chunk", true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
value := validEventList()
|
||||
value.Events[0].SourceRefs[0] = test.ref
|
||||
request := contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), Value: value}
|
||||
if test.chunk != nil || test.missing {
|
||||
request.Stage = string(pipeline.StageExtract)
|
||||
request.Chunk = test.chunk
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), request)
|
||||
if test.missing {
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Validate() error = %v; want %q", err, test.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
|
||||
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersShapeFailureAndRegistersStrictly(t *testing.T) {
|
||||
malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), Value: malformed})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("shape deferral = %#v, %v", result, err)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v", Spec())
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v", got)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() accepted unknown option")
|
||||
}
|
||||
}
|
||||
|
||||
func validEventList() dnd.EnemyEventList {
|
||||
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
|
||||
}
|
||||
|
||||
func document() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Ashfang attacks."}, {ID: 2, Text: "Ashfang retreats."}, {ID: 3, Text: "Later."}}}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package sourcerelatedness reports advisory enemy-subject evidence concerns.
|
||||
package sourcerelatedness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/enemy-events/source_relatedness"
|
||||
WarningReasonCode = "enemy_event_not_near_source"
|
||||
OmittedReasonCode = "enemy_event_relatedness_warnings_omitted"
|
||||
policy = "dnd.enemy_events.validator.source_relatedness.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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.EnemyEventList]) (contracts.ValidationResult, error) {
|
||||
if enemyeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
resolver, err := shared.NewCitationResolver(req.Source)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for eventIndex, event := range req.Value.Events {
|
||||
citedText, err := resolver.CitedText(event.SourceRefs)
|
||||
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: fmt.Sprintf("events[%d]", eventIndex),
|
||||
ReasonCode: WarningReasonCode,
|
||||
Message: diagnostics.Aggregate("enemy event subject not near source", []string{
|
||||
fmt.Sprintf("subject %s was not found in cited source text", diagnostics.Quote(event.Name)),
|
||||
}),
|
||||
})
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true, Warnings: diagnostics.LimitWarnings(warnings, "enemy_events", OmittedReasonCode)}, nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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,66 @@
|
||||
package sourcerelatedness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) {
|
||||
value := validEventList()
|
||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"npcs": {Items: []contracts.ReferenceItem{{Content: []byte("Ashfang")}}}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("The party waits."), References: references, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorAcceptsUnicodeSubjectInCitedEvidence(t *testing.T) {
|
||||
value := validEventList()
|
||||
value.Events[0].Name = "O'Rin Thorn"
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("o’rin\u2003thorn flees."), Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersMalformedValuesAndBoundsWarnings(t *testing.T) {
|
||||
malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: malformed})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("shape deferral = %#v, %v", result, err)
|
||||
}
|
||||
value := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 30)}
|
||||
for index := range value.Events {
|
||||
value.Events[index] = dnd.EnemyEvent{Name: "Missing", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||
}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) == 0 || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
|
||||
t.Fatalf("bounded warnings = %#v, %v", result, err)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v", Spec())
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v", got)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() accepted unknown option")
|
||||
}
|
||||
}
|
||||
|
||||
func validEventList() dnd.EnemyEventList {
|
||||
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||
}
|
||||
|
||||
func document(text string) *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: text}}}
|
||||
}
|
||||
Reference in New Issue
Block a user