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
|
Notarius is a Go CLI for turning source material into structured artifacts with
|
||||||
configured extraction pipelines. The implemented D&D workflow reads Seriatim
|
configured extraction pipelines. The implemented D&D workflow reads Seriatim
|
||||||
transcript JSON and can produce scene descriptions, item and currency events,
|
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
|
## Quickstart
|
||||||
|
|
||||||
|
|||||||
@@ -370,14 +370,28 @@ selected target declares them:
|
|||||||
| **players** | Optional text player context. |
|
| **players** | Optional text player context. |
|
||||||
| **glossary** | Optional text campaign glossary. |
|
| **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). |
|
| **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. |
|
| **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 extraction. |
|
| **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
|
Scene descriptions accept **party**, **players**, and **glossary**, but not
|
||||||
**roster**. NPC interactions require **npcs** for both extraction and
|
**roster**. NPC interactions require **npcs** for both extraction and
|
||||||
normalization. Combat turns require **scene_descriptions** for extraction; the
|
normalization. Combat turns require **scene_descriptions** for extraction; the
|
||||||
normalized combat-turn module may use optional **npcs**. The complete example
|
normalized combat-turn module may use optional **npcs**. Enemy-event extraction
|
||||||
shows generated **npcs** and **scene_descriptions** bindings.
|
requires all four JSON artifact slots; its normalizer requires **npcs**. The
|
||||||
|
complete example shows the ordered generated bindings.
|
||||||
|
|
||||||
## Production Module Keys
|
## Production Module Keys
|
||||||
|
|
||||||
@@ -385,9 +399,9 @@ shows generated **npcs** and **scene_descriptions** bindings.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Input | **seriatim** |
|
| Input | **seriatim** |
|
||||||
| Chunk | **generic**, **dnd/scenes** |
|
| 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** |
|
| 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** |
|
| Output | **json** |
|
||||||
|
|
||||||
The D&D artifact contracts define each emitted schema:
|
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),
|
[NPCs](integrations/dnd-npc-artifacts.md),
|
||||||
[NPC interactions](integrations/dnd-npc-interaction-artifacts.md),
|
[NPC interactions](integrations/dnd-npc-interaction-artifacts.md),
|
||||||
[combat turns](integrations/dnd-combat-turn-artifacts.md),
|
[combat turns](integrations/dnd-combat-turn-artifacts.md),
|
||||||
[item events](integrations/dnd-item-event-artifacts.md), and
|
[item events](integrations/dnd-item-event-artifacts.md),
|
||||||
[scene descriptions](integrations/dnd-scene-description-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
|
## 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** |
|
| 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** |
|
| 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** |
|
| 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
|
When no override is configured, production D&D bindings use the following
|
||||||
ordered chains. Each row lists extract then normalize; spell chains are the
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
Chains are only registered for the D&D extract and normalize modules shown
|
||||||
above; select an explicit override when a different compatible chain is
|
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.
|
actions from registry or scene data.
|
||||||
|
|
||||||
The [NPC-interaction artifact](dnd-npc-interaction-artifacts.md) records
|
The [NPC-interaction artifact](dnd-npc-interaction-artifacts.md) records
|
||||||
broader NPC occurrences. The [JSON output contract](json-output.md) defines
|
broader NPC occurrences. The [enemy-event artifact](dnd-enemy-event-artifacts.md)
|
||||||
publication, and [D&D module internals](../internal/dnd.md) describes routing
|
uses combat turns as grounding only; turns do not establish an enemy event or
|
||||||
and validation mechanics.
|
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
|
Only individually identifiable NPC names with transcript evidence belong in
|
||||||
this artifact. Groups, generic roles, invented labels, and descriptive
|
this artifact. Groups, generic roles, invented labels, and descriptive
|
||||||
enrichment are excluded. Its source references prove registry provenance; they
|
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)
|
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
|
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).
|
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
|
The [JSON output contract](json-output.md) defines publication, and
|
||||||
[D&D module internals](../internal/dnd.md) owns pipeline mechanics.
|
[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.
|
sequence are collapsed; distinct categories or evidence remain separate.
|
||||||
|
|
||||||
See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action
|
See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action
|
||||||
occurrences and the [JSON output contract](json-output.md) for publication.
|
occurrences. The [enemy-event artifact](dnd-enemy-event-artifacts.md) consumes
|
||||||
Pipeline mechanics are described in [D&D module internals](../internal/dnd.md).
|
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
|
summary, is invalid. It does not merge adjacent ranges, alter prose, or infer
|
||||||
missing scenes.
|
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
|
`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
|
[JSON output contract](json-output.md); implementation details live in
|
||||||
[D&D module internals](../internal/dnd.md).
|
[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),
|
[spells](dnd-spell-artifacts.md), [NPCs](dnd-npc-artifacts.md),
|
||||||
[NPC interactions](dnd-npc-interaction-artifacts.md),
|
[NPC interactions](dnd-npc-interaction-artifacts.md),
|
||||||
[combat turns](dnd-combat-turn-artifacts.md),
|
[combat turns](dnd-combat-turn-artifacts.md),
|
||||||
[item events](dnd-item-event-artifacts.md), and
|
[item events](dnd-item-event-artifacts.md),
|
||||||
[scene descriptions](dnd-scene-description-artifacts.md).
|
[scene descriptions](dnd-scene-description-artifacts.md), and
|
||||||
|
[enemy events](dnd-enemy-event-artifacts.md).
|
||||||
|
|
||||||
## `manifest.json`
|
## `manifest.json`
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ selectable keys, bindings, reference syntax, and default validator chains.
|
|||||||
|
|
||||||
## Durable Artifact Contracts
|
## 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.
|
does not repeat their JSON shapes or schemas.
|
||||||
|
|
||||||
| Lane | Durable contract |
|
| 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) |
|
| Item events | [item-event artifacts](../integrations/dnd-item-event-artifacts.md) |
|
||||||
| NPC interactions | [NPC-interaction artifacts](../integrations/dnd-npc-interaction-artifacts.md) |
|
| NPC interactions | [NPC-interaction artifacts](../integrations/dnd-npc-interaction-artifacts.md) |
|
||||||
| Scene descriptions | [scene-description artifacts](../integrations/dnd-scene-description-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
|
## Family Composition
|
||||||
|
|
||||||
@@ -101,14 +102,19 @@ relatedness validators report advisory evidence concerns. The configured order
|
|||||||
is documented in
|
is documented in
|
||||||
[Configuration](../config.md#production-validator-keys-and-default-chains).
|
[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
|
Normalizers are deterministic for spells, combat turns, item events, NPC
|
||||||
interactions, and scene descriptions. They canonicalize display values and
|
interactions, scene descriptions, and enemy events. They canonicalize display
|
||||||
evidence, use source-document order for stable output, and issue bounded
|
values and evidence, use source-document order for stable output, and issue
|
||||||
warnings for changes or collapsed duplicates. The NPC normalizer is the
|
bounded warnings for changes or collapsed duplicates. The NPC normalizer is
|
||||||
intentional exception: it first produces a deterministic candidate set, then
|
the intentional exception: it first produces a deterministic candidate set,
|
||||||
uses a bounded structured-LLM proposal to reconcile identity groups. Invalid
|
then uses a bounded structured-LLM proposal to reconcile identity groups.
|
||||||
or unusable proposals retain the deterministic result and surface retry or
|
Invalid or unusable proposals retain the deterministic result and surface retry
|
||||||
fallback diagnostics; the model does not directly replace durable records.
|
or fallback diagnostics; the model does not directly replace durable records.
|
||||||
|
|
||||||
## Generated References And Grounding
|
## 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
|
actors for spells and combat turns and are required for NPC interactions, but
|
||||||
they do not supply evidence. Scene-description registries are eligibility-only
|
they do not supply evidence. Scene-description registries are eligibility-only
|
||||||
projections: they retain the current chunk’s classification data, not scene
|
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
|
## 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. |
|
| 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. |
|
| 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. |
|
| 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
|
The combat and scene-description contracts describe their exact handoff and
|
||||||
empty-result behavior in more detail:
|
empty-result behavior in more detail:
|
||||||
[combat turns](../integrations/dnd-combat-turn-artifacts.md) and
|
[combat turns](../integrations/dnd-combat-turn-artifacts.md) and
|
||||||
[scene descriptions](../integrations/dnd-scene-description-artifacts.md).
|
[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
|
## Focused Verification
|
||||||
|
|
||||||
|
|||||||
@@ -7,24 +7,6 @@ not as committed release dates.
|
|||||||
|
|
||||||
## Near-Term D&D Pipeline
|
## 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
|
### Location Extraction
|
||||||
|
|
||||||
- Add a D&D artifact for locations visited by the party or otherwise mentioned
|
- 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
|
- spells
|
||||||
- combat-turns
|
- combat-turns
|
||||||
- npc-interactions
|
- npc-interactions
|
||||||
|
- enemy-events
|
||||||
steps:
|
steps:
|
||||||
# Establish session-wide reference artifacts alongside independent item events.
|
# Establish session-wide reference artifacts alongside independent item events.
|
||||||
- id: describe-session
|
- id: describe-session
|
||||||
@@ -101,3 +102,28 @@ pipelines:
|
|||||||
retries: 2
|
retries: 2
|
||||||
merge: appendorder
|
merge: appendorder
|
||||||
normalize: dnd/npc-interactions
|
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)
|
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
|
||||||
}
|
}
|
||||||
if example.name == "complete" {
|
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" {
|
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 every D&D extractor in the documented two-step composition", got)
|
t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
|
||||||
}
|
}
|
||||||
spellLane := referenceContractLane(t, materialized, "spells")
|
spellLane := referenceContractLane(t, materialized, "spells")
|
||||||
if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
|
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)
|
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
|
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"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||||
|
enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents"
|
||||||
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
||||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
|
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
|
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||||
@@ -47,9 +50,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
|
|
||||||
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
|
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
|
||||||
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
|
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
|
||||||
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "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, "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, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
|
||||||
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||||
"extract/dnd/spells/catalog",
|
"extract/dnd/spells/catalog",
|
||||||
@@ -64,17 +67,23 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
"extract/dnd/item-events/source_refs",
|
"extract/dnd/item-events/source_refs",
|
||||||
"extract/dnd/item-events/source_relatedness",
|
"extract/dnd/item-events/source_relatedness",
|
||||||
"normalize/dnd/item-events/invariants",
|
"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_accept",
|
||||||
"generic/always_reject",
|
"generic/always_reject",
|
||||||
"generic/valid_json",
|
"generic/valid_json",
|
||||||
"generic/valid_json_schema",
|
"generic/valid_json_schema",
|
||||||
})
|
})
|
||||||
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, 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})
|
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})
|
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, "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, "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, "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{
|
wantChain := []pipeline.ModuleBinding{
|
||||||
pipeline.Binding("generic/valid_json"),
|
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) {
|
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)
|
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)
|
assetNames := productionAssetNames(t, components.assets.PromptFS)
|
||||||
requiredAssets := []string{
|
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-system.md",
|
||||||
"dnd.item_events/sharedassets/common-dnd-transcript.md",
|
"dnd.item_events/sharedassets/common-dnd-transcript.md",
|
||||||
"dnd.item_events/task.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)
|
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/item-events", want: contracts.ExecutionClassLLMBacked},
|
||||||
{stage: pipeline.StageExtract, key: "dnd/npc-interactions", 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: "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.StageMerge, key: "appendorder", want: contracts.ExecutionClassDeterministic},
|
||||||
{stage: pipeline.StageNormalize, key: "noop", want: contracts.ExecutionClassDeterministic},
|
{stage: pipeline.StageNormalize, key: "noop", want: contracts.ExecutionClassDeterministic},
|
||||||
{stage: pipeline.StageNormalize, key: "dnd/spells", 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/item-events", want: contracts.ExecutionClassDeterministic},
|
||||||
{stage: pipeline.StageNormalize, key: "dnd/npc-interactions", 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: "dnd/scene-descriptions", want: contracts.ExecutionClassDeterministic},
|
||||||
|
{stage: pipeline.StageNormalize, key: enemyeventnormalize.Key, want: contracts.ExecutionClassDeterministic},
|
||||||
{stage: pipeline.StageOutput, key: "json", want: contracts.ExecutionClassDeterministic},
|
{stage: pipeline.StageOutput, key: "json", want: contracts.ExecutionClassDeterministic},
|
||||||
} {
|
} {
|
||||||
got, ok := catalog.ExecutionClass(test.stage, test.key)
|
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 {
|
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)
|
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) {
|
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||||
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
|
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
|
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
|
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||||
@@ -18,6 +20,11 @@ import (
|
|||||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||||
|
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"
|
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
|
||||||
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
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"
|
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 {
|
{name: "item events validator chain", register: func() error {
|
||||||
return registry.Register(pipeline.ValidatorChainMapping{
|
return registry.Register(pipeline.ValidatorChainMapping{
|
||||||
Stage: pipeline.StageExtract,
|
Stage: pipeline.StageExtract,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
|
|||||||
{name: "combat turns evidence", register: func() error {
|
{name: "combat turns evidence", register: func() error {
|
||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.CombatTurnListKind, combatTurnEvidence)
|
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 {
|
{name: "item events evidence", register: func() error {
|
||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
||||||
}},
|
}},
|
||||||
@@ -49,6 +52,14 @@ func combatTurnEvidence(value dnd.CombatTurnList) []source.SourceRef {
|
|||||||
return append([]source.SourceRef(nil), refs...)
|
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 {
|
func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
|
||||||
var refs []source.SourceRef
|
var refs []source.SourceRef
|
||||||
for _, record := range value.Events {
|
for _, record := range value.Events {
|
||||||
|
|||||||
@@ -70,6 +70,27 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
|
|||||||
return combined, nil
|
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) {
|
func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) {
|
||||||
count := 0
|
count := 0
|
||||||
present := false
|
present := false
|
||||||
@@ -137,6 +158,12 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
|||||||
return clone
|
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 {
|
func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent {
|
||||||
clone := value
|
clone := value
|
||||||
if value.Quantity != nil {
|
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"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||||
|
enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents"
|
||||||
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
||||||
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
|
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
|
||||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||||
scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
|
scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
|
||||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
|
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
|
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||||
@@ -33,6 +36,7 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
||||||
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
||||||
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
||||||
|
{name: "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: "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: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }},
|
||||||
{name: "scene descriptions codec", register: func() error {
|
{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: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
|
||||||
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
||||||
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
||||||
|
{name: "enemy events extractor", register: func() error { return enemyeventextract.Register(registries.Extractors) }},
|
||||||
{name: "item events extractor", register: func() error { return itemeventextract.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: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }},
|
||||||
{name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.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 {
|
{name: "combat-turn-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
||||||
}},
|
}},
|
||||||
|
{name: "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 {
|
{name: "item-event-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
|
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: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
||||||
|
{name: "enemy events normalizer", register: func() error { return enemyeventnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "item events normalizer", register: func() error { return itemeventnormalize.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: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.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 {
|
{name: "combat-turn-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
||||||
}},
|
}},
|
||||||
|
{name: "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 {
|
{name: "item-event-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
|
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: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
|
||||||
{name: "npc normalization prompt assets", register: func() error { return npcnormalize.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: "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: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }},
|
||||||
{name: "npc interactions prompt assets", register: func() error { return interactionextract.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) }},
|
{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"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
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"
|
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
@@ -38,12 +39,11 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
|||||||
"glossary": promptkit.Inline(glossarySentinel),
|
"glossary": promptkit.Inline(glossarySentinel),
|
||||||
}
|
}
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
promptID string
|
promptID string
|
||||||
promptVersion string
|
promptVersion string
|
||||||
inputs map[string]promptkit.ArtifactRef
|
inputs map[string]promptkit.ArtifactRef
|
||||||
npcInput bool
|
inputSentinels []string
|
||||||
spellCatalogInput bool
|
|
||||||
}{
|
}{
|
||||||
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs},
|
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs},
|
||||||
{name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.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{
|
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
||||||
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
"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",
|
name: "npc interactions",
|
||||||
@@ -64,7 +75,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
|||||||
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
||||||
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
||||||
}),
|
}),
|
||||||
npcInput: true,
|
inputSentinels: []string{npcSentinel},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "spells",
|
name: "spells",
|
||||||
@@ -74,8 +85,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
|||||||
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
|
||||||
"spell_catalog": promptkit.Inline(`{"sentinel":"` + catalogSentinel + `"}`),
|
"spell_catalog": promptkit.Inline(`{"sentinel":"` + catalogSentinel + `"}`),
|
||||||
}),
|
}),
|
||||||
npcInput: true,
|
inputSentinels: []string{npcSentinel, catalogSentinel},
|
||||||
spellCatalogInput: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +103,6 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
transcriptIndex := renderedInputMessageIndex(t, prepared.Messages, transcriptSentinel)
|
transcriptIndex := renderedInputMessageIndex(t, prepared.Messages, transcriptSentinel)
|
||||||
prefix := prepared.Messages[:transcriptIndex+1]
|
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) {
|
if len(prepared.Messages) <= len(prefix) {
|
||||||
t.Fatalf("prepared prompt has %d messages, want lane-specific suffix after transcript", len(prepared.Messages))
|
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) {
|
} else if !reflect.DeepEqual(prefix, sharedPrefix) {
|
||||||
t.Fatalf("rendered prefix = %#v, want %#v", prefix, sharedPrefix)
|
t.Fatalf("rendered prefix = %#v, want %#v", prefix, sharedPrefix)
|
||||||
}
|
}
|
||||||
if testCase.npcInput {
|
for _, sentinel := range testCase.inputSentinels {
|
||||||
assertRenderedInputAfter(t, prepared.Messages, npcSentinel, transcriptIndex)
|
assertRenderedInputAfter(t, prepared.Messages, sentinel, transcriptIndex)
|
||||||
}
|
|
||||||
if testCase.spellCatalogInput {
|
|
||||||
assertRenderedInputAfter(t, prepared.Messages, catalogSentinel, transcriptIndex)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
|
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||||
|
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||||
@@ -44,6 +46,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
"dnd.spells/dnd.spells.yaml",
|
"dnd.spells/dnd.spells.yaml",
|
||||||
"dnd.npcs/dnd.npcs.yaml",
|
"dnd.npcs/dnd.npcs.yaml",
|
||||||
"dnd.combat_turns/dnd.combat_turns.yaml",
|
"dnd.combat_turns/dnd.combat_turns.yaml",
|
||||||
|
"dnd.enemy_events/dnd.enemy_events.yaml",
|
||||||
"dnd.item_events/dnd.item_events.yaml",
|
"dnd.item_events/dnd.item_events.yaml",
|
||||||
"dnd.npc_interactions/dnd.npc_interactions.yaml",
|
"dnd.npc_interactions/dnd.npc_interactions.yaml",
|
||||||
"dnd.scene_descriptions/dnd.scene_descriptions.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)
|
t.Fatalf("normalization schema asset = %v, want registered private schema", err)
|
||||||
}
|
}
|
||||||
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
|
||||||
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key})
|
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, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule})
|
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.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
|
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.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.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.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(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
||||||
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
|
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(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
|
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_refs",
|
||||||
"extract/dnd/combat-turns/source_relatedness",
|
"extract/dnd/combat-turns/source_relatedness",
|
||||||
"normalize/dnd/combat-turns/invariants",
|
"normalize/dnd/combat-turns/invariants",
|
||||||
|
"extract/dnd/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/shape",
|
||||||
"extract/dnd/item-events/source_refs",
|
"extract/dnd/item-events/source_refs",
|
||||||
"extract/dnd/item-events/source_relatedness",
|
"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) {
|
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
|
||||||
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
|
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
|
||||||
}
|
}
|
||||||
|
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{
|
itemEventExtractChain := []pipeline.ModuleBinding{
|
||||||
pipeline.Binding("generic/valid_json"),
|
pipeline.Binding("generic/valid_json"),
|
||||||
pipeline.Binding("extract/dnd/item-events/shape"),
|
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-system.md",
|
||||||
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||||
"dnd.combat_turns/task.md",
|
"dnd.combat_turns/task.md",
|
||||||
|
"dnd.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/dnd.item_events.yaml",
|
||||||
"dnd.item_events/instructions.md",
|
"dnd.item_events/instructions.md",
|
||||||
"dnd.item_events/sharedassets/common-dnd-extraction-evidence.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_spells_llm.v1.json",
|
||||||
"dnd_npcs_llm.v1.json",
|
"dnd_npcs_llm.v1.json",
|
||||||
"dnd_combat_turns_llm.v1.json",
|
"dnd_combat_turns_llm.v1.json",
|
||||||
|
"dnd_enemy_events_llm.v1.json",
|
||||||
"dnd_item_events_llm.v1.json",
|
"dnd_item_events_llm.v1.json",
|
||||||
"dnd_npc_interactions_llm.v1.json",
|
"dnd_npc_interactions_llm.v1.json",
|
||||||
"dnd_scene_descriptions_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 {
|
if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize {
|
||||||
t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok)
|
t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok)
|
||||||
}
|
}
|
||||||
|
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)
|
itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key)
|
||||||
itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.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 {
|
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 {
|
{name: "combat turns", project: func() []source.SourceRef {
|
||||||
return combatTurnEvidence(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{first, second}}}})
|
return combatTurnEvidence(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||||
}, want: []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 {
|
{name: "item events", project: func() []source.SourceRef {
|
||||||
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||||
}, want: []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) {
|
func TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||||
quantity := 3
|
quantity := 3
|
||||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
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)
|
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{}}}}})
|
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}})
|
||||||
if err != nil || interactions.Interactions[0].SourceRefs == nil {
|
if err != nil || interactions.Interactions[0].SourceRefs == nil {
|
||||||
t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err)
|
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"
|
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||||
|
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"
|
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
|
||||||
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
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"
|
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 references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
|
||||||
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
|
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
|
||||||
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
|
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
|
||||||
|
{name: "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 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 references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }},
|
||||||
{name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.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 {
|
{name: "combat-turn-list always reject validator", register: func() error {
|
||||||
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||||
}},
|
}},
|
||||||
|
{name: "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 {
|
{name: "item-event-list always accept validator", register: func() error {
|
||||||
return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
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 ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list"
|
||||||
|
|
||||||
|
const EnemyEventListKind contracts.ArtifactKind = "dnd/enemy-event-list"
|
||||||
|
|
||||||
type SpellList struct {
|
type SpellList struct {
|
||||||
SpellCasts []SpellCast `json:"spell_casts"`
|
SpellCasts []SpellCast `json:"spell_casts"`
|
||||||
}
|
}
|
||||||
@@ -122,3 +124,23 @@ type ItemEvent struct {
|
|||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
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