Adopt registry-backed item occurrences
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
Extract meaningful Dungeons & Dragons item and currency discoveries and changes
|
Extract meaningful Dungeons & Dragons item and currency occurrences: discoveries and changes
|
||||||
in party possession established by the transcript. This is an event history,
|
in party possession established by the transcript. This is an event history,
|
||||||
not an inventory or ledger: do not calculate balances, resolve item identity
|
not an inventory or ledger: do not calculate balances, resolve item identity
|
||||||
across records, or infer ownership that the transcript does not establish.
|
across records, or infer ownership that the transcript does not establish.
|
||||||
|
|
||||||
Record a stated quantity as an integer and leave it null when the transcript
|
For every occurrence, copy the exact `item_id` and `name` pair from the supplied
|
||||||
|
item registry. Record a stated quantity as an integer and leave it null when the transcript
|
||||||
does not state one. Use a concise observed item name and preserve the stated
|
does not state one. Use a concise observed item name and preserve the stated
|
||||||
currency denomination.
|
currency denomination.
|
||||||
|
|
||||||
|
|||||||
6
assets/dnd/item-events/prompts/item-registry.md
Normal file
6
assets/dnd/item-events/prompts/item-registry.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Use the supplied item registry only to ground each occurrence. Every record
|
||||||
|
must copy one registry item's exact `id` and exact `name`; do not invent,
|
||||||
|
rename, merge, or infer registry items. The registry is not transcript
|
||||||
|
evidence: cite only the current transcript chunk in `source_refs`.
|
||||||
|
|
||||||
|
{{ input "item_registry" }}
|
||||||
@@ -14,6 +14,9 @@ inputs:
|
|||||||
- name: glossary
|
- name: glossary
|
||||||
required: false
|
required: false
|
||||||
content_type: text/plain
|
content_type: text/plain
|
||||||
|
- name: item_registry
|
||||||
|
required: true
|
||||||
|
content_type: application/json
|
||||||
messages:
|
messages:
|
||||||
- role: system
|
- role: system
|
||||||
content_file: ./sharedassets/common-dnd-system.md
|
content_file: ./sharedassets/common-dnd-system.md
|
||||||
@@ -29,6 +32,8 @@ messages:
|
|||||||
type: ephemeral
|
type: ephemeral
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./sharedassets/common-dnd-extraction-evidence.md
|
content_file: ./sharedassets/common-dnd-extraction-evidence.md
|
||||||
|
- role: user
|
||||||
|
content_file: ./item-registry.md
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./instructions.md
|
content_file: ./instructions.md
|
||||||
cache_control:
|
cache_control:
|
||||||
@@ -36,5 +41,5 @@ messages:
|
|||||||
output:
|
output:
|
||||||
format: json
|
format: json
|
||||||
validation_mode: json_schema
|
validation_mode: json_schema
|
||||||
schema_path: dnd_item_events_llm.v1.json
|
schema_path: dnd_item_occurrences_llm.v1.json
|
||||||
repair_attempts: 0
|
repair_attempts: 0
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "notarius.dnd.item_events.llm",
|
"$id": "notarius.dnd.item_occurrences.llm",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["events"],
|
"required": ["occurrences"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"events": {
|
"occurrences": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "kind", "quantity", "from", "to", "source_refs"],
|
"required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"item_id": {"type": "string"},
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"kind": {"type": "string"},
|
"kind": {"type": "string"},
|
||||||
"quantity": {"type": ["integer", "null"]},
|
"quantity": {"type": ["integer", "null"]},
|
||||||
@@ -411,9 +411,10 @@ generated bindings.
|
|||||||
|
|
||||||
`dnd/location-registry` and `dnd/item-registry` extraction and normalization
|
`dnd/location-registry` and `dnd/item-registry` extraction and normalization
|
||||||
are `llm_backed`; their normalizers may use the pipeline's selected LLM profile
|
are `llm_backed`; their normalizers may use the pipeline's selected LLM profile
|
||||||
for bounded duplicate proposals. `dnd/location-occurrences` extraction is
|
for bounded duplicate proposals. `dnd/location-occurrences` and
|
||||||
`llm_backed`, while its normalizer is `deterministic`. The complete example
|
`dnd/item-events` extraction are `llm_backed`, while their normalizers are
|
||||||
binds the location registry in one step and the occurrence lane in the next.
|
`deterministic`. The complete example binds each registry in an earlier step
|
||||||
|
before its occurrence consumer.
|
||||||
|
|
||||||
The D&D artifact contracts define each emitted schema:
|
The D&D artifact contracts define each emitted schema:
|
||||||
[spells](integrations/dnd-spell-artifacts.md),
|
[spells](integrations/dnd-spell-artifacts.md),
|
||||||
@@ -437,7 +438,7 @@ Available validator keys are:
|
|||||||
| Spells | **extract/dnd/spells/shape**, **extract/dnd/spells/catalog**, **extract/dnd/spells/source_refs**, **extract/dnd/spells/source_relatedness** |
|
| Spells | **extract/dnd/spells/shape**, **extract/dnd/spells/catalog**, **extract/dnd/spells/source_refs**, **extract/dnd/spells/source_relatedness** |
|
||||||
| NPC registry | **extract/dnd/npc-registry/shape**, **extract/dnd/npc-registry/source_refs**, **extract/dnd/npc-registry/source_relatedness**, **normalize/dnd/npc-registry/identity** |
|
| NPC registry | **extract/dnd/npc-registry/shape**, **extract/dnd/npc-registry/source_refs**, **extract/dnd/npc-registry/source_relatedness**, **normalize/dnd/npc-registry/identity** |
|
||||||
| Combat turns | **extract/dnd/combat-turns/shape**, **extract/dnd/combat-turns/source_refs**, **extract/dnd/combat-turns/source_relatedness**, **normalize/dnd/combat-turns/invariants** |
|
| Combat turns | **extract/dnd/combat-turns/shape**, **extract/dnd/combat-turns/source_refs**, **extract/dnd/combat-turns/source_relatedness**, **normalize/dnd/combat-turns/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** |
|
| Item events | **extract/dnd/item-events/shape**, **extract/dnd/item-events/registry**, **extract/dnd/item-events/source_refs**, **extract/dnd/item-events/source_relatedness**, **normalize/dnd/item-events/invariants** |
|
||||||
| Item registry | **extract/dnd/item-registry/shape**, **extract/dnd/item-registry/source_refs**, **extract/dnd/item-registry/source_relatedness**, **normalize/dnd/item-registry/identity** |
|
| Item registry | **extract/dnd/item-registry/shape**, **extract/dnd/item-registry/source_refs**, **extract/dnd/item-registry/source_relatedness**, **normalize/dnd/item-registry/identity** |
|
||||||
| NPC occurrences | **extract/dnd/npc-occurrences/shape**, **extract/dnd/npc-occurrences/registry**, **extract/dnd/npc-occurrences/source_refs**, **extract/dnd/npc-occurrences/source_relatedness**, **normalize/dnd/npc-occurrences/invariants** |
|
| NPC occurrences | **extract/dnd/npc-occurrences/shape**, **extract/dnd/npc-occurrences/registry**, **extract/dnd/npc-occurrences/source_refs**, **extract/dnd/npc-occurrences/source_relatedness**, **normalize/dnd/npc-occurrences/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** |
|
||||||
@@ -454,7 +455,7 @@ same at both stages.
|
|||||||
| Spells | generic/valid_json, extract/dnd/spells/shape, extract/dnd/spells/catalog, extract/dnd/spells/source_refs, generic/valid_json_schema, extract/dnd/spells/source_relatedness | Same as extract |
|
| Spells | generic/valid_json, extract/dnd/spells/shape, extract/dnd/spells/catalog, extract/dnd/spells/source_refs, generic/valid_json_schema, extract/dnd/spells/source_relatedness | Same as extract |
|
||||||
| NPC registry | generic/valid_json, extract/dnd/npc-registry/shape, extract/dnd/npc-registry/source_refs, generic/valid_json_schema, extract/dnd/npc-registry/source_relatedness | generic/valid_json, extract/dnd/npc-registry/shape, normalize/dnd/npc-registry/identity, extract/dnd/npc-registry/source_refs, generic/valid_json_schema, extract/dnd/npc-registry/source_relatedness |
|
| NPC registry | generic/valid_json, extract/dnd/npc-registry/shape, extract/dnd/npc-registry/source_refs, generic/valid_json_schema, extract/dnd/npc-registry/source_relatedness | generic/valid_json, extract/dnd/npc-registry/shape, normalize/dnd/npc-registry/identity, extract/dnd/npc-registry/source_refs, generic/valid_json_schema, extract/dnd/npc-registry/source_relatedness |
|
||||||
| Combat turns | generic/valid_json, extract/dnd/combat-turns/shape, extract/dnd/combat-turns/source_refs, generic/valid_json_schema, extract/dnd/combat-turns/source_relatedness | generic/valid_json, extract/dnd/combat-turns/shape, normalize/dnd/combat-turns/invariants, extract/dnd/combat-turns/source_refs, generic/valid_json_schema, extract/dnd/combat-turns/source_relatedness |
|
| Combat turns | generic/valid_json, extract/dnd/combat-turns/shape, extract/dnd/combat-turns/source_refs, generic/valid_json_schema, extract/dnd/combat-turns/source_relatedness | generic/valid_json, extract/dnd/combat-turns/shape, normalize/dnd/combat-turns/invariants, extract/dnd/combat-turns/source_refs, generic/valid_json_schema, extract/dnd/combat-turns/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 |
|
| Item events | generic/valid_json, extract/dnd/item-events/shape, extract/dnd/item-events/registry, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness | generic/valid_json, extract/dnd/item-events/shape, extract/dnd/item-events/registry, normalize/dnd/item-events/invariants, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness |
|
||||||
| Item registry | generic/valid_json, extract/dnd/item-registry/shape, extract/dnd/item-registry/source_refs, generic/valid_json_schema, extract/dnd/item-registry/source_relatedness | generic/valid_json, extract/dnd/item-registry/shape, normalize/dnd/item-registry/identity, extract/dnd/item-registry/source_refs, generic/valid_json_schema, extract/dnd/item-registry/source_relatedness |
|
| Item registry | generic/valid_json, extract/dnd/item-registry/shape, extract/dnd/item-registry/source_refs, generic/valid_json_schema, extract/dnd/item-registry/source_relatedness | generic/valid_json, extract/dnd/item-registry/shape, normalize/dnd/item-registry/identity, extract/dnd/item-registry/source_refs, generic/valid_json_schema, extract/dnd/item-registry/source_relatedness |
|
||||||
| NPC occurrences | generic/valid_json, extract/dnd/npc-occurrences/shape, extract/dnd/npc-occurrences/registry, extract/dnd/npc-occurrences/source_refs, generic/valid_json_schema, extract/dnd/npc-occurrences/source_relatedness | generic/valid_json, extract/dnd/npc-occurrences/shape, extract/dnd/npc-occurrences/registry, normalize/dnd/npc-occurrences/invariants, extract/dnd/npc-occurrences/source_refs, generic/valid_json_schema, extract/dnd/npc-occurrences/source_relatedness |
|
| NPC occurrences | generic/valid_json, extract/dnd/npc-occurrences/shape, extract/dnd/npc-occurrences/registry, extract/dnd/npc-occurrences/source_refs, generic/valid_json_schema, extract/dnd/npc-occurrences/source_relatedness | generic/valid_json, extract/dnd/npc-occurrences/shape, extract/dnd/npc-occurrences/registry, normalize/dnd/npc-occurrences/invariants, extract/dnd/npc-occurrences/source_refs, generic/valid_json_schema, extract/dnd/npc-occurrences/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 |
|
||||||
|
|||||||
@@ -1,45 +1,43 @@
|
|||||||
# D&D Item-Event Artifact
|
# D&D Item-Occurrence Artifact
|
||||||
|
|
||||||
This contract defines the durable item and currency occurrence list produced by
|
`dnd/item-events` currently produces this source-grounded item and currency
|
||||||
`dnd/item-events`. It records source-grounded discoveries and possession
|
occurrence list. It records discoveries and possession changes, not an
|
||||||
changes; it does not maintain an inventory, balance, or ledger.
|
inventory, balance, or ledger.
|
||||||
|
|
||||||
## Identity and compatibility
|
## Identity and compatibility
|
||||||
|
|
||||||
| Property | Value |
|
| Property | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Artifact kind | `dnd/item-event-list` |
|
| Artifact kind | `dnd/item-occurrence-list` |
|
||||||
| Schema ID | `notarius.dnd.item_events` |
|
| Schema ID | `notarius.dnd.item_occurrences` |
|
||||||
| Schema name | `notarius_dnd_item_events_v1` |
|
| Schema name | `notarius_dnd_item_occurrences_v1` |
|
||||||
| Schema version | `v1` |
|
| Schema version | `v1` |
|
||||||
| Media type | `application/json` |
|
| Media type | `application/json` |
|
||||||
|
|
||||||
`v1` is a strict JSON object with required `events`; the array may be empty.
|
The strict root has required `occurrences`. Records require `item_id`, `name`,
|
||||||
Event and source-reference objects reject unknown fields. An incompatible
|
`kind`, and `source_refs`; unknown fields are rejected. `quantity`, `from`, and
|
||||||
shape change requires a new schema version.
|
`to` appear only when their kind permits them.
|
||||||
|
|
||||||
|
## Registry grounding
|
||||||
|
|
||||||
|
Both extraction and normalization require an `item_registry` reference bound to
|
||||||
|
an earlier normalized `dnd/item-registry` artifact. The registry is immutable
|
||||||
|
for an operation and contributes only its ordered `{id,name}` projection after
|
||||||
|
the shared evidence message. It is never occurrence evidence.
|
||||||
|
|
||||||
|
Each occurrence must use one exact registry ID/name pair. Extraction omits an
|
||||||
|
unknown ID or mismatched name rather than creating an item. Normalization
|
||||||
|
canonicalizes a recognized name by ID, preserves unknown values for the
|
||||||
|
registry validator, and the registry validator rejects unknown or mismatched
|
||||||
|
pairs.
|
||||||
|
|
||||||
## Wire shape
|
## Wire shape
|
||||||
|
|
||||||
Every event has required `name`, `kind`, and `source_refs`. `quantity`, `from`,
|
|
||||||
and `to` are optional where the event kind permits them.
|
|
||||||
|
|
||||||
| Field | Contract |
|
|
||||||
| --- | --- |
|
|
||||||
| `name` | Non-empty item or currency display name. |
|
|
||||||
| `kind` | `discovered`, `acquired`, `lost`, `consumed`, or `transferred`. |
|
|
||||||
| `quantity` | Optional positive integer; omit it when no count is established. |
|
|
||||||
| `from` | Optional non-empty losing holder, when allowed by `kind`. |
|
|
||||||
| `to` | Optional non-empty gaining holder, when allowed by `kind`. |
|
|
||||||
| `source_refs` | One or more 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
|
```json
|
||||||
{
|
{
|
||||||
"events": [
|
"occurrences": [
|
||||||
{
|
{
|
||||||
|
"item_id": "item:sha256:…",
|
||||||
"name": "Silver Pieces",
|
"name": "Silver Pieces",
|
||||||
"kind": "acquired",
|
"kind": "acquired",
|
||||||
"quantity": 20,
|
"quantity": 20,
|
||||||
@@ -52,27 +50,11 @@ are positive and the start may not follow the end.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Holder rules and minimal extraction
|
The five kinds remain `discovered`, `acquired`, `lost`, `consumed`, and
|
||||||
|
`transferred`. Holder, quantity, currency, ordering, and exact-duplicate rules
|
||||||
|
are unchanged: discovered has no holder; acquired requires `to`; lost and
|
||||||
|
consumed require `from`; transferred requires distinct non-`party` holders.
|
||||||
|
Each source reference is an inclusive current-transcript range.
|
||||||
|
|
||||||
`discovered` has neither holder; `acquired` requires `to` and forbids `from`;
|
See [item registry](dnd-item-registry-artifacts.md) for the grounding artifact
|
||||||
`lost` and `consumed` require `from` and forbid `to`; `transferred` requires
|
and [D&D module internals](../internal/dnd.md) for implementation details.
|
||||||
both holders. `party` denotes collective possession. A transfer cannot use
|
|
||||||
`party` for either holder and its two normalized holders must differ.
|
|
||||||
|
|
||||||
Only an evidenced discovery or possession change belongs in this artifact.
|
|
||||||
It does not infer quantities or holders, convert currency denominations,
|
|
||||||
calculate balances, or merge nearby events. Campaign references may
|
|
||||||
disambiguate names but are never event evidence. Currency uses the ordinary
|
|
||||||
`name` field and an explicit `quantity` only when the transcript establishes
|
|
||||||
one; each denomination remains a separate event.
|
|
||||||
|
|
||||||
Normalization trims display whitespace, orders and removes exact duplicate
|
|
||||||
source references, then orders events by valid source chronology, name identity
|
|
||||||
and display value, kind, holders, quantity, and reference sequence. It
|
|
||||||
collapses only entries with the same normalized durable fields and complete
|
|
||||||
valid evidence.
|
|
||||||
|
|
||||||
The [JSON output contract](json-output.md) defines publication. See
|
|
||||||
[D&D module internals](../internal/dnd.md) for implementation details and the
|
|
||||||
[NPC-occurrence artifact](dnd-npc-occurrence-artifacts.md) for a distinct
|
|
||||||
kind of occurrence.
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pipelines:
|
|||||||
window_units: 3
|
window_units: 3
|
||||||
lanes:
|
lanes:
|
||||||
- item-events
|
- item-events
|
||||||
|
- item-registry
|
||||||
- locations
|
- locations
|
||||||
- location-occurrences
|
- location-occurrences
|
||||||
- npc_registry
|
- npc_registry
|
||||||
@@ -44,15 +45,15 @@ pipelines:
|
|||||||
- npc-occurrences
|
- npc-occurrences
|
||||||
- enemy-events
|
- enemy-events
|
||||||
steps:
|
steps:
|
||||||
# Establish session-wide reference artifacts alongside independent item events.
|
# Establish session-wide reference artifacts before their consumers.
|
||||||
- id: describe-session
|
- id: describe-session
|
||||||
artifacts:
|
artifacts:
|
||||||
item-events:
|
item-registry:
|
||||||
extract:
|
extract:
|
||||||
module: dnd/item-events
|
module: dnd/item-registry
|
||||||
retries: 2
|
retries: 2
|
||||||
merge: appendorder
|
merge: appendorder
|
||||||
normalize: dnd/item-events
|
normalize: dnd/item-registry
|
||||||
npc_registry:
|
npc_registry:
|
||||||
extract:
|
extract:
|
||||||
module: dnd/npc-registry
|
module: dnd/npc-registry
|
||||||
@@ -91,7 +92,17 @@ pipelines:
|
|||||||
artifact:
|
artifact:
|
||||||
step: describe-session
|
step: describe-session
|
||||||
lane: scene-descriptions
|
lane: scene-descriptions
|
||||||
|
item_registry:
|
||||||
|
artifact:
|
||||||
|
step: describe-session
|
||||||
|
lane: item-registry
|
||||||
artifacts:
|
artifacts:
|
||||||
|
item-events:
|
||||||
|
extract:
|
||||||
|
module: dnd/item-events
|
||||||
|
retries: 2
|
||||||
|
merge: appendorder
|
||||||
|
normalize: dnd/item-events
|
||||||
spells:
|
spells:
|
||||||
extract:
|
extract:
|
||||||
module: dnd/spells
|
module: dnd/spells
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
|
||||||
itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||||
|
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemregistry"
|
||||||
locationoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences"
|
locationoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences"
|
||||||
locations "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry"
|
locations "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry"
|
||||||
npcoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences"
|
npcoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences"
|
||||||
@@ -32,6 +33,7 @@ import (
|
|||||||
scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
scenedescriptions "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"
|
||||||
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
|
||||||
|
itemregistrynormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemregistry"
|
||||||
locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationregistry"
|
locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationregistry"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
|
||||||
)
|
)
|
||||||
@@ -253,8 +255,12 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
|||||||
content = []byte(`{"duplicate_groups":[]}`)
|
content = []byte(`{"duplicate_groups":[]}`)
|
||||||
case spells.PromptID:
|
case spells.PromptID:
|
||||||
content = []byte(`{"spell_casts":[]}`)
|
content = []byte(`{"spell_casts":[]}`)
|
||||||
|
case itemregistry.PromptID:
|
||||||
|
content = []byte(`{"items":[]}`)
|
||||||
|
case itemregistrynormalize.PromptID:
|
||||||
|
content = []byte(`{"duplicate_groups":[]}`)
|
||||||
case itemevents.PromptID:
|
case itemevents.PromptID:
|
||||||
content = []byte(`{"events":[]}`)
|
content = []byte(`{"occurrences":[]}`)
|
||||||
case combat.PromptID:
|
case combat.PromptID:
|
||||||
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
|
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
|
||||||
case npcoccurrences.PromptID:
|
case npcoccurrences.PromptID:
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ 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,locations,npc_registry,scene-descriptions|extract-events:combat-turns,location-occurrences,npc-occurrences,spells|track-enemies:enemy-events" {
|
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-registry,locations,npc_registry,scene-descriptions|extract-events:combat-turns,item-events,location-occurrences,npc-occurrences,spells|track-enemies:enemy-events" {
|
||||||
t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
|
t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
|
||||||
}
|
}
|
||||||
locationLane := referenceContractLane(t, materialized, "locations")
|
locationLane := referenceContractLane(t, materialized, "locations")
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ import (
|
|||||||
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"
|
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"
|
||||||
|
itemregistryextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemregistry"
|
||||||
"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"
|
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"
|
||||||
|
itemregistrynormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemregistry"
|
||||||
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"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
@@ -76,12 +78,12 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
"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.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
|
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind})
|
||||||
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
|
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind})
|
||||||
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
|
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, 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.ItemOccurrenceListKind})
|
||||||
assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
|
assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
|
||||||
|
|
||||||
wantChain := []pipeline.ModuleBinding{
|
wantChain := []pipeline.ModuleBinding{
|
||||||
@@ -122,6 +124,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
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"),
|
||||||
|
pipeline.Binding("extract/dnd/item-events/registry"),
|
||||||
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
||||||
pipeline.Binding("generic/valid_json_schema"),
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
|
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
|
||||||
@@ -129,6 +132,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
itemEventNormalizeChain := []pipeline.ModuleBinding{
|
itemEventNormalizeChain := []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"),
|
||||||
|
pipeline.Binding("extract/dnd/item-events/registry"),
|
||||||
pipeline.Binding("normalize/dnd/item-events/invariants"),
|
pipeline.Binding("normalize/dnd/item-events/invariants"),
|
||||||
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
||||||
pipeline.Binding("generic/valid_json_schema"),
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
@@ -196,8 +200,8 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
|||||||
if !ok || combatCodecSpec.Kind != dnd.CombatTurnListKind || combatCodecSpec.Schema.ID != combatcodec.SchemaID {
|
if !ok || combatCodecSpec.Kind != dnd.CombatTurnListKind || combatCodecSpec.Schema.ID != combatcodec.SchemaID {
|
||||||
t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok)
|
t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok)
|
||||||
}
|
}
|
||||||
itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemEventListKind)
|
itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemOccurrenceListKind)
|
||||||
if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID {
|
if !ok || itemEventCodecSpec.Kind != dnd.ItemOccurrenceListKind || 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)
|
enemyEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.EnemyEventListKind)
|
||||||
@@ -916,11 +920,23 @@ pipelines:
|
|||||||
module: json
|
module: json
|
||||||
options:
|
options:
|
||||||
include_chunk_map: true
|
include_chunk_map: true
|
||||||
artifacts:
|
steps:
|
||||||
spells:
|
- id: identify-items
|
||||||
extract: dnd/spells
|
artifacts:
|
||||||
item-events:
|
item-registry:
|
||||||
extract: dnd/item-events
|
extract: dnd/item-registry
|
||||||
|
normalize: dnd/item-registry
|
||||||
|
- id: extract-occurrences
|
||||||
|
references:
|
||||||
|
item_registry:
|
||||||
|
artifact:
|
||||||
|
step: identify-items
|
||||||
|
lane: item-registry
|
||||||
|
artifacts:
|
||||||
|
spells:
|
||||||
|
extract: dnd/spells
|
||||||
|
item-events:
|
||||||
|
extract: dnd/item-events
|
||||||
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
|
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,7 +1014,11 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
|
|||||||
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
|
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
|
||||||
}
|
}
|
||||||
case itemeventextract.PromptID:
|
case itemeventextract.PromptID:
|
||||||
content = []byte(`{"events":[{"name":"Cure Wounds","kind":"acquired","to":"party","source_refs":[{"start_segment":1,"end_segment":1}]}]}`)
|
content = []byte(`{"occurrences":[]}`)
|
||||||
|
case itemregistryextract.PromptID:
|
||||||
|
content = []byte(`{"items":[]}`)
|
||||||
|
case itemregistrynormalize.PromptID:
|
||||||
|
content = []byte(`{"duplicate_groups":[]}`)
|
||||||
default:
|
default:
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "notarius.dnd.item_events",
|
"$id": "notarius.dnd.item_occurrences",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["events"],
|
"required": ["occurrences"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"events": {
|
"occurrences": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "kind", "source_refs"],
|
"required": ["item_id", "name", "kind", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"item_id": {"type": "string", "minLength": 1},
|
||||||
"name": {"type": "string", "minLength": 1},
|
"name": {"type": "string", "minLength": 1},
|
||||||
"kind": {"type": "string", "enum": ["discovered", "acquired", "lost", "consumed", "transferred"]},
|
"kind": {"type": "string", "enum": ["discovered", "acquired", "lost", "consumed", "transferred"]},
|
||||||
"quantity": {"type": "integer", "minimum": 1},
|
"quantity": {"type": "integer", "minimum": 1},
|
||||||
@@ -14,25 +14,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SchemaID = "notarius.dnd.item_events"
|
SchemaID = "notarius.dnd.item_occurrences"
|
||||||
SchemaName = "notarius_dnd_item_events_v1"
|
SchemaName = "notarius_dnd_item_occurrences_v1"
|
||||||
SchemaVersion = "v1"
|
SchemaVersion = "v1"
|
||||||
MediaType = "application/json"
|
MediaType = "application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/schemas/dnd_item_events.v1.json
|
//go:embed assets/schemas/dnd_item_occurrences.v1.json
|
||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.ItemEventList] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
|
||||||
|
|
||||||
type Codec struct{}
|
type Codec struct{}
|
||||||
|
|
||||||
func New() *Codec { return &Codec{} }
|
func New() *Codec { return &Codec{} }
|
||||||
|
|
||||||
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemEventListKind }
|
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemOccurrenceListKind }
|
||||||
|
|
||||||
func (c *Codec) Schema() contracts.ArtifactSchema {
|
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||||
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_events.v1.json")
|
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_occurrences.v1.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ArtifactSchema{}
|
return contracts.ArtifactSchema{}
|
||||||
}
|
}
|
||||||
@@ -46,50 +46,53 @@ func (c *Codec) Schema() contracts.ArtifactSchema {
|
|||||||
|
|
||||||
func (c *Codec) MediaType() string { return MediaType }
|
func (c *Codec) MediaType() string { return MediaType }
|
||||||
|
|
||||||
func (c *Codec) Metadata(value dnd.ItemEventList) map[string]any {
|
func (c *Codec) Metadata(value dnd.ItemOccurrenceList) map[string]any {
|
||||||
return map[string]any{"event_count": len(value.Events)}
|
return map[string]any{"occurrence_count": len(value.Occurrences)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Encode(value dnd.ItemEventList) ([]byte, error) {
|
func (c *Codec) Encode(value dnd.ItemOccurrenceList) ([]byte, error) {
|
||||||
if err := validate(value); err != nil {
|
if err := validate(value); err != nil {
|
||||||
return nil, fmt.Errorf("encode dnd item event list: %w", err)
|
return nil, fmt.Errorf("encode dnd item occurrence list: %w", err)
|
||||||
}
|
}
|
||||||
return c.EncodeCandidate(value)
|
return c.EncodeCandidate(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncodeCandidate provides the durable representation before semantic
|
// EncodeCandidate provides the durable representation before semantic
|
||||||
// validators have approved a value.
|
// validators have approved a value.
|
||||||
func (c *Codec) EncodeCandidate(value dnd.ItemEventList) ([]byte, error) {
|
func (c *Codec) EncodeCandidate(value dnd.ItemOccurrenceList) ([]byte, error) {
|
||||||
return candidatejson.EncodeCandidate("dnd item event list", cloneList(value))
|
return candidatejson.EncodeCandidate("dnd item occurrence list", cloneList(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Decode(content []byte) (dnd.ItemEventList, error) {
|
func (c *Codec) Decode(content []byte) (dnd.ItemOccurrenceList, error) {
|
||||||
value, err := c.DecodeCandidate(content)
|
value, err := c.DecodeCandidate(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dnd.ItemEventList{}, err
|
return dnd.ItemOccurrenceList{}, err
|
||||||
}
|
}
|
||||||
if err := validate(value); err != nil {
|
if err := validate(value); err != nil {
|
||||||
return dnd.ItemEventList{}, fmt.Errorf("decode dnd item event list: %w", err)
|
return dnd.ItemOccurrenceList{}, fmt.Errorf("decode dnd item occurrence list: %w", err)
|
||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||||
// validators have approved it.
|
// validators have approved it.
|
||||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemEventList, error) {
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemOccurrenceList, error) {
|
||||||
value, err := candidatejson.DecodeCandidate[dnd.ItemEventList]("dnd item event list", content)
|
value, err := candidatejson.DecodeCandidate[dnd.ItemOccurrenceList]("dnd item occurrence list", content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dnd.ItemEventList{}, err
|
return dnd.ItemOccurrenceList{}, err
|
||||||
}
|
}
|
||||||
return cloneList(value), nil
|
return cloneList(value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.ItemEventList) error {
|
func validate(value dnd.ItemOccurrenceList) error {
|
||||||
if value.Events == nil {
|
if value.Occurrences == nil {
|
||||||
return fmt.Errorf("events must be present")
|
return fmt.Errorf("events must be present")
|
||||||
}
|
}
|
||||||
for index, event := range value.Events {
|
for index, event := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("events[%d]", index)
|
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||||
|
if strings.TrimSpace(event.ItemID) == "" {
|
||||||
|
return fmt.Errorf("%s.item_id must not be empty", prefix)
|
||||||
|
}
|
||||||
if strings.TrimSpace(event.Name) == "" {
|
if strings.TrimSpace(event.Name) == "" {
|
||||||
return fmt.Errorf("%s.name must not be empty", prefix)
|
return fmt.Errorf("%s.name must not be empty", prefix)
|
||||||
}
|
}
|
||||||
@@ -127,19 +130,19 @@ func validate(value dnd.ItemEventList) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneList(value dnd.ItemEventList) dnd.ItemEventList {
|
func cloneList(value dnd.ItemOccurrenceList) dnd.ItemOccurrenceList {
|
||||||
if value.Events == nil {
|
if value.Occurrences == nil {
|
||||||
return dnd.ItemEventList{}
|
return dnd.ItemOccurrenceList{}
|
||||||
}
|
}
|
||||||
cloned := dnd.ItemEventList{Events: make([]dnd.ItemEvent, len(value.Events))}
|
cloned := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, len(value.Occurrences))}
|
||||||
for index, event := range value.Events {
|
for index, event := range value.Occurrences {
|
||||||
cloned.Events[index] = event
|
cloned.Occurrences[index] = event
|
||||||
if event.Quantity != nil {
|
if event.Quantity != nil {
|
||||||
quantity := *event.Quantity
|
quantity := *event.Quantity
|
||||||
cloned.Events[index].Quantity = &quantity
|
cloned.Occurrences[index].Quantity = &quantity
|
||||||
}
|
}
|
||||||
if event.SourceRefs != nil {
|
if event.SourceRefs != nil {
|
||||||
cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
|
cloned.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validList() dnd.ItemEventList {
|
func validList() dnd.ItemOccurrenceList {
|
||||||
quantity := 12
|
quantity := 12
|
||||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{
|
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Hidden Cache", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)},
|
||||||
{Name: "Hidden Cache", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)},
|
{ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)},
|
||||||
{Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)},
|
{ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: "party", SourceRefs: refs(3, 3)},
|
||||||
{Name: "Torch", Kind: dnd.ItemEventKindLost, From: "party", SourceRefs: refs(3, 3)},
|
{ItemID: "item", Name: "Healing Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: "party", SourceRefs: refs(4, 4)},
|
||||||
{Name: "Healing Potion", Kind: dnd.ItemEventKindConsumed, From: "party", SourceRefs: refs(4, 4)},
|
{ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||||
{Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,18 +41,18 @@ func TestCodecRoundTripAndIdentities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
schema := codec.Schema()
|
schema := codec.Schema()
|
||||||
if codec.Kind() != dnd.ItemEventListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
if codec.Kind() != dnd.ItemOccurrenceListKind || 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)
|
t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema)
|
||||||
}
|
}
|
||||||
registry := pipeline.NewArtifactCodecRegistry()
|
registry := pipeline.NewArtifactCodecRegistry()
|
||||||
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
spec, ok := registry.Spec(dnd.ItemEventListKind)
|
spec, ok := registry.Spec(dnd.ItemOccurrenceListKind)
|
||||||
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||||
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||||
}
|
}
|
||||||
if _, err := registry.Encode(dnd.ItemEventListKind, dnd.NPCRegistry{}); err == nil {
|
if _, err := registry.Encode(dnd.ItemOccurrenceListKind, dnd.NPCRegistry{}); err == nil {
|
||||||
t.Fatal("Encode() error = nil, want exact type rejection")
|
t.Fatal("Encode() error = nil, want exact type rejection")
|
||||||
} else {
|
} else {
|
||||||
var typeErr *pipeline.ArtifactCodecTypeError
|
var typeErr *pipeline.ArtifactCodecTypeError
|
||||||
@@ -65,13 +64,13 @@ func TestCodecRoundTripAndIdentities(t *testing.T) {
|
|||||||
|
|
||||||
func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
|
func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
|
||||||
codec := New()
|
codec := New()
|
||||||
empty := dnd.ItemEventList{Events: []dnd.ItemEvent{}}
|
empty := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}
|
||||||
if content, err := codec.Encode(empty); err != nil || string(content) != `{"events":[]}` {
|
if content, err := codec.Encode(empty); err != nil || string(content) != `{"occurrences":[]}` {
|
||||||
t.Fatalf("Encode() = %s, %v", content, err)
|
t.Fatalf("Encode() = %s, %v", content, err)
|
||||||
}
|
}
|
||||||
zero := 0
|
zero := 0
|
||||||
candidate := dnd.ItemEventList{Events: []dnd.ItemEvent{{
|
candidate := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
|
||||||
Name: " ", Kind: dnd.ItemEventKindTransferred, Quantity: &zero, From: "party", To: "Party",
|
Name: " ", Kind: dnd.ItemOccurrenceKindTransferred, Quantity: &zero, From: "party", To: "Party",
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
|
SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
|
||||||
}}}
|
}}}
|
||||||
content, err := codec.EncodeCandidate(candidate)
|
content, err := codec.EncodeCandidate(candidate)
|
||||||
@@ -88,15 +87,15 @@ func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) {
|
func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) {
|
||||||
validJSON := `{"events":[{"name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
validJSON := `{"occurrences":[{"item_id":"ring","name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name, raw, want string
|
name, raw, want string
|
||||||
}{
|
}{
|
||||||
{"malformed", `{`, "decode dnd item event list"},
|
{"malformed", `{`, "decode dnd item occurrence list"},
|
||||||
{"unknown top level", `{"events":[],"unexpected":true}`, "unknown field"},
|
{"unknown top level", `{"occurrences":[],"unexpected":true}`, "unknown field"},
|
||||||
{"unknown event field", strings.Replace(validJSON, `"to":"party"`, `"to":"party","unexpected":true`, 1), "unknown field"},
|
{"unknown event field", strings.Replace(validJSON, `"to":"party"`, `"to":"party","unexpected":true`, 1), "unknown field"},
|
||||||
{"unknown reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
{"unknown reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
||||||
{"trailing", `{"events":[]} {}`, "multiple JSON values"},
|
{"trailing", `{"occurrences":[]} {}`, "multiple JSON values"},
|
||||||
{"missing list", `{}`, "events must be present"},
|
{"missing list", `{}`, "events must be present"},
|
||||||
{"zero quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":0,"to":"party"`, 1), "quantity must be positive"},
|
{"zero quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":0,"to":"party"`, 1), "quantity must be positive"},
|
||||||
{"negative quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":-1,"to":"party"`, 1), "quantity must be positive"},
|
{"negative quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":-1,"to":"party"`, 1), "quantity must be positive"},
|
||||||
@@ -125,13 +124,13 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if decoded.Events[1].Quantity == value.Events[1].Quantity || &decoded.Events[1].SourceRefs[0] == &value.Events[1].SourceRefs[0] {
|
if decoded.Occurrences[1].Quantity == value.Occurrences[1].Quantity || &decoded.Occurrences[1].SourceRefs[0] == &value.Occurrences[1].SourceRefs[0] {
|
||||||
t.Fatal("DecodeCandidate() retained caller-owned event fields")
|
t.Fatal("DecodeCandidate() retained caller-owned event fields")
|
||||||
}
|
}
|
||||||
*decoded.Events[1].Quantity = 99
|
*decoded.Occurrences[1].Quantity = 99
|
||||||
decoded.Events[1].SourceRefs[0].SourceID = "changed"
|
decoded.Occurrences[1].SourceRefs[0].SourceID = "changed"
|
||||||
if *value.Events[1].Quantity != 12 || value.Events[1].SourceRefs[0].SourceID != "session" {
|
if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" {
|
||||||
t.Fatal("decoded item event aliases input")
|
t.Fatal("decoded item occurrence aliases input")
|
||||||
}
|
}
|
||||||
|
|
||||||
first := codec.Schema()
|
first := codec.Schema()
|
||||||
@@ -141,7 +140,7 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
metadata := codec.Metadata(value)
|
metadata := codec.Metadata(value)
|
||||||
metadata["payload"] = bytes.Repeat([]byte("x"), 10)
|
metadata["payload"] = bytes.Repeat([]byte("x"), 10)
|
||||||
if next := codec.Metadata(value); len(next) != 1 || next["event_count"] != len(value.Events) {
|
if next := codec.Metadata(value); len(next) != 1 || next["occurrence_count"] != len(value.Occurrences) {
|
||||||
t.Fatalf("Metadata() = %#v", next)
|
t.Fatalf("Metadata() = %#v", next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
type orderedItemEventResponse struct {
|
type orderedItemOccurrenceResponse struct {
|
||||||
value itemEventResponse
|
value itemOccurrenceResponse
|
||||||
earliest int
|
earliest int
|
||||||
hasEvidence bool
|
hasEvidence bool
|
||||||
}
|
}
|
||||||
@@ -18,11 +19,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
|||||||
if response == nil {
|
if response == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ordered := make([]orderedItemEventResponse, len(response.Events))
|
ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences))
|
||||||
for index := range response.Events {
|
for index := range response.Occurrences {
|
||||||
earliest, hasEvidence := canonicalizeItemEvent(&response.Events[index], order, sourceID)
|
earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID)
|
||||||
ordered[index] = orderedItemEventResponse{
|
ordered[index] = orderedItemOccurrenceResponse{
|
||||||
value: response.Events[index],
|
value: response.Occurrences[index],
|
||||||
earliest: earliest,
|
earliest: earliest,
|
||||||
hasEvidence: hasEvidence,
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
@@ -37,38 +38,43 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
|||||||
return ordered[left].earliest < ordered[right].earliest
|
return ordered[left].earliest < ordered[right].earliest
|
||||||
})
|
})
|
||||||
for index := range ordered {
|
for index := range ordered {
|
||||||
response.Events[index] = ordered[index].value
|
response.Occurrences[index] = ordered[index].value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeItemEvent(event *itemEventResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
func canonicalizeItemOccurrence(event *itemOccurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||||
if event == nil {
|
if event == nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
refs := order.Canonicalize(itemEventSourceRefs(event.SourceRefs, sourceID))
|
refs := order.Canonicalize(itemOccurrenceSourceRefs(event.SourceRefs, sourceID))
|
||||||
event.SourceRefs = itemEventResponseRefs(refs)
|
event.SourceRefs = itemOccurrenceResponseRefs(refs)
|
||||||
return order.EarliestValid(refs)
|
return order.EarliestValid(refs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalItemEventList(response extractionResponse, sourceID string) dnd.ItemEventList {
|
func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) dnd.ItemOccurrenceList {
|
||||||
if response.Events == nil {
|
if response.Occurrences == nil {
|
||||||
return dnd.ItemEventList{}
|
return dnd.ItemOccurrenceList{}
|
||||||
}
|
}
|
||||||
events := make([]dnd.ItemEvent, len(response.Events))
|
occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences))
|
||||||
for index, event := range response.Events {
|
for _, event := range response.Occurrences {
|
||||||
events[index] = dnd.ItemEvent{
|
item, found := registry.LookupID(event.ItemID)
|
||||||
|
if !found || item.Name != event.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
occurrences = append(occurrences, dnd.ItemOccurrence{
|
||||||
|
ItemID: event.ItemID,
|
||||||
Name: event.Name,
|
Name: event.Name,
|
||||||
Kind: dnd.ItemEventKind(event.Kind),
|
Kind: dnd.ItemOccurrenceKind(event.Kind),
|
||||||
Quantity: cloneQuantity(event.Quantity),
|
Quantity: cloneQuantity(event.Quantity),
|
||||||
From: event.From,
|
From: event.From,
|
||||||
To: event.To,
|
To: event.To,
|
||||||
SourceRefs: itemEventSourceRefs(event.SourceRefs, sourceID),
|
SourceRefs: itemOccurrenceSourceRefs(event.SourceRefs, sourceID),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
return dnd.ItemEventList{Events: events}
|
return dnd.ItemOccurrenceList{Occurrences: occurrences}
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemEventSourceRefs(refs []itemEventSourceRefResponse, sourceID string) []source.SourceRef {
|
func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
||||||
if refs == nil {
|
if refs == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -79,13 +85,13 @@ func itemEventSourceRefs(refs []itemEventSourceRefResponse, sourceID string) []s
|
|||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemEventResponseRefs(refs []source.SourceRef) []itemEventSourceRefResponse {
|
func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse {
|
||||||
if refs == nil {
|
if refs == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
values := make([]itemEventSourceRefResponse, len(refs))
|
values := make([]itemOccurrenceSourceRefResponse, len(refs))
|
||||||
for index, ref := range refs {
|
for index, ref := range refs {
|
||||||
values[index] = itemEventSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID}
|
values[index] = itemOccurrenceSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID}
|
||||||
}
|
}
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
// Package itemevents extracts source-grounded D&D item events.
|
// Package itemevents extracts source-grounded D&D item occurrences.
|
||||||
package itemevents
|
package itemevents
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Key = "dnd/item-events"
|
const Key = "dnd/item-events"
|
||||||
|
|
||||||
|
const (
|
||||||
|
ItemRegistryReferenceSlot = itemregistry.ReferenceSlot
|
||||||
|
ItemRegistryMaxBytes = itemregistry.MaxBytes
|
||||||
|
)
|
||||||
|
|
||||||
const mappingPolicy = "dnd.item_events.extract_mapping.v1"
|
const mappingPolicy = "dnd.item_events.extract_mapping.v1"
|
||||||
|
|
||||||
var requiredCapabilities = []string{
|
var requiredCapabilities = []string{
|
||||||
@@ -32,10 +39,20 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func referenceSlots() []contracts.ReferenceSlot {
|
func referenceSlots() []contracts.ReferenceSlot {
|
||||||
return shared.ReferenceSlots(referenceSlotDescriptions)
|
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||||
|
slots = append(slots, contracts.ReferenceSlot{
|
||||||
|
Name: ItemRegistryReferenceSlot,
|
||||||
|
Description: "Required normalized item registry used only for item identity grounding, never as occurrence evidence.",
|
||||||
|
Required: true,
|
||||||
|
AcceptedMediaTypes: []string{"application/json"},
|
||||||
|
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.ItemRegistryKind},
|
||||||
|
MaxBytes: ItemRegistryMaxBytes,
|
||||||
|
})
|
||||||
|
sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name })
|
||||||
|
return slots
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ contracts.Extractor[dnd.ItemEventList] = (*Extractor)(nil)
|
var _ contracts.Extractor[dnd.ItemOccurrenceList] = (*Extractor)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
||||||
|
|
||||||
@@ -43,6 +60,7 @@ type Options struct{}
|
|||||||
|
|
||||||
type Extractor struct {
|
type Extractor struct {
|
||||||
llm contracts.StructuredLLMClient
|
llm contracts.StructuredLLMClient
|
||||||
|
itemResolver *itemregistry.Resolver
|
||||||
promptSHA string
|
promptSHA string
|
||||||
responseSchemaSHA string
|
responseSchemaSHA string
|
||||||
}
|
}
|
||||||
@@ -54,6 +72,14 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
|
|||||||
if len(references) > 1 {
|
if len(references) > 1 {
|
||||||
return nil, extractorErrorf("at most one reference set may be supplied")
|
return nil, extractorErrorf("at most one reference set may be supplied")
|
||||||
}
|
}
|
||||||
|
var referenceSet contracts.ReferenceSet
|
||||||
|
if len(references) == 1 {
|
||||||
|
referenceSet = references[0]
|
||||||
|
}
|
||||||
|
itemResolver, err := itemregistry.NewResolver(referenceSet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, extractorErrorf("prepare item registry prompt input: %w", err)
|
||||||
|
}
|
||||||
promptSHA, err := promptAssetMetadata()
|
promptSHA, err := promptAssetMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, extractorErrorf("load prompt metadata: %w", err)
|
return nil, extractorErrorf("load prompt metadata: %w", err)
|
||||||
@@ -62,7 +88,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, extractorErrorf("load response schema: %w", err)
|
return nil, extractorErrorf("load response schema: %w", err)
|
||||||
}
|
}
|
||||||
return &Extractor{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
|
return &Extractor{llm: llmClient, itemResolver: itemResolver, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Extractor) Key() string { return Key }
|
func (e *Extractor) Key() string { return Key }
|
||||||
@@ -73,7 +99,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
|||||||
if e == nil {
|
if e == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return map[string]any{
|
metadata := map[string]any{
|
||||||
"prompt_id": PromptID,
|
"prompt_id": PromptID,
|
||||||
"prompt_version": SchemaVersion,
|
"prompt_version": SchemaVersion,
|
||||||
"prompt_sha256": e.promptSHA,
|
"prompt_sha256": e.promptSHA,
|
||||||
@@ -84,6 +110,12 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
|||||||
"response_schema_sha256": e.responseSchemaSHA,
|
"response_schema_sha256": e.responseSchemaSHA,
|
||||||
"mapping_policy": mappingPolicy,
|
"mapping_policy": mappingPolicy,
|
||||||
}
|
}
|
||||||
|
seeded := e.itemResolver.Seeded()
|
||||||
|
if seeded.Bound() {
|
||||||
|
metadata["item_registry_digest"] = seeded.Digest()
|
||||||
|
metadata["item_count"] = seeded.Count()
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
@@ -94,35 +126,41 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
{Name: "prompt", Value: e.promptSHA},
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
{Name: "mapping_policy", Value: mappingPolicy},
|
{Name: "mapping_policy", Value: mappingPolicy},
|
||||||
|
{Name: "item_registry", Value: e.itemResolver.Seeded().ProjectionDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemEventList], error) {
|
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemOccurrenceList], error) {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("extractor must not be nil")
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("extractor must not be nil")
|
||||||
}
|
}
|
||||||
if e.llm == nil {
|
if e.llm == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("LLM client must not be nil")
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("LLM client must not be nil")
|
||||||
}
|
}
|
||||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("%w", err)
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
order := shared.NewSourceRefOrder(req.Source)
|
order := shared.NewSourceRefOrder(req.Source)
|
||||||
|
|
||||||
|
registry, err := e.itemResolver.Resolve(req.References)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("resolve item registry: %w", err)
|
||||||
|
}
|
||||||
|
if !registry.Bound() {
|
||||||
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("item registry reference is required")
|
||||||
|
}
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
|
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||||
|
inputs[ItemRegistryReferenceSlot] = registry.PromptInput()
|
||||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: Key,
|
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
|
||||||
PromptID: PromptID,
|
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
|
||||||
PromptVersion: SchemaVersion,
|
|
||||||
ProfileID: req.LLMProfile,
|
|
||||||
SessionID: req.SessionID,
|
|
||||||
Inputs: shared.PromptInputs(sourceInput, req.References),
|
|
||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response, order, req.Source.ID)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{Value: canonicalItemEventList(response, req.Source.ID)}, nil
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID, registry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
@@ -132,13 +170,13 @@ func ModuleSpec() pipeline.ModuleSpec {
|
|||||||
ExecutionClass: contracts.ExecutionClassLLMBacked,
|
ExecutionClass: contracts.ExecutionClassLLMBacked,
|
||||||
Requires: append([]string(nil), requiredCapabilities...),
|
Requires: append([]string(nil), requiredCapabilities...),
|
||||||
Provides: append([]string(nil), providedCapabilities...),
|
Provides: append([]string(nil), providedCapabilities...),
|
||||||
ArtifactKind: dnd.ItemEventListKind,
|
ArtifactKind: dnd.ItemOccurrenceListKind,
|
||||||
ReferenceSlots: referenceSlots(),
|
ReferenceSlots: referenceSlots(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ExtractorRegistry) error {
|
func Register(registry *pipeline.ExtractorRegistry) error {
|
||||||
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemEventList], error) {
|
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -160,5 +198,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractorErrorf(format string, args ...any) error {
|
func extractorErrorf(format string, args ...any) error {
|
||||||
return fmt.Errorf("dnd item events extractor: "+format, args...)
|
return fmt.Errorf("dnd item occurrences extractor: "+format, args...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,161 +2,61 @@ package itemevents
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractMapsAllEventKindsAndCanonicalizesEvidence(t *testing.T) {
|
func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
||||||
quantity := 20
|
id := itemidentity.DeriveID("Torch")
|
||||||
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{
|
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
||||||
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(5, 5)},
|
{ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
||||||
{Name: "Moonblade", Kind: "transferred", From: "Aria", To: "Borin", SourceRefs: responseRefs(3, 3)},
|
{ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
|
||||||
{Name: "Hidden Cache", Kind: "discovered", SourceRefs: []itemEventSourceRefResponse{{StartSegment: 1, EndSegment: 1}, {StartSegment: 1, EndSegment: 1}}},
|
{ItemID: "torch", Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(3, 3)},
|
||||||
{Name: "Gold Pieces", Kind: "acquired", Quantity: &quantity, To: "party", SourceRefs: responseRefs(2, 2)},
|
|
||||||
{Name: "Healing Potion", Kind: "consumed", From: "party", SourceRefs: responseRefs(4, 4)},
|
|
||||||
}}}
|
|
||||||
|
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract() error = %v", err)
|
|
||||||
}
|
|
||||||
if got := []dnd.ItemEventKind{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.ItemEventKind{
|
|
||||||
dnd.ItemEventKindDiscovered, dnd.ItemEventKindAcquired, dnd.ItemEventKindTransferred, dnd.ItemEventKindConsumed, dnd.ItemEventKindLost,
|
|
||||||
}) {
|
|
||||||
t.Fatalf("event kinds = %#v, want source order", got)
|
|
||||||
}
|
|
||||||
acquired := result.Value.Events[1]
|
|
||||||
if acquired.Name != "Gold Pieces" || acquired.To != "party" || acquired.Quantity == nil || *acquired.Quantity != 20 {
|
|
||||||
t.Fatalf("acquired event = %#v", acquired)
|
|
||||||
}
|
|
||||||
if result.Value.Events[0].Quantity != nil || result.Value.Events[0].From != "" || result.Value.Events[0].To != "" {
|
|
||||||
t.Fatalf("discovered event = %#v, want omitted optional fields", result.Value.Events[0])
|
|
||||||
}
|
|
||||||
if refs := result.Value.Events[0].SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}) {
|
|
||||||
t.Fatalf("source refs = %#v, want source ID attachment and deduplication", refs)
|
|
||||||
}
|
|
||||||
if acquired.Quantity == client.response.Events[3].Quantity {
|
|
||||||
t.Fatal("mapped quantity aliases private response")
|
|
||||||
}
|
|
||||||
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 != "profile-item-events" || request.SessionID != "session-123" {
|
|
||||||
t.Fatalf("LLM request identity = %#v", request)
|
|
||||||
}
|
|
||||||
if transcript := request.Inputs["transcript"]; string(transcript.Content) != string(extractionRequest().Chunk.Content) || transcript.Name != "transcript" {
|
|
||||||
t.Fatalf("transcript input = %#v", transcript)
|
|
||||||
}
|
|
||||||
for _, name := range []string{"players", "party", "glossary"} {
|
|
||||||
if input := request.Inputs[name]; string(input.Content) != " " {
|
|
||||||
t.Fatalf("absent %s input = %#v, want retained empty prompt material", name, input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractPreservesInvalidCandidatesAndEmptyResults(t *testing.T) {
|
|
||||||
client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"","kind":"transferred","quantity":0,"from":"party","to":"Party","source_refs":[{"start_segment":99,"end_segment":-1}]}]}`)}
|
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract() error = %v", err)
|
|
||||||
}
|
|
||||||
event := result.Value.Events[0]
|
|
||||||
if event.Name != "" || event.Kind != "transferred" || event.Quantity == nil || *event.Quantity != 0 || event.From != "party" || event.To != "Party" {
|
|
||||||
t.Fatalf("invalid candidate = %#v, want values preserved", event)
|
|
||||||
}
|
|
||||||
if refs := event.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99, EndUnitID: -1}) {
|
|
||||||
t.Fatalf("source refs = %#v, want invalid range preserved", refs)
|
|
||||||
}
|
|
||||||
|
|
||||||
empty, err := newExtractor(t, &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}}).Extract(context.Background(), extractionRequest())
|
|
||||||
if err != nil || empty.Value.Events == nil || len(empty.Value.Events) != 0 {
|
|
||||||
t.Fatalf("empty provider result = %#v, %v; want valid empty list", empty.Value, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractMapsNullableResponseFieldsToAbsentArtifactFields(t *testing.T) {
|
|
||||||
client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"Hidden Cache","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(result.Value.Events) != 1 {
|
|
||||||
t.Fatalf("events = %#v, want one", result.Value.Events)
|
|
||||||
}
|
|
||||||
event := result.Value.Events[0]
|
|
||||||
if event.Quantity != nil || event.From != "" || event.To != "" {
|
|
||||||
t.Fatalf("nullable response fields mapped to artifact values: %#v", event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractUsesSourceDocumentOrderForCandidates(t *testing.T) {
|
|
||||||
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{
|
|
||||||
{Name: "Later", Kind: "discovered", SourceRefs: responseRefs(10, 10)},
|
|
||||||
{Name: "Earlier", Kind: "discovered", SourceRefs: responseRefs(100, 100)},
|
|
||||||
}}}
|
}}}
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.Source.Units = []source.SourceUnit{{ID: 100}, {ID: 10}}
|
req.References = itemRegistryReferences(t)
|
||||||
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||||
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 10}
|
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := []string{result.Value.Events[0].Name, result.Value.Events[1].Name}; !reflect.DeepEqual(got, []string{"Earlier", "Later"}) {
|
if len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].ItemID != id || result.Value.Occurrences[0].Name != "Torch" {
|
||||||
t.Fatalf("event order = %#v, want source document order", got)
|
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
||||||
|
}
|
||||||
|
input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
|
||||||
|
if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" {
|
||||||
|
t.Fatalf("registry prompt input = %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRequiresItemRegistry(t *testing.T) {
|
||||||
|
_, err := newExtractor(t, &fakeItemOccurrencesLLMClient{}).Extract(context.Background(), extractionRequest())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Extract() error = nil, want required registry error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPreservesNullableFields(t *testing.T) {
|
||||||
|
id := itemidentity.DeriveID("Torch")
|
||||||
|
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"item_id":"` + id + `","name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = itemRegistryReferences(t)
|
||||||
|
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||||
|
if err != nil || len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].Quantity != nil || result.Value.Occurrences[0].From != "" || result.Value.Occurrences[0].To != "" {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractUsesOnlySupportedPromptInputs(t *testing.T) {
|
func TestExtractUsesOnlySupportedPromptInputs(t *testing.T) {
|
||||||
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}}
|
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}}
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
req.References = itemRegistryReferences(t)
|
||||||
"players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Aria")}}},
|
req.References.Slots["unrelated"] = contracts.ResolvedReferenceSlot{Slot: contracts.ReferenceSlot{Name: "unrelated"}, Items: []contracts.ReferenceItem{{SlotName: "unrelated", Content: []byte("ignored")}}}
|
||||||
"party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria: ranger")}}},
|
if _, err := newExtractor(t, client, req.References).Extract(context.Background(), req); err != nil {
|
||||||
"glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Moonblade: heirloom")}}},
|
|
||||||
"npcs": {Slot: contracts.ReferenceSlot{Name: "npc_registry"}, Items: []contracts.ReferenceItem{{SlotName: "npc_registry", Content: []byte("must not be used")}}},
|
|
||||||
}}
|
|
||||||
if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
inputs := client.requests[0].Inputs
|
if _, ok := client.requests[0].Inputs["unrelated"]; ok {
|
||||||
if string(inputs["players"].Content) != "Dana: Aria" || string(inputs["party"].Content) != "Aria: ranger" || string(inputs["glossary"].Content) != "Moonblade: heirloom" {
|
t.Fatalf("unexpected prompt input: %#v", client.requests[0].Inputs)
|
||||||
t.Fatalf("reference inputs = %#v", inputs)
|
|
||||||
}
|
|
||||||
if _, ok := inputs["npcs"]; ok {
|
|
||||||
t.Fatalf("unexpected generated-lane input: %#v", inputs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRetainsLocalErrorContext(t *testing.T) {
|
|
||||||
request := extractionRequest()
|
|
||||||
extractor := newExtractor(t, &fakeItemEventsLLMClient{})
|
|
||||||
var nilExtractor *Extractor
|
|
||||||
request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
extractor *Extractor
|
|
||||||
req contracts.TypedExtractionRequest
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"nil extractor", nilExtractor, extractionRequest(), "extractor"},
|
|
||||||
{"nil client", &Extractor{}, extractionRequest(), "LLM client"},
|
|
||||||
{"preflight", extractor, request, "must match chunk"},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd item events") || !strings.Contains(err.Error(), test.want) {
|
|
||||||
t.Fatalf("Extract() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if _, err := newExtractor(t, &fakeItemEventsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), extractionRequest()); err == nil || !strings.Contains(err.Error(), "provider unavailable") {
|
|
||||||
t.Fatalf("provider error = %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
package itemevents
|
package itemevents
|
||||||
|
|
||||||
type extractionResponse struct {
|
type extractionResponse struct {
|
||||||
Events []itemEventResponse `json:"events"`
|
Occurrences []itemOccurrenceResponse `json:"occurrences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type itemEventResponse struct {
|
type itemOccurrenceResponse struct {
|
||||||
Name string `json:"name"`
|
ItemID string `json:"item_id"`
|
||||||
Kind string `json:"kind"`
|
Name string `json:"name"`
|
||||||
Quantity *int `json:"quantity,omitempty"`
|
Kind string `json:"kind"`
|
||||||
From string `json:"from,omitempty"`
|
Quantity *int `json:"quantity,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
SourceRefs []itemEventSourceRefResponse `json:"source_refs"`
|
To string `json:"to,omitempty"`
|
||||||
|
SourceRefs []itemOccurrenceSourceRefResponse `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type itemEventSourceRefResponse struct {
|
type itemOccurrenceSourceRefResponse struct {
|
||||||
StartSegment int `json:"start_segment"`
|
StartSegment int `json:"start_segment"`
|
||||||
EndSegment int `json:"end_segment"`
|
EndSegment int `json:"end_segment"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ var promptAssetManifest = shared.PromptAssetManifest{
|
|||||||
ModuleFiles: []promptfs.ModulePromptFile{
|
ModuleFiles: []promptfs.ModulePromptFile{
|
||||||
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
|
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
|
||||||
{Name: "instructions.md", Path: "prompts/instructions.md"},
|
{Name: "instructions.md", Path: "prompts/instructions.md"},
|
||||||
|
{Name: "item-registry.md", Path: "prompts/item-registry.md"},
|
||||||
},
|
},
|
||||||
SharedFiles: []string{
|
SharedFiles: []string{
|
||||||
"common-dnd-system.md",
|
"common-dnd-system.md",
|
||||||
@@ -43,7 +44,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
|||||||
}
|
}
|
||||||
promptFS, err := promptAssetManifest.PromptFS(assets)
|
promptFS, err := promptAssetManifest.PromptFS(assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("prepare item event prompt assets: %w", err)
|
return fmt.Errorf("prepare item occurrence prompt assets: %w", err)
|
||||||
}
|
}
|
||||||
if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil {
|
if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) {
|
func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
|
||||||
registry := llm.NewAssetRegistry()
|
registry := llm.NewAssetRegistry()
|
||||||
if err := RegisterPromptAssets(registry); err != nil {
|
if err := RegisterPromptAssets(registry); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -30,16 +30,17 @@ func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) {
|
|||||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "item-events-test-profile",
|
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "item-events-test-profile",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`),
|
"transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`),
|
||||||
"players": promptkit.Inline("item-event-player"),
|
"players": promptkit.Inline("item-event-player"),
|
||||||
"party": promptkit.Inline(" "),
|
"party": promptkit.Inline(" "),
|
||||||
"glossary": promptkit.Inline(" "),
|
"glossary": promptkit.Inline(" "),
|
||||||
|
"item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_events_llm.v1.json" {
|
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_occurrences_llm.v1.json" {
|
||||||
t.Fatalf("prepared prompt = %#v", prepared)
|
t.Fatalf("prepared prompt = %#v", prepared)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,12 +50,12 @@ func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) {
|
|||||||
if err != nil || !strings.HasPrefix(hash, "sha256:") {
|
if err != nil || !strings.HasPrefix(hash, "sha256:") {
|
||||||
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
|
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
|
||||||
}
|
}
|
||||||
metadata := newExtractor(t, &fakeItemEventsLLMClient{}).ManifestMetadata()
|
metadata := newExtractor(t, &fakeItemOccurrencesLLMClient{}).ManifestMetadata()
|
||||||
payload, err := json.Marshal(metadata)
|
payload, err := json.Marshal(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{"common-dnd-system", "dnd_item_events_llm.v1.json"} {
|
for _, forbidden := range []string{"common-dnd-system", "dnd_item_occurrences_llm.v1.json"} {
|
||||||
if strings.Contains(string(payload), forbidden) {
|
if strings.Contains(string(payload), forbidden) {
|
||||||
t.Fatalf("metadata leaked raw asset content %q: %s", forbidden, payload)
|
t.Fatalf("metadata leaked raw asset content %q: %s", forbidden, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,64 +2,42 @@ package itemevents
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/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"
|
||||||
|
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
|
||||||
|
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConstructorSpecOptionsAndMetadata(t *testing.T) {
|
func itemRegistryReferences(t *testing.T) contracts.ReferenceSet {
|
||||||
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
|
t.Helper()
|
||||||
t.Fatalf("New(nil) error = %v", err)
|
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: itemidentity.DeriveID("Torch"), Name: "Torch", SourceRefs: testSourceRefs()}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := New(&fakeItemEventsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||||
t.Fatalf("New() error = %v", err)
|
}
|
||||||
|
|
||||||
|
func testSourceRefs() []source.SourceRef {
|
||||||
|
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleSpecDeclaresRequiredRegistry(t *testing.T) {
|
||||||
|
spec := ModuleSpec()
|
||||||
|
var slot contracts.ReferenceSlot
|
||||||
|
for _, candidate := range spec.ReferenceSlots {
|
||||||
|
if candidate.Name == ItemRegistryReferenceSlot {
|
||||||
|
slot = candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
want := pipeline.ModuleSpec{
|
if !slot.Required || !reflect.DeepEqual(slot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.ItemRegistryKind}) || slot.MaxBytes != ItemRegistryMaxBytes {
|
||||||
Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.item_events"}, ArtifactKind: dnd.ItemEventListKind,
|
t.Fatalf("registry slot = %#v", slot)
|
||||||
ReferenceSlots: []contracts.ReferenceSlot{
|
|
||||||
{Name: "glossary", Description: referenceSlotDescriptions.Glossary, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
|
||||||
{Name: "party", Description: referenceSlotDescriptions.Party, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
|
||||||
{Name: "players", Description: referenceSlotDescriptions.Players, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
|
||||||
{Name: "roster", Description: referenceSlotDescriptions.Roster, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
|
||||||
}
|
|
||||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
|
||||||
t.Fatalf("DecodeOptions() error = %v", err)
|
|
||||||
}
|
}
|
||||||
registry := pipeline.NewExtractorRegistry()
|
registry := pipeline.NewExtractorRegistry()
|
||||||
if err := Register(registry); err != nil {
|
if err := Register(registry); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, ok := registry.Spec(Key); !ok || !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("registry spec = %#v, %t", got, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
extractor := newExtractor(t, &fakeItemEventsLLMClient{})
|
|
||||||
metadata := extractor.ManifestMetadata()
|
|
||||||
for key, want := range map[string]string{
|
|
||||||
"prompt_id": PromptID, "prompt_version": SchemaVersion, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
|
|
||||||
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, "mapping_policy": mappingPolicy,
|
|
||||||
} {
|
|
||||||
if metadata[key] != want {
|
|
||||||
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, key := range []string{"prompt_sha256", "response_schema_sha256"} {
|
|
||||||
if value, ok := metadata[key].(string); !ok || !strings.HasPrefix(value, "sha256:") {
|
|
||||||
t.Fatalf("metadata[%q] = %#v", key, metadata[key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := extractor.CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{
|
|
||||||
{Name: "prompt", Value: metadata["prompt_sha256"].(string)},
|
|
||||||
{Name: "response_schema", Value: metadata["response_schema_sha256"].(string)},
|
|
||||||
{Name: "mapping_policy", Value: mappingPolicy},
|
|
||||||
}) {
|
|
||||||
t.Fatalf("CheckpointFingerprints() = %#v", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
PromptID = "dnd.item_events"
|
PromptID = "dnd.item_events"
|
||||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_events_llm")
|
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_occurrences_llm")
|
||||||
ResponseSchemaID = "notarius.dnd.item_events.llm"
|
ResponseSchemaID = "notarius.dnd.item_occurrences.llm"
|
||||||
ResponseSchemaName = "notarius_dnd_item_events_llm_v1"
|
ResponseSchemaName = "notarius_dnd_item_occurrences_llm_v1"
|
||||||
SchemaVersion = "v1"
|
SchemaVersion = "v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) {
|
|||||||
ID: ResponseSchemaID,
|
ID: ResponseSchemaID,
|
||||||
Version: SchemaVersion,
|
Version: SchemaVersion,
|
||||||
Name: ResponseSchemaName,
|
Name: ResponseSchemaName,
|
||||||
AssetPath: "schemas/dnd_item_events_llm.v1.json",
|
AssetPath: "schemas/dnd_item_occurrences_llm.v1.json",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
|||||||
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
|
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)
|
t.Fatalf("schema = %#v", schema)
|
||||||
}
|
}
|
||||||
valid := map[string]any{"events": []any{
|
valid := map[string]any{"occurrences": []any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
|
"item_id": "item:sha256:test", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
|
||||||
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
|
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
|
||||||
},
|
},
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
|
"item_id": "item:sha256:test", "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
|
||||||
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
|
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -38,12 +38,12 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
value map[string]any
|
value map[string]any
|
||||||
}{
|
}{
|
||||||
{"missing events", map[string]any{}},
|
{"missing occurrences", map[string]any{}},
|
||||||
{"missing event name", map[string]any{"events": []any{withoutField(responseEvent(), "name")}}},
|
{"missing event name", map[string]any{"occurrences": []any{withoutField(responseEvent(), "name")}}},
|
||||||
{"missing nullable field", map[string]any{"events": []any{withoutField(responseEvent(), "quantity")}}},
|
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseEvent(), "quantity")}}},
|
||||||
{"unknown event field", map[string]any{"events": []any{withField(responseEvent(), "extra", true)}}},
|
{"unknown event field", map[string]any{"occurrences": []any{withField(responseEvent(), "extra", true)}}},
|
||||||
{"unknown reference field", map[string]any{"events": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
|
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
|
||||||
{"noninteger range", map[string]any{"events": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
|
{"noninteger range", map[string]any{"occurrences": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
content, err := json.Marshal(test.value)
|
content, err := json.Marshal(test.value)
|
||||||
@@ -68,7 +68,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
|||||||
|
|
||||||
func responseEvent() map[string]any {
|
func responseEvent() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
|
"item_id": "item:sha256:test", "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ func sourceDocument() *source.SourceDocument {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func responseRefs(start, end int) []itemEventSourceRefResponse {
|
func responseRefs(start, end int) []itemOccurrenceSourceRefResponse {
|
||||||
return []itemEventSourceRefResponse{{StartSegment: start, EndSegment: end}}
|
return []itemOccurrenceSourceRefResponse{{StartSegment: start, EndSegment: end}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
||||||
@@ -61,14 +61,14 @@ func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest)
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeItemEventsLLMClient struct {
|
type fakeItemOccurrencesLLMClient struct {
|
||||||
response extractionResponse
|
response extractionResponse
|
||||||
content []byte
|
content []byte
|
||||||
err error
|
err error
|
||||||
requests []contracts.StructuredCompletionRequest
|
requests []contracts.StructuredCompletionRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *fakeItemEventsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
func (client *fakeItemOccurrencesLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||||
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
||||||
if client.err != nil {
|
if client.err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, client.err
|
return contracts.StructuredCompletionResponse{}, client.err
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
|
|
||||||
const partyHolder = "party"
|
const partyHolder = "party"
|
||||||
|
|
||||||
// SupportedKind reports whether kind is one of the durable item-event kinds.
|
// SupportedKind reports whether kind is one of the durable item occurrence kinds.
|
||||||
func SupportedKind(kind dnd.ItemEventKind) bool {
|
func SupportedKind(kind dnd.ItemOccurrenceKind) bool {
|
||||||
switch kind {
|
switch kind {
|
||||||
case dnd.ItemEventKindDiscovered,
|
case dnd.ItemOccurrenceKindDiscovered,
|
||||||
dnd.ItemEventKindAcquired,
|
dnd.ItemOccurrenceKindAcquired,
|
||||||
dnd.ItemEventKindLost,
|
dnd.ItemOccurrenceKindLost,
|
||||||
dnd.ItemEventKindConsumed,
|
dnd.ItemOccurrenceKindConsumed,
|
||||||
dnd.ItemEventKindTransferred:
|
dnd.ItemOccurrenceKindTransferred:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -45,18 +45,18 @@ func IsPartyHolder(value string) bool { return ComparisonKey(value) == partyHold
|
|||||||
// ValidHolderCombination reports whether the optional holder fields satisfy
|
// ValidHolderCombination reports whether the optional holder fields satisfy
|
||||||
// the durable rules for kind. Blank holders are treated as absent so callers
|
// the durable rules for kind. Blank holders are treated as absent so callers
|
||||||
// can preserve invalid extraction candidates for their owning validators.
|
// can preserve invalid extraction candidates for their owning validators.
|
||||||
func ValidHolderCombination(kind dnd.ItemEventKind, from, to string) bool {
|
func ValidHolderCombination(kind dnd.ItemOccurrenceKind, from, to string) bool {
|
||||||
hasFrom := HolderPresent(from)
|
hasFrom := HolderPresent(from)
|
||||||
hasTo := HolderPresent(to)
|
hasTo := HolderPresent(to)
|
||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case dnd.ItemEventKindDiscovered:
|
case dnd.ItemOccurrenceKindDiscovered:
|
||||||
return !hasFrom && !hasTo
|
return !hasFrom && !hasTo
|
||||||
case dnd.ItemEventKindAcquired:
|
case dnd.ItemOccurrenceKindAcquired:
|
||||||
return !hasFrom && hasTo
|
return !hasFrom && hasTo
|
||||||
case dnd.ItemEventKindLost, dnd.ItemEventKindConsumed:
|
case dnd.ItemOccurrenceKindLost, dnd.ItemOccurrenceKindConsumed:
|
||||||
return hasFrom && !hasTo
|
return hasFrom && !hasTo
|
||||||
case dnd.ItemEventKindTransferred:
|
case dnd.ItemOccurrenceKindTransferred:
|
||||||
return hasFrom && hasTo && !IsPartyHolder(from) && !IsPartyHolder(to) && ComparisonKey(from) != ComparisonKey(to)
|
return hasFrom && hasTo && !IsPartyHolder(from) && !IsPartyHolder(to) && ComparisonKey(from) != ComparisonKey(to)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -93,7 +93,7 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
|||||||
// Less defines the canonical event order. Invalid source references remain
|
// Less defines the canonical event order. Invalid source references remain
|
||||||
// comparable through SourceRefOrder's literal fallback so malformed candidates
|
// comparable through SourceRefOrder's literal fallback so malformed candidates
|
||||||
// are still safe to sort and diagnose.
|
// are still safe to sort and diagnose.
|
||||||
func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
func Less(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||||
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
||||||
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
||||||
if leftHasEvidence != rightHasEvidence {
|
if leftHasEvidence != rightHasEvidence {
|
||||||
@@ -102,6 +102,9 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
|||||||
if leftHasEvidence && leftPosition != rightPosition {
|
if leftHasEvidence && leftPosition != rightPosition {
|
||||||
return leftPosition < rightPosition
|
return leftPosition < rightPosition
|
||||||
}
|
}
|
||||||
|
if left.ItemID != right.ItemID {
|
||||||
|
return left.ItemID < right.ItemID
|
||||||
|
}
|
||||||
if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey {
|
if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey {
|
||||||
return leftKey < rightKey
|
return leftKey < rightKey
|
||||||
}
|
}
|
||||||
@@ -125,8 +128,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
|||||||
|
|
||||||
// ExactEqual reports whether events are exact duplicates after their display
|
// ExactEqual reports whether events are exact duplicates after their display
|
||||||
// fields and evidence have been canonicalized for the supplied source order.
|
// fields and evidence have been canonicalized for the supplied source order.
|
||||||
func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||||
if DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind ||
|
if left.ItemID != right.ItemID || DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind ||
|
||||||
DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) ||
|
DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) ||
|
||||||
(left.Quantity == nil) != (right.Quantity == nil) {
|
(left.Quantity == nil) != (right.Quantity == nil) {
|
||||||
return false
|
return false
|
||||||
@@ -140,8 +143,9 @@ func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
|||||||
// ExactIdentity returns a collision-safe duplicate key after display and
|
// ExactIdentity returns a collision-safe duplicate key after display and
|
||||||
// evidence canonicalization. It is intended for callers that have already
|
// evidence canonicalization. It is intended for callers that have already
|
||||||
// decided the event is eligible for duplicate handling.
|
// decided the event is eligible for duplicate handling.
|
||||||
func ExactIdentity(order shared.SourceRefOrder, event dnd.ItemEvent) string {
|
func ExactIdentity(order shared.SourceRefOrder, event dnd.ItemOccurrence) string {
|
||||||
var key strings.Builder
|
var key strings.Builder
|
||||||
|
writeKeyString(&key, event.ItemID)
|
||||||
writeKeyString(&key, DisplayValue(event.Name))
|
writeKeyString(&key, DisplayValue(event.Name))
|
||||||
writeKeyString(&key, string(event.Kind))
|
writeKeyString(&key, string(event.Kind))
|
||||||
writeKeyString(&key, DisplayValue(event.From))
|
writeKeyString(&key, DisplayValue(event.From))
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import (
|
|||||||
|
|
||||||
func TestValidHolderCombination(t *testing.T) {
|
func TestValidHolderCombination(t *testing.T) {
|
||||||
valid := []struct {
|
valid := []struct {
|
||||||
kind dnd.ItemEventKind
|
kind dnd.ItemOccurrenceKind
|
||||||
from, to string
|
from, to string
|
||||||
}{
|
}{
|
||||||
{dnd.ItemEventKindDiscovered, "", ""},
|
{dnd.ItemOccurrenceKindDiscovered, "", ""},
|
||||||
{dnd.ItemEventKindAcquired, "", "party"},
|
{dnd.ItemOccurrenceKindAcquired, "", "party"},
|
||||||
{dnd.ItemEventKindLost, "party", ""},
|
{dnd.ItemOccurrenceKindLost, "party", ""},
|
||||||
{dnd.ItemEventKindConsumed, "party", ""},
|
{dnd.ItemOccurrenceKindConsumed, "party", ""},
|
||||||
{dnd.ItemEventKindTransferred, "Aria", "Borin"},
|
{dnd.ItemOccurrenceKindTransferred, "Aria", "Borin"},
|
||||||
}
|
}
|
||||||
for _, test := range valid {
|
for _, test := range valid {
|
||||||
if !ValidHolderCombination(test.kind, test.from, test.to) {
|
if !ValidHolderCombination(test.kind, test.from, test.to) {
|
||||||
@@ -27,17 +27,17 @@ func TestValidHolderCombination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invalid := []struct {
|
invalid := []struct {
|
||||||
kind dnd.ItemEventKind
|
kind dnd.ItemOccurrenceKind
|
||||||
from, to string
|
from, to string
|
||||||
}{
|
}{
|
||||||
{dnd.ItemEventKindDiscovered, "Aria", ""},
|
{dnd.ItemOccurrenceKindDiscovered, "Aria", ""},
|
||||||
{dnd.ItemEventKindAcquired, "", ""},
|
{dnd.ItemOccurrenceKindAcquired, "", ""},
|
||||||
{dnd.ItemEventKindLost, "", ""},
|
{dnd.ItemOccurrenceKindLost, "", ""},
|
||||||
{dnd.ItemEventKindConsumed, "", "Borin"},
|
{dnd.ItemOccurrenceKindConsumed, "", "Borin"},
|
||||||
{dnd.ItemEventKindTransferred, "party", "Borin"},
|
{dnd.ItemOccurrenceKindTransferred, "party", "Borin"},
|
||||||
{dnd.ItemEventKindTransferred, "Aria", "Party"},
|
{dnd.ItemOccurrenceKindTransferred, "Aria", "Party"},
|
||||||
{dnd.ItemEventKindTransferred, "Aria", "aria"},
|
{dnd.ItemOccurrenceKindTransferred, "Aria", "aria"},
|
||||||
{dnd.ItemEventKindTransferred, "Åria", "Åria"},
|
{dnd.ItemOccurrenceKindTransferred, "Åria", "Åria"},
|
||||||
{"unsupported", "", ""},
|
{"unsupported", "", ""},
|
||||||
}
|
}
|
||||||
for _, test := range invalid {
|
for _, test := range invalid {
|
||||||
@@ -53,16 +53,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) {
|
|||||||
return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}}
|
return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}}
|
||||||
}
|
}
|
||||||
quantity := func(value int) *int { return &value }
|
quantity := func(value int) *int { return &value }
|
||||||
base := dnd.ItemEvent{Name: "Amulet", Kind: dnd.ItemEventKindAcquired, To: "Borin", SourceRefs: ref(20, 20)}
|
base := dnd.ItemOccurrence{ItemID: "item", Name: "Amulet", Kind: dnd.ItemOccurrenceKindAcquired, To: "Borin", SourceRefs: ref(20, 20)}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
left, right dnd.ItemEvent
|
left, right dnd.ItemOccurrence
|
||||||
}{
|
}{
|
||||||
{"earlier evidence", withRefs(base, ref(10, 10)), base},
|
{"earlier evidence", withRefs(base, ref(10, 10)), base},
|
||||||
{"valid evidence before malformed", base, withRefs(base, ref(999, 999))},
|
{"valid evidence before malformed", base, withRefs(base, ref(999, 999))},
|
||||||
{"normalized name", withName(base, "Amulet"), withName(base, "Blade")},
|
{"normalized name", withName(base, "Amulet"), withName(base, "Blade")},
|
||||||
{"exact trimmed name", withName(base, "Amulet"), withName(base, "amulet")},
|
{"exact trimmed name", withName(base, "Amulet"), withName(base, "amulet")},
|
||||||
{"kind", withKind(base, dnd.ItemEventKindAcquired), withKind(base, dnd.ItemEventKindLost)},
|
{"kind", withKind(base, dnd.ItemOccurrenceKindAcquired), withKind(base, dnd.ItemOccurrenceKindLost)},
|
||||||
{"from presence", withFrom(base, ""), withFrom(base, "Aria")},
|
{"from presence", withFrom(base, ""), withFrom(base, "Aria")},
|
||||||
{"from normalized value", withFrom(base, "Aria"), withFrom(base, "Borin")},
|
{"from normalized value", withFrom(base, "Aria"), withFrom(base, "Borin")},
|
||||||
{"from exact value", withFrom(base, "Aria"), withFrom(base, "aria")},
|
{"from exact value", withFrom(base, "Aria"), withFrom(base, "aria")},
|
||||||
@@ -84,16 +84,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) {
|
|||||||
|
|
||||||
func TestLessAndExactEqualityCanonicalizeEvidence(t *testing.T) {
|
func TestLessAndExactEqualityCanonicalizeEvidence(t *testing.T) {
|
||||||
order := testOrder()
|
order := testOrder()
|
||||||
first := dnd.ItemEvent{
|
first := dnd.ItemOccurrence{ItemID: "item",
|
||||||
Name: " Silver Coin ", Kind: dnd.ItemEventKindAcquired, To: " party ",
|
Name: " Silver Coin ", Kind: dnd.ItemOccurrenceKindAcquired, To: " party ",
|
||||||
SourceRefs: []source.SourceRef{
|
SourceRefs: []source.SourceRef{
|
||||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
second := dnd.ItemEvent{
|
second := dnd.ItemOccurrence{ItemID: "item",
|
||||||
Name: "Silver Coin", Kind: dnd.ItemEventKindAcquired, To: "party",
|
Name: "Silver Coin", Kind: dnd.ItemOccurrenceKindAcquired, To: "party",
|
||||||
SourceRefs: []source.SourceRef{
|
SourceRefs: []source.SourceRef{
|
||||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||||
@@ -136,10 +136,9 @@ func TestSourceReferenceHelpers(t *testing.T) {
|
|||||||
|
|
||||||
func TestLessSortsMalformedReferencesDeterministically(t *testing.T) {
|
func TestLessSortsMalformedReferencesDeterministically(t *testing.T) {
|
||||||
order := testOrder()
|
order := testOrder()
|
||||||
events := []dnd.ItemEvent{
|
events := []dnd.ItemOccurrence{{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}},
|
||||||
{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}},
|
{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}},
|
||||||
{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}},
|
{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||||
{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
|
||||||
}
|
}
|
||||||
sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) })
|
sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) })
|
||||||
if events[0].SourceRefs[0].StartUnitID != 10 || events[1].SourceRefs[0].SourceID != "other" || events[2].SourceRefs[0].StartUnitID != 999 {
|
if events[0].SourceRefs[0].StartUnitID != 10 || events[1].SourceRefs[0].SourceID != "other" || events[2].SourceRefs[0].StartUnitID != 999 {
|
||||||
@@ -151,19 +150,28 @@ func testOrder() shared.SourceRefOrder {
|
|||||||
return shared.NewSourceRefOrder(&source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}})
|
return shared.NewSourceRefOrder(&source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func withName(event dnd.ItemEvent, value string) dnd.ItemEvent { event.Name = value; return event }
|
func withName(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
|
||||||
func withKind(event dnd.ItemEvent, value dnd.ItemEventKind) dnd.ItemEvent {
|
event.Name = value
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
func withKind(event dnd.ItemOccurrence, value dnd.ItemOccurrenceKind) dnd.ItemOccurrence {
|
||||||
event.Kind = value
|
event.Kind = value
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
func withFrom(event dnd.ItemEvent, value string) dnd.ItemEvent { event.From = value; return event }
|
func withFrom(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
|
||||||
func withTo(event dnd.ItemEvent, value string) dnd.ItemEvent { event.To = value; return event }
|
event.From = value
|
||||||
func withoutTo(event dnd.ItemEvent) dnd.ItemEvent { event.To = ""; return event }
|
return event
|
||||||
func withQuantity(event dnd.ItemEvent, value *int) dnd.ItemEvent {
|
}
|
||||||
|
func withTo(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
|
||||||
|
event.To = value
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
func withoutTo(event dnd.ItemOccurrence) dnd.ItemOccurrence { event.To = ""; return event }
|
||||||
|
func withQuantity(event dnd.ItemOccurrence, value *int) dnd.ItemOccurrence {
|
||||||
event.Quantity = value
|
event.Quantity = value
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
func withRefs(event dnd.ItemEvent, value []source.SourceRef) dnd.ItemEvent {
|
func withRefs(event dnd.ItemOccurrence, value []source.SourceRef) dnd.ItemOccurrence {
|
||||||
event.SourceRefs = value
|
event.SourceRefs = value
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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"
|
||||||
itemeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemevents"
|
itemeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemevents"
|
||||||
|
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
)
|
)
|
||||||
@@ -20,85 +21,126 @@ const (
|
|||||||
normalizationPolicy = "dnd.item_events.normalize.v1"
|
normalizationPolicy = "dnd.item_events.normalize.v1"
|
||||||
NormalizationPolicy = normalizationPolicy
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|
||||||
ReasonCodeDisplayNormalized = "item_event_display_normalized"
|
ReasonCodeNameCanonicalized = "item_occurrence_name_canonicalized"
|
||||||
|
ReasonCodeUnknownItemID = "item_occurrence_unknown_item_id"
|
||||||
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||||
ReasonCodeEventsReordered = "item_events_reordered"
|
ReasonCodeOccurrencesReordered = "item_occurrences_reordered"
|
||||||
ReasonCodeDuplicateCollapsed = "duplicate_item_event_collapsed"
|
ReasonCodeDuplicateCollapsed = "duplicate_item_occurrence_collapsed"
|
||||||
ReasonCodeWarningsOmitted = "item_event_normalization_warnings_omitted"
|
ReasonCodeWarningsOmitted = "item_occurrence_normalization_warnings_omitted"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ItemRegistryReferenceSlot = itemregistry.ReferenceSlot
|
||||||
|
ItemRegistryMaxBytes = itemregistry.MaxBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
var requiredCapabilities = []string{"merged"}
|
var requiredCapabilities = []string{"merged"}
|
||||||
var providedCapabilities = []string{"normalized"}
|
var providedCapabilities = []string{"normalized"}
|
||||||
|
|
||||||
var _ contracts.Normalizer[dnd.ItemEventList] = (*Normalizer)(nil)
|
var _ contracts.Normalizer[dnd.ItemOccurrenceList] = (*Normalizer)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||||
|
|
||||||
// Options deliberately has no fields: item-event normalization has no
|
|
||||||
// generated-reference dependency or configurable semantic behavior.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
type Normalizer struct{}
|
type Normalizer struct{ itemResolver *itemregistry.Resolver }
|
||||||
|
|
||||||
func New(Options) *Normalizer { return &Normalizer{} }
|
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 := itemregistry.NewResolver(referenceSet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, normalizerErrorf("prepare item registry: %w", err)
|
||||||
|
}
|
||||||
|
return &Normalizer{itemResolver: resolver}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (n *Normalizer) Key() string { return Key }
|
func (n *Normalizer) Key() string { return Key }
|
||||||
|
|
||||||
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
|
||||||
|
|
||||||
func (n *Normalizer) ManifestMetadata() map[string]any {
|
func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||||
if n == nil {
|
if n == nil || n.itemResolver == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return map[string]any{"normalization_policy": normalizationPolicy}
|
metadata := map[string]any{"normalization_policy": normalizationPolicy}
|
||||||
|
seeded := n.itemResolver.Seeded()
|
||||||
|
if seeded.Bound() {
|
||||||
|
metadata["item_registry_digest"] = seeded.Digest()
|
||||||
|
metadata["item_count"] = seeded.Count()
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
if n == nil {
|
if n == nil || n.itemResolver == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{{Name: "normalization_policy", Value: normalizationPolicy}}
|
return []pipeline.CheckpointFingerprint{
|
||||||
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
|
{Name: "item_registry", Value: n.itemResolver.Seeded().ProjectionDigest()},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemEventList]) (contracts.TypedNormalizeResult[dnd.ItemEventList], error) {
|
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]) (contracts.TypedNormalizeResult[dnd.ItemOccurrenceList], error) {
|
||||||
if n == nil {
|
if n == nil || n.itemResolver == nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("normalizer must not be nil")
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("normalizer must not be nil")
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context must not be nil")
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("context must not be nil")
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context error before normalize: %w", err)
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registry, err := n.itemResolver.Resolve(req.References)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("resolve item registry: %w", err)
|
||||||
|
}
|
||||||
|
if !registry.Bound() {
|
||||||
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("item registry reference is required")
|
||||||
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index))
|
value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry)
|
||||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{Value: value, Warnings: warnings}, nil
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{Value: value, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type normalizedRecord struct {
|
type normalizedRecord struct {
|
||||||
event dnd.ItemEvent
|
event dnd.ItemOccurrence
|
||||||
inputIndex int
|
inputIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order shared.SourceRefOrder) (dnd.ItemEventList, []contracts.Warning) {
|
func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, []contracts.Warning) {
|
||||||
if input.Events == nil {
|
if input.Occurrences == nil {
|
||||||
return dnd.ItemEventList{}, nil
|
return dnd.ItemOccurrenceList{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
records := make([]normalizedRecord, len(input.Events))
|
records := make([]normalizedRecord, len(input.Occurrences))
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, inputEvent := range input.Events {
|
for index, inputEvent := range input.Occurrences {
|
||||||
event, changedFields, refsChanged := normalizeEvent(inputEvent, order)
|
event, changedFields, found, refsChanged := normalizeEvent(inputEvent, order, registry)
|
||||||
records[index] = normalizedRecord{event: event, inputIndex: index}
|
records[index] = normalizedRecord{event: event, inputIndex: index}
|
||||||
if len(changedFields) != 0 {
|
if len(changedFields) != 0 {
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
Scope: eventScope(index),
|
Scope: eventScope(index),
|
||||||
ReasonCode: ReasonCodeDisplayNormalized,
|
ReasonCode: ReasonCodeNameCanonicalized,
|
||||||
Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index,
|
Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index,
|
||||||
diagnostics.Aggregate("fields", changedFields)),
|
diagnostics.Aggregate("fields", changedFields)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if found && inputEvent.Name != event.Name {
|
||||||
|
warnings = append(warnings, contracts.Warning{Scope: eventScope(index), ReasonCode: ReasonCodeNameCanonicalized,
|
||||||
|
Message: fmt.Sprintf("input index %d: item name canonicalized from %s to %s", index, diagnostics.Quote(inputEvent.Name), diagnostics.Quote(event.Name))})
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
warnings = append(warnings, contracts.Warning{Scope: eventScope(index), ReasonCode: ReasonCodeUnknownItemID,
|
||||||
|
Message: fmt.Sprintf("input index %d: item ID %s is not in the supplied registry", index, diagnostics.Quote(inputEvent.ItemID))})
|
||||||
|
}
|
||||||
if refsChanged {
|
if refsChanged {
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
Scope: eventScope(index),
|
Scope: eventScope(index),
|
||||||
@@ -118,24 +160,27 @@ func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order sh
|
|||||||
}
|
}
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
Scope: eventScope(record.inputIndex),
|
Scope: eventScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeEventsReordered,
|
ReasonCode: ReasonCodeOccurrencesReordered,
|
||||||
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records, index, order)
|
output, duplicateWarnings := collapseDuplicates(records, index, order)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
warnings = append(warnings, duplicateWarnings...)
|
||||||
return dnd.ItemEventList{Events: output}, diagnostics.LimitWarnings(warnings, "item_events", ReasonCodeWarningsOmitted)
|
return dnd.ItemOccurrenceList{Occurrences: output}, diagnostics.LimitWarnings(warnings, "item_occurrences", ReasonCodeWarningsOmitted)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeEvent(input dnd.ItemEvent, order shared.SourceRefOrder) (dnd.ItemEvent, []string, bool) {
|
func normalizeEvent(input dnd.ItemOccurrence, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrence, []string, bool, bool) {
|
||||||
output := cloneEvent(input)
|
output := cloneEvent(input)
|
||||||
changedFields := make([]string, 0, 3)
|
canonical, found := registry.LookupID(input.ItemID)
|
||||||
|
if found {
|
||||||
|
output.Name = canonical.Name
|
||||||
|
}
|
||||||
|
changedFields := make([]string, 0, 2)
|
||||||
for _, field := range []struct {
|
for _, field := range []struct {
|
||||||
name string
|
name string
|
||||||
value *string
|
value *string
|
||||||
}{
|
}{
|
||||||
{name: "name", value: &output.Name},
|
|
||||||
{name: "from", value: &output.From},
|
{name: "from", value: &output.From},
|
||||||
{name: "to", value: &output.To},
|
{name: "to", value: &output.To},
|
||||||
} {
|
} {
|
||||||
@@ -146,10 +191,10 @@ func normalizeEvent(input dnd.ItemEvent, order shared.SourceRefOrder) (dnd.ItemE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||||
return output, changedFields, !itemeventmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
return output, changedFields, found, !itemeventmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneEvent(input dnd.ItemEvent) dnd.ItemEvent {
|
func cloneEvent(input dnd.ItemOccurrence) dnd.ItemOccurrence {
|
||||||
output := input
|
output := input
|
||||||
if input.Quantity != nil {
|
if input.Quantity != nil {
|
||||||
quantity := *input.Quantity
|
quantity := *input.Quantity
|
||||||
@@ -166,9 +211,9 @@ type duplicateGroup struct {
|
|||||||
removed []int
|
removed []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex, order shared.SourceRefOrder) ([]dnd.ItemEvent, []contracts.Warning) {
|
func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex, order shared.SourceRefOrder) ([]dnd.ItemOccurrence, []contracts.Warning) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return make([]dnd.ItemEvent, 0), nil
|
return make([]dnd.ItemOccurrence, 0), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
keep := make([]bool, len(records))
|
keep := make([]bool, len(records))
|
||||||
@@ -190,7 +235,7 @@ func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex,
|
|||||||
groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex)
|
groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := make([]dnd.ItemEvent, 0, len(records))
|
output := make([]dnd.ItemOccurrence, 0, len(records))
|
||||||
for recordIndex, record := range records {
|
for recordIndex, record := range records {
|
||||||
if keep[recordIndex] {
|
if keep[recordIndex] {
|
||||||
output = append(output, cloneEvent(record.event))
|
output = append(output, cloneEvent(record.event))
|
||||||
@@ -214,11 +259,11 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
|
|||||||
Scope: eventScope(retainedIndex),
|
Scope: eventScope(retainedIndex),
|
||||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||||
Message: diagnostics.Aggregate(
|
Message: diagnostics.Aggregate(
|
||||||
fmt.Sprintf("duplicate item event collapsed; retained input index %d", retainedIndex), issues),
|
fmt.Sprintf("duplicate item occurrence collapsed; retained input index %d", retainedIndex), issues),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) }
|
func eventScope(index int) string { return fmt.Sprintf("occurrences[%d]", index) }
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
@@ -227,20 +272,32 @@ func ModuleSpec() pipeline.ModuleSpec {
|
|||||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||||
Requires: append([]string(nil), requiredCapabilities...),
|
Requires: append([]string(nil), requiredCapabilities...),
|
||||||
Provides: append([]string(nil), providedCapabilities...),
|
Provides: append([]string(nil), providedCapabilities...),
|
||||||
ArtifactKind: dnd.ItemEventListKind,
|
ArtifactKind: dnd.ItemOccurrenceListKind,
|
||||||
|
ReferenceSlots: referenceSlots(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.NormalizerRegistry) error {
|
func Register(registry *pipeline.NormalizerRegistry) error {
|
||||||
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.ItemEventList], error) {
|
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.ItemOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return New(options), nil
|
return New(options, request.References)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func referenceSlots() []contracts.ReferenceSlot {
|
||||||
|
return []contracts.ReferenceSlot{{
|
||||||
|
Name: ItemRegistryReferenceSlot,
|
||||||
|
Description: "Required normalized item registry used only for item identity grounding.",
|
||||||
|
Required: true,
|
||||||
|
AcceptedMediaTypes: []string{"application/json"},
|
||||||
|
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.ItemRegistryKind},
|
||||||
|
MaxBytes: ItemRegistryMaxBytes,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
func DecodeOptions(options map[string]any) (Options, error) {
|
func DecodeOptions(options map[string]any) (Options, error) {
|
||||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||||
return Options{}, normalizerErrorf("%w", err)
|
return Options{}, normalizerErrorf("%w", err)
|
||||||
@@ -251,5 +308,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func normalizerErrorf(format string, args ...any) error {
|
func normalizerErrorf(format string, args ...any) error {
|
||||||
return fmt.Errorf("dnd item events normalizer: "+format, args...)
|
return fmt.Errorf("dnd item occurrences normalizer: "+format, args...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,186 +2,65 @@ package itemevents
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
|
||||||
|
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeTrimsCanonicalizesAndClones(t *testing.T) {
|
func registryReferences(t *testing.T) contracts.ReferenceSet {
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
t.Helper()
|
||||||
quantity := 4
|
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: itemidentity.DeriveID("Torch"), Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}})
|
||||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{{
|
if err != nil {
|
||||||
Name: " Gold Pieces ", Kind: dnd.ItemEventKindTransferred, Quantity: &quantity, From: " Borin ", To: " Aria ",
|
t.Fatal(err)
|
||||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
|
||||||
}}}
|
|
||||||
result := normalize(t, doc, input)
|
|
||||||
got := result.Value.Events[0]
|
|
||||||
if got.Name != "Gold Pieces" || got.From != "Borin" || got.To != "Aria" || got.Quantity == input.Events[0].Quantity ||
|
|
||||||
!reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
|
|
||||||
t.Fatalf("normalized event = %#v", got)
|
|
||||||
}
|
|
||||||
if !hasWarning(result.Warnings, ReasonCodeDisplayNormalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
|
||||||
t.Fatalf("warnings = %#v", result.Warnings)
|
|
||||||
}
|
|
||||||
second := normalize(t, doc, result.Value)
|
|
||||||
if !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 {
|
|
||||||
t.Fatalf("second normalization = %#v; want idempotent output without warnings", second)
|
|
||||||
}
|
|
||||||
*got.Quantity = 99
|
|
||||||
got.SourceRefs[0].StartUnitID = 999
|
|
||||||
if quantity != 4 || input.Events[0].SourceRefs[1].StartUnitID != 30 {
|
|
||||||
t.Fatalf("Normalize() aliased input: %#v", input)
|
|
||||||
}
|
}
|
||||||
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) {
|
func TestNormalizeCanonicalizesRegistryNameAndRetainsUnknownValues(t *testing.T) {
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}}}
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}
|
||||||
ref := func(unit int) source.SourceRef {
|
refs := registryReferences(t)
|
||||||
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
|
normalizer, err := New(Options{}, refs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
quantity := 2
|
id := itemidentity.DeriveID("Torch")
|
||||||
duplicate := dnd.ItemEvent{Name: " Potion ", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " Aria ", SourceRefs: []source.SourceRef{ref(30)}}
|
input := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{
|
||||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
{ItemID: id, Name: "torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
||||||
{Name: "Later", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(20)}},
|
{ItemID: "unknown", Name: "Unknown", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
|
||||||
duplicate,
|
|
||||||
{Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Aria", SourceRefs: []source.SourceRef{ref(30)}},
|
|
||||||
{Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Borin", SourceRefs: []source.SourceRef{ref(30)}},
|
|
||||||
{Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Aria", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}},
|
|
||||||
}}
|
}}
|
||||||
result := normalize(t, doc, input)
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{Source: doc, References: refs, MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: input}})
|
||||||
got := result.Value.Events
|
if err != nil {
|
||||||
if len(got) != 4 || got[0].Name != "Potion" || got[0].To != "Aria" || got[1].To != "Borin" || got[2].Name != "Later" || got[3].SourceRefs[0].StartUnitID != 999 {
|
t.Fatal(err)
|
||||||
t.Fatalf("normalized events = %#v", got)
|
|
||||||
}
|
}
|
||||||
if !hasWarning(result.Warnings, ReasonCodeEventsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
if result.Value.Occurrences[1].Name != "Torch" || result.Value.Occurrences[0].Name != "Unknown" {
|
||||||
|
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
||||||
|
}
|
||||||
|
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeUnknownItemID) {
|
||||||
t.Fatalf("warnings = %#v", result.Warnings)
|
t.Fatalf("warnings = %#v", result.Warnings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeUsesEveryCanonicalTieBreaker(t *testing.T) {
|
func TestNormalizeRequiresRegistryAndRegistersSlot(t *testing.T) {
|
||||||
doc := testDocument()
|
normalizer, err := New(Options{})
|
||||||
ref := func(unit int) source.SourceRef {
|
if err != nil {
|
||||||
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
quantity := 1
|
_, err = normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}}})
|
||||||
tests := []struct {
|
if err == nil {
|
||||||
name string
|
t.Fatal("Normalize() error = nil")
|
||||||
earlier dnd.ItemEvent
|
|
||||||
later dnd.ItemEvent
|
|
||||||
}{
|
|
||||||
{"evidence position", dnd.ItemEvent{Name: "Z", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(20)}}},
|
|
||||||
{"normalized name", dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "B", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"exact name", dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "a", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"kind", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindAcquired, To: "Aria", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"from presence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"from comparison", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "B", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"exact from", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "a", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"to presence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, To: "Aria", SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"to comparison", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "A", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "B", SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"exact to", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "A", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "a", SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"quantity", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: []source.SourceRef{ref(10)}}},
|
|
||||||
{"reference sequence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10), ref(20)}}},
|
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
if len(ModuleSpec().ReferenceSlots) != 1 || !ModuleSpec().ReferenceSlots[0].Required {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Fatalf("ModuleSpec() = %#v", ModuleSpec())
|
||||||
result := normalize(t, doc, dnd.ItemEventList{Events: []dnd.ItemEvent{test.later, test.earlier}})
|
|
||||||
if len(result.Value.Events) != 2 || !reflect.DeepEqual(result.Value.Events[0], test.earlier) || !reflect.DeepEqual(result.Value.Events[1], test.later) {
|
|
||||||
t.Fatalf("normalized events = %#v; want %#v then %#v", result.Value.Events, test.earlier, test.later)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizePreservesEmptyAndMalformedCandidates(t *testing.T) {
|
|
||||||
for _, input := range []dnd.ItemEventList{{}, {Events: []dnd.ItemEvent{}}} {
|
|
||||||
result := normalize(t, nil, input)
|
|
||||||
if (result.Value.Events == nil) != (input.Events == nil) {
|
|
||||||
t.Fatalf("empty representation changed: %#v", result.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
|
||||||
{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
|
|
||||||
{Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
|
|
||||||
}}
|
|
||||||
result := normalize(t, testDocument(), malformed)
|
|
||||||
if len(result.Value.Events) != 2 || result.Value.Events[0].Name != "Coin" || result.Value.Events[1].Name != "Coin" {
|
|
||||||
t.Fatalf("malformed candidates were not safely retained: %#v", result.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeDoesNotInferOrConvertInventoryMeaning(t *testing.T) {
|
|
||||||
doc := testDocument()
|
|
||||||
quantity := 25
|
|
||||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
|
||||||
{Name: " gold pieces ", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
|
||||||
{Name: " gp ", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
|
||||||
{Name: " potion of healing ", Kind: dnd.ItemEventKindTransferred, Quantity: &quantity, From: " Aria ", To: " Borin ", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
|
||||||
}}
|
|
||||||
result := normalize(t, doc, input)
|
|
||||||
got := make(map[string]dnd.ItemEvent, len(result.Value.Events))
|
|
||||||
for _, event := range result.Value.Events {
|
|
||||||
got[event.Name] = event
|
|
||||||
}
|
|
||||||
if event := got["gold pieces"]; event.Kind != dnd.ItemEventKindDiscovered || event.Quantity == nil || *event.Quantity != 25 {
|
|
||||||
t.Fatalf("discovery event was inferred or converted: %#v", event)
|
|
||||||
}
|
|
||||||
if event := got["gp"]; event.Kind != dnd.ItemEventKindAcquired || event.Quantity == nil || *event.Quantity != 25 || event.To != "party" {
|
|
||||||
t.Fatalf("acquisition event was inferred or denomination-converted: %#v", event)
|
|
||||||
}
|
|
||||||
if event := got["potion of healing"]; event.Kind != dnd.ItemEventKindTransferred || event.From != "Aria" || event.To != "Borin" || event.Quantity == nil || *event.Quantity != 25 {
|
|
||||||
t.Fatalf("transfer event was inferred, aliased, singularized, or ledger-adjusted: %#v", event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizerContractAndBoundedWarnings(t *testing.T) {
|
|
||||||
normalizer := New(Options{})
|
|
||||||
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.ItemEventListKind || len(spec.ReferenceSlots) != 0 ||
|
|
||||||
!reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) {
|
|
||||||
t.Fatalf("ModuleSpec() = %#v", spec)
|
|
||||||
}
|
|
||||||
if metadata := normalizer.ManifestMetadata(); !reflect.DeepEqual(metadata, map[string]any{"normalization_policy": normalizationPolicy}) {
|
|
||||||
t.Fatalf("ManifestMetadata() = %#v", metadata)
|
|
||||||
}
|
|
||||||
if fingerprints := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(fingerprints, []pipeline.CheckpointFingerprint{{Name: "normalization_policy", Value: normalizationPolicy}}) {
|
|
||||||
t.Fatalf("CheckpointFingerprints() = %#v", fingerprints)
|
|
||||||
}
|
}
|
||||||
registry := pipeline.NewNormalizerRegistry()
|
registry := pipeline.NewNormalizerRegistry()
|
||||||
if err := Register(registry); err != nil {
|
if err := Register(registry); err != nil {
|
||||||
t.Fatalf("Register() error = %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
|
||||||
t.Fatal("DecodeOptions() accepted unknown option")
|
|
||||||
}
|
|
||||||
|
|
||||||
count := diagnostics.MaxWarnings + 5
|
|
||||||
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
|
||||||
input := dnd.ItemEventList{Events: make([]dnd.ItemEvent, count)}
|
|
||||||
for index := range input.Events {
|
|
||||||
doc.Units[index].ID = index + 1
|
|
||||||
input.Events[index] = dnd.ItemEvent{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: count - index, EndUnitID: count - index}}}
|
|
||||||
}
|
|
||||||
result := normalize(t, doc, input)
|
|
||||||
if len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted {
|
|
||||||
t.Fatalf("warnings = %#v", result.Warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalize(t *testing.T, doc *source.SourceDocument, input dnd.ItemEventList) contracts.TypedNormalizeResult[dnd.ItemEventList] {
|
|
||||||
t.Helper()
|
|
||||||
result, err := New(Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Normalize() error = %v", err)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDocument() *source.SourceDocument {
|
|
||||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasWarning(warnings []contracts.Warning, reason string) bool {
|
func hasWarning(warnings []contracts.Warning, reason string) bool {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
|
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"
|
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"
|
||||||
|
itemeventregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/registry"
|
||||||
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"
|
||||||
itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness"
|
itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness"
|
||||||
@@ -181,26 +182,28 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}},
|
}},
|
||||||
{name: "item events validator chain", register: func() error {
|
{name: "item occurrences validator chain", register: func() error {
|
||||||
return registry.Register(pipeline.ValidatorChainMapping{
|
return registry.Register(pipeline.ValidatorChainMapping{
|
||||||
Stage: pipeline.StageExtract,
|
Stage: pipeline.StageExtract,
|
||||||
Module: itemeventextract.Key,
|
Module: itemeventextract.Key,
|
||||||
Validators: []pipeline.ModuleBinding{
|
Validators: []pipeline.ModuleBinding{
|
||||||
pipeline.Binding(validjson.Key),
|
pipeline.Binding(validjson.Key),
|
||||||
pipeline.Binding(itemeventshape.Key),
|
pipeline.Binding(itemeventshape.Key),
|
||||||
|
pipeline.Binding(itemeventregistry.Key),
|
||||||
pipeline.Binding(itemeventrefs.Key),
|
pipeline.Binding(itemeventrefs.Key),
|
||||||
pipeline.Binding(validjsonschema.Key),
|
pipeline.Binding(validjsonschema.Key),
|
||||||
pipeline.Binding(itemeventrelatedness.Key),
|
pipeline.Binding(itemeventrelatedness.Key),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}},
|
}},
|
||||||
{name: "item events normalize validator chain", register: func() error {
|
{name: "item occurrences normalize validator chain", register: func() error {
|
||||||
return registry.Register(pipeline.ValidatorChainMapping{
|
return registry.Register(pipeline.ValidatorChainMapping{
|
||||||
Stage: pipeline.StageNormalize,
|
Stage: pipeline.StageNormalize,
|
||||||
Module: itemeventnormalize.Key,
|
Module: itemeventnormalize.Key,
|
||||||
Validators: []pipeline.ModuleBinding{
|
Validators: []pipeline.ModuleBinding{
|
||||||
pipeline.Binding(validjson.Key),
|
pipeline.Binding(validjson.Key),
|
||||||
pipeline.Binding(itemeventshape.Key),
|
pipeline.Binding(itemeventshape.Key),
|
||||||
|
pipeline.Binding(itemeventregistry.Key),
|
||||||
pipeline.Binding(itemeventinvariants.Key),
|
pipeline.Binding(itemeventinvariants.Key),
|
||||||
pipeline.Binding(itemeventrefs.Key),
|
pipeline.Binding(itemeventrefs.Key),
|
||||||
pipeline.Binding(validjsonschema.Key),
|
pipeline.Binding(validjsonschema.Key),
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
|
|||||||
{name: "enemy events evidence", register: func() error {
|
{name: "enemy events evidence", register: func() error {
|
||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence)
|
return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence)
|
||||||
}},
|
}},
|
||||||
{name: "item events evidence", register: func() error {
|
{name: "item occurrences evidence", register: func() error {
|
||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemOccurrenceListKind, itemOccurrenceEvidence)
|
||||||
}},
|
}},
|
||||||
{name: "item registry evidence", register: func() error {
|
{name: "item registry evidence", register: func() error {
|
||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemRegistryKind, itemRegistryEvidence)
|
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemRegistryKind, itemRegistryEvidence)
|
||||||
@@ -69,9 +69,9 @@ func enemyEventEvidence(value dnd.EnemyEventList) []source.SourceRef {
|
|||||||
return append([]source.SourceRef(nil), refs...)
|
return append([]source.SourceRef(nil), refs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
|
func itemOccurrenceEvidence(value dnd.ItemOccurrenceList) []source.SourceRef {
|
||||||
var refs []source.SourceRef
|
var refs []source.SourceRef
|
||||||
for _, record := range value.Events {
|
for _, record := range value.Occurrences {
|
||||||
refs = append(refs, record.SourceRefs...)
|
refs = append(refs, record.SourceRefs...)
|
||||||
}
|
}
|
||||||
return append([]source.SourceRef(nil), refs...)
|
return append([]source.SourceRef(nil), refs...)
|
||||||
|
|||||||
@@ -91,22 +91,22 @@ func appendEnemyEventLists(values []dnd.EnemyEventList) (dnd.EnemyEventList, err
|
|||||||
return combined, nil
|
return combined, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) {
|
func appendItemOccurrenceLists(values []dnd.ItemOccurrenceList) (dnd.ItemOccurrenceList, error) {
|
||||||
count := 0
|
count := 0
|
||||||
present := false
|
present := false
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if value.Events != nil {
|
if value.Occurrences != nil {
|
||||||
present = true
|
present = true
|
||||||
}
|
}
|
||||||
count += len(value.Events)
|
count += len(value.Occurrences)
|
||||||
}
|
}
|
||||||
if !present {
|
if !present {
|
||||||
return dnd.ItemEventList{}, nil
|
return dnd.ItemOccurrenceList{}, nil
|
||||||
}
|
}
|
||||||
combined := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 0, count)}
|
combined := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 0, count)}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
for _, event := range value.Events {
|
for _, occurrence := range value.Occurrences {
|
||||||
combined.Events = append(combined.Events, cloneItemEvent(event))
|
combined.Occurrences = append(combined.Occurrences, cloneItemOccurrence(occurrence))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return combined, nil
|
return combined, nil
|
||||||
@@ -227,7 +227,7 @@ func cloneEnemyEvent(value dnd.EnemyEvent) dnd.EnemyEvent {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent {
|
func cloneItemOccurrence(value dnd.ItemOccurrence) dnd.ItemOccurrence {
|
||||||
clone := value
|
clone := value
|
||||||
if value.Quantity != nil {
|
if value.Quantity != nil {
|
||||||
quantity := *value.Quantity
|
quantity := *value.Quantity
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
{name: "npc registry codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
{name: "npc registry 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: "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 occurrences codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }},
|
||||||
{name: "item registry codec", register: func() error {
|
{name: "item registry codec", register: func() error {
|
||||||
return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemregistrycodec.New())
|
return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemregistrycodec.New())
|
||||||
}},
|
}},
|
||||||
@@ -64,7 +64,7 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
{name: "npc registry extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
{name: "npc registry 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: "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 occurrences extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }},
|
||||||
{name: "item registry extractor", register: func() error { return itemregistryextract.Register(registries.Extractors) }},
|
{name: "item registry extractor", register: func() error { return itemregistryextract.Register(registries.Extractors) }},
|
||||||
{name: "npc occurrences extractor", register: func() error { return occurrenceextract.Register(registries.Extractors) }},
|
{name: "npc occurrences extractor", register: func() error { return occurrenceextract.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) }},
|
||||||
@@ -82,8 +82,8 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
{name: "enemy-event-list appendorder merger", register: func() error {
|
{name: "enemy-event-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists)
|
||||||
}},
|
}},
|
||||||
{name: "item-event-list appendorder merger", register: func() error {
|
{name: "item-occurrence-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemOccurrenceListKind, appendItemOccurrenceLists)
|
||||||
}},
|
}},
|
||||||
{name: "item-registry appendorder merger", register: func() error {
|
{name: "item-registry appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemRegistryKind, appendItemRegistries)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemRegistryKind, appendItemRegistries)
|
||||||
@@ -104,7 +104,7 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
{name: "npc registry normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
{name: "npc registry 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: "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 occurrences normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }},
|
||||||
{name: "item registry normalizer", register: func() error { return itemregistrynormalize.Register(registries.Normalizers) }},
|
{name: "item registry normalizer", register: func() error { return itemregistrynormalize.Register(registries.Normalizers) }},
|
||||||
{name: "npc occurrences normalizer", register: func() error { return occurrencenormalize.Register(registries.Normalizers) }},
|
{name: "npc occurrences normalizer", register: func() error { return occurrencenormalize.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) }},
|
||||||
@@ -122,8 +122,8 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
{name: "enemy-event-list noop normalizer", register: func() error {
|
{name: "enemy-event-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind)
|
return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind)
|
||||||
}},
|
}},
|
||||||
{name: "item-event-list noop normalizer", register: func() error {
|
{name: "item-occurrence-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
|
return noop.RegisterTyped[dnd.ItemOccurrenceList](registries.Normalizers, dnd.ItemOccurrenceListKind)
|
||||||
}},
|
}},
|
||||||
{name: "item-registry noop normalizer", register: func() error {
|
{name: "item-registry noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.ItemRegistry](registries.Normalizers, dnd.ItemRegistryKind)
|
return noop.RegisterTyped[dnd.ItemRegistry](registries.Normalizers, dnd.ItemRegistryKind)
|
||||||
@@ -152,7 +152,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error {
|
|||||||
{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: "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 occurrences prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }},
|
||||||
{name: "item registry prompt assets", register: func() error { return itemregistryextract.RegisterPromptAssets(assets) }},
|
{name: "item registry prompt assets", register: func() error { return itemregistryextract.RegisterPromptAssets(assets) }},
|
||||||
{name: "item registry normalization prompt assets", register: func() error { return itemregistrynormalize.RegisterPromptAssets(assets) }},
|
{name: "item registry normalization prompt assets", register: func() error { return itemregistrynormalize.RegisterPromptAssets(assets) }},
|
||||||
{name: "npc occurrences prompt assets", register: func() error { return occurrenceextract.RegisterPromptAssets(assets) }},
|
{name: "npc occurrences prompt assets", register: func() error { return occurrenceextract.RegisterPromptAssets(assets) }},
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ func TestExtractionPromptComposition(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
||||||
{name: "locations", promptID: locationextract.PromptID, promptVersion: locationextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
{name: "locations", promptID: locationextract.PromptID, promptVersion: locationextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
||||||
{name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
{name: "item occurrences", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
||||||
|
"item_registry": promptkit.Inline(`{"items":[{"id":"item-registry-sentinel","name":"Torch"}]}`),
|
||||||
|
}), suffixGroups: [][]string{{evidenceSentinel}, {"item-registry-sentinel"}}},
|
||||||
{name: "item registry", promptID: itemregistryextract.PromptID, promptVersion: itemregistryextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
{name: "item registry", promptID: itemregistryextract.PromptID, promptVersion: itemregistryextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
||||||
{name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs},
|
{name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
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, enemyeventextract.Key, itemeventextract.Key, itemregistryextract.Key, occurrenceextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key})
|
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, itemregistryextract.Key, occurrenceextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key})
|
||||||
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, itemregistrynormalize.Key, occurrencenormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule})
|
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, itemregistrynormalize.Key, occurrencenormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule})
|
||||||
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind})
|
||||||
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(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.ItemOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemregistrynormalize.Key), []contracts.ArtifactKind{dnd.ItemRegistryKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemregistrynormalize.Key), []contracts.ArtifactKind{dnd.ItemRegistryKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(occurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(occurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
|
||||||
@@ -230,26 +230,28 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, enemyeventnormalize.Key); !reflect.DeepEqual(got, enemyEventNormalizeChain) {
|
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)
|
t.Fatalf("enemy event normalize validator chain = %#v, want %#v", got, enemyEventNormalizeChain)
|
||||||
}
|
}
|
||||||
itemEventExtractChain := []pipeline.ModuleBinding{
|
itemOccurrenceExtractChain := []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"),
|
||||||
|
pipeline.Binding("extract/dnd/item-events/registry"),
|
||||||
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
||||||
pipeline.Binding("generic/valid_json_schema"),
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
|
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
|
||||||
}
|
}
|
||||||
itemEventNormalizeChain := []pipeline.ModuleBinding{
|
itemOccurrenceNormalizeChain := []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"),
|
||||||
|
pipeline.Binding("extract/dnd/item-events/registry"),
|
||||||
pipeline.Binding("normalize/dnd/item-events/invariants"),
|
pipeline.Binding("normalize/dnd/item-events/invariants"),
|
||||||
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
pipeline.Binding("extract/dnd/item-events/source_refs"),
|
||||||
pipeline.Binding("generic/valid_json_schema"),
|
pipeline.Binding("generic/valid_json_schema"),
|
||||||
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
|
pipeline.Binding("extract/dnd/item-events/source_relatedness"),
|
||||||
}
|
}
|
||||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) {
|
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemOccurrenceExtractChain) {
|
||||||
t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain)
|
t.Fatalf("item occurrence extract validator chain = %#v, want %#v", got, itemOccurrenceExtractChain)
|
||||||
}
|
}
|
||||||
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, itemOccurrenceNormalizeChain) {
|
||||||
t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain)
|
t.Fatalf("item occurrence normalize validator chain = %#v, want %#v", got, itemOccurrenceNormalizeChain)
|
||||||
}
|
}
|
||||||
itemRegistryExtractChain := []pipeline.ModuleBinding{
|
itemRegistryExtractChain := []pipeline.ModuleBinding{
|
||||||
pipeline.Binding("generic/valid_json"),
|
pipeline.Binding("generic/valid_json"),
|
||||||
@@ -328,7 +330,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
"dnd_npc_registry_llm.v1.json",
|
"dnd_npc_registry_llm.v1.json",
|
||||||
"dnd_combat_turns_llm.v1.json",
|
"dnd_combat_turns_llm.v1.json",
|
||||||
"dnd_enemy_events_llm.v1.json",
|
"dnd_enemy_events_llm.v1.json",
|
||||||
"dnd_item_events_llm.v1.json",
|
"dnd_item_occurrences_llm.v1.json",
|
||||||
"dnd_npc_occurrences_llm.v1.json",
|
"dnd_npc_occurrences_llm.v1.json",
|
||||||
"dnd_scene_descriptions_llm.v1.json",
|
"dnd_scene_descriptions_llm.v1.json",
|
||||||
"dnd_location_registry_llm.v1.json",
|
"dnd_location_registry_llm.v1.json",
|
||||||
@@ -368,15 +370,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
if !npcRegistrySlot.Required || !enemyNormalizeRegistrySlot.Required || npcRegistrySlot.MaxBytes != enemyNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(npcRegistrySlot.AcceptedMediaTypes, enemyNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(npcRegistrySlot.AcceptedArtifactKinds, enemyNormalizeRegistrySlot.AcceptedArtifactKinds) {
|
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)
|
t.Fatalf("enemy-event NPC registry slots disagree: %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots)
|
||||||
}
|
}
|
||||||
itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key)
|
itemOccurrenceExtractSpec, itemOccurrenceExtractOK := registries.Extractors.Spec(itemeventextract.Key)
|
||||||
itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key)
|
itemOccurrenceNormalizeSpec, itemOccurrenceNormalizeOK := 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 !itemOccurrenceExtractOK || itemOccurrenceExtractSpec.ArtifactKind != dnd.ItemOccurrenceListKind || !itemOccurrenceNormalizeOK || itemOccurrenceNormalizeSpec.ArtifactKind != dnd.ItemOccurrenceListKind || itemOccurrenceNormalizeSpec.Stage != pipeline.StageNormalize {
|
||||||
t.Fatalf("item event specs = %#v / %#v, present = %t / %t", itemEventExtractSpec, itemEventNormalizeSpec, itemEventExtractOK, itemEventNormalizeOK)
|
t.Fatalf("item occurrence specs = %#v / %#v, present = %t / %t", itemOccurrenceExtractSpec, itemOccurrenceNormalizeSpec, itemOccurrenceExtractOK, itemOccurrenceNormalizeOK)
|
||||||
}
|
}
|
||||||
for _, slot := range itemEventExtractSpec.ReferenceSlots {
|
itemExtractRegistrySlot := referenceSlot(itemOccurrenceExtractSpec.ReferenceSlots, "item_registry")
|
||||||
if slot.Required || len(slot.AcceptedArtifactKinds) != 0 {
|
itemNormalizeRegistrySlot := referenceSlot(itemOccurrenceNormalizeSpec.ReferenceSlots, "item_registry")
|
||||||
t.Fatalf("item event extractor has a generated-reference dependency: %#v", slot)
|
if !itemExtractRegistrySlot.Required || !itemNormalizeRegistrySlot.Required || itemExtractRegistrySlot.MaxBytes != itemNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedMediaTypes, itemNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedArtifactKinds, itemNormalizeRegistrySlot.AcceptedArtifactKinds) {
|
||||||
}
|
t.Fatalf("item occurrence registry slots disagree: %#v / %#v", itemOccurrenceExtractSpec.ReferenceSlots, itemOccurrenceNormalizeSpec.ReferenceSlots)
|
||||||
}
|
}
|
||||||
occurrenceExtractSpec, extractOK := registries.Extractors.Spec(occurrenceextract.Key)
|
occurrenceExtractSpec, extractOK := registries.Extractors.Spec(occurrenceextract.Key)
|
||||||
occurrenceNormalizeSpec, normalizeOK := registries.Normalizers.Spec(occurrencenormalize.Key)
|
occurrenceNormalizeSpec, normalizeOK := registries.Normalizers.Spec(occurrencenormalize.Key)
|
||||||
@@ -442,8 +444,8 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes
|
|||||||
{name: "enemy events", project: func() []source.SourceRef {
|
{name: "enemy events", project: func() []source.SourceRef {
|
||||||
return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||||
}, want: []source.SourceRef{first, second}},
|
}, want: []source.SourceRef{first, second}},
|
||||||
{name: "item events", project: func() []source.SourceRef {
|
{name: "item occurrences", project: func() []source.SourceRef {
|
||||||
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
return itemOccurrenceEvidence(dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||||
}, want: []source.SourceRef{first, second}},
|
}, want: []source.SourceRef{first, second}},
|
||||||
{name: "item registry", project: func() []source.SourceRef {
|
{name: "item registry", project: func() []source.SourceRef {
|
||||||
return itemRegistryEvidence(dnd.ItemRegistry{Items: []dnd.Item{{SourceRefs: []source.SourceRef{first, second}}}})
|
return itemRegistryEvidence(dnd.ItemRegistry{Items: []dnd.Item{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||||
@@ -694,27 +696,27 @@ func TestAppendEnemyEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
func TestAppendItemOccurrenceListsPreservesOrderPresenceAndOwnership(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}}
|
||||||
input := []dnd.ItemEventList{
|
input := []dnd.ItemOccurrenceList{
|
||||||
{},
|
{},
|
||||||
{Events: []dnd.ItemEvent{}},
|
{Occurrences: []dnd.ItemOccurrence{}},
|
||||||
{Events: []dnd.ItemEvent{{Name: "first", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: refs}}},
|
{Occurrences: []dnd.ItemOccurrence{{ItemID: "first", Name: "first", Kind: dnd.ItemOccurrenceKindDiscovered, Quantity: &quantity, SourceRefs: refs}}},
|
||||||
{Events: []dnd.ItemEvent{{Name: "second", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}},
|
{Occurrences: []dnd.ItemOccurrence{{ItemID: "second", Name: "second", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}},
|
||||||
}
|
}
|
||||||
got, err := appendItemEventLists(input)
|
got, err := appendItemOccurrenceLists(input)
|
||||||
if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) {
|
if err != nil || got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"first", "second"}) {
|
||||||
t.Fatalf("appendItemEventLists() = %#v, error = %v", got, err)
|
t.Fatalf("appendItemOccurrenceLists() = %#v, error = %v", got, err)
|
||||||
}
|
}
|
||||||
*got.Events[0].Quantity = 99
|
*got.Occurrences[0].Quantity = 99
|
||||||
got.Events[0].SourceRefs[0].StartUnitID = 999
|
got.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||||
if quantity != 3 || input[2].Events[0].SourceRefs[0].StartUnitID != 1 {
|
if quantity != 3 || input[2].Occurrences[0].SourceRefs[0].StartUnitID != 1 {
|
||||||
t.Fatal("merged item events share quantity or source-reference storage")
|
t.Fatal("merged item occurrences share quantity or source-reference storage")
|
||||||
}
|
}
|
||||||
for _, values := range [][]dnd.ItemEventList{nil, []dnd.ItemEventList{{}, {}}} {
|
for _, values := range [][]dnd.ItemOccurrenceList{nil, []dnd.ItemOccurrenceList{{}, {}}} {
|
||||||
result, err := appendItemEventLists(values)
|
result, err := appendItemOccurrenceLists(values)
|
||||||
if err != nil || result.Events != nil {
|
if err != nil || result.Occurrences != nil {
|
||||||
t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err)
|
t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,9 +767,9 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) {
|
|||||||
t.Fatalf("appendNPCOccurrenceLists() = %#v, %v; want present-empty source refs", occurrences, err)
|
t.Fatalf("appendNPCOccurrenceLists() = %#v, %v; want present-empty source refs", occurrences, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
events, err := appendItemEventLists([]dnd.ItemEventList{{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{}}}}})
|
events, err := appendItemOccurrenceLists([]dnd.ItemOccurrenceList{{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{}}}}})
|
||||||
if err != nil || events.Events[0].SourceRefs == nil {
|
if err != nil || events.Occurrences[0].SourceRefs == nil {
|
||||||
t.Fatalf("appendItemEventLists() = %#v, %v; want present-empty source refs", events, err)
|
t.Fatalf("appendItemOccurrenceLists() = %#v, %v; want present-empty source refs", events, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := appendItemRegistries([]dnd.ItemRegistry{{Items: []dnd.Item{{SourceRefs: []source.SourceRef{}}}}})
|
items, err := appendItemRegistries([]dnd.ItemRegistry{{Items: []dnd.Item{{SourceRefs: []source.SourceRef{}}}}})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
|
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"
|
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"
|
||||||
|
itemeventregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/registry"
|
||||||
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"
|
||||||
itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness"
|
itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness"
|
||||||
@@ -69,10 +70,11 @@ func registerValidators(registries pipeline.Registries) error {
|
|||||||
{name: "enemy event source references validator", register: func() error { return enemyeventrefs.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 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: "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 occurrence 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 occurrence registry validator", register: func() error { return itemeventregistry.Register(registries.Validators) }},
|
||||||
{name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }},
|
{name: "item occurrence source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }},
|
||||||
{name: "item event normalized invariants validator", register: func() error { return itemeventinvariants.Register(registries.Validators) }},
|
{name: "item occurrence source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }},
|
||||||
|
{name: "item occurrence normalized invariants validator", register: func() error { return itemeventinvariants.Register(registries.Validators) }},
|
||||||
{name: "item shape validator", register: func() error { return itemshape.Register(registries.Validators) }},
|
{name: "item shape validator", register: func() error { return itemshape.Register(registries.Validators) }},
|
||||||
{name: "item identity validator", register: func() error { return itemidentity.Register(registries.Validators) }},
|
{name: "item identity validator", register: func() error { return itemidentity.Register(registries.Validators) }},
|
||||||
{name: "item source references validator", register: func() error { return itemrefs.Register(registries.Validators) }},
|
{name: "item source references validator", register: func() error { return itemrefs.Register(registries.Validators) }},
|
||||||
@@ -119,11 +121,11 @@ func registerValidators(registries pipeline.Registries) error {
|
|||||||
{name: "enemy-event-list always reject validator", register: func() error {
|
{name: "enemy-event-list always reject validator", register: func() error {
|
||||||
return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
|
return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
|
||||||
}},
|
}},
|
||||||
{name: "item-event-list always accept validator", register: func() error {
|
{name: "item-occurrence-list always accept validator", register: func() error {
|
||||||
return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
return alwaysaccept.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind)
|
||||||
}},
|
}},
|
||||||
{name: "item-event-list always reject validator", register: func() error {
|
{name: "item-occurrence-list always reject validator", register: func() error {
|
||||||
return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
return alwaysreject.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind)
|
||||||
}},
|
}},
|
||||||
{name: "item-registry always accept validator", register: func() error {
|
{name: "item-registry always accept validator", register: func() error {
|
||||||
return alwaysaccept.RegisterTyped[dnd.ItemRegistry](registries.Validators, dnd.ItemRegistryKind)
|
return alwaysaccept.RegisterTyped[dnd.ItemRegistry](registries.Validators, dnd.ItemRegistryKind)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list"
|
|||||||
|
|
||||||
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
||||||
|
|
||||||
const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list"
|
const ItemOccurrenceListKind contracts.ArtifactKind = "dnd/item-occurrence-list"
|
||||||
|
|
||||||
const ItemRegistryKind contracts.ArtifactKind = "dnd/item-registry"
|
const ItemRegistryKind contracts.ArtifactKind = "dnd/item-registry"
|
||||||
|
|
||||||
@@ -109,23 +109,24 @@ type SceneDescription struct {
|
|||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemEventKind string
|
type ItemOccurrenceKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ItemEventKindDiscovered ItemEventKind = "discovered"
|
ItemOccurrenceKindDiscovered ItemOccurrenceKind = "discovered"
|
||||||
ItemEventKindAcquired ItemEventKind = "acquired"
|
ItemOccurrenceKindAcquired ItemOccurrenceKind = "acquired"
|
||||||
ItemEventKindLost ItemEventKind = "lost"
|
ItemOccurrenceKindLost ItemOccurrenceKind = "lost"
|
||||||
ItemEventKindConsumed ItemEventKind = "consumed"
|
ItemOccurrenceKindConsumed ItemOccurrenceKind = "consumed"
|
||||||
ItemEventKindTransferred ItemEventKind = "transferred"
|
ItemOccurrenceKindTransferred ItemOccurrenceKind = "transferred"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ItemEventList struct {
|
type ItemOccurrenceList struct {
|
||||||
Events []ItemEvent `json:"events"`
|
Occurrences []ItemOccurrence `json:"occurrences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemEvent struct {
|
type ItemOccurrence struct {
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind ItemEventKind `json:"kind"`
|
Kind ItemOccurrenceKind `json:"kind"`
|
||||||
Quantity *int `json:"quantity,omitempty"`
|
Quantity *int `json:"quantity,omitempty"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const (
|
|||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
@@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Source, req.Value); err != nil {
|
if err := Validate(req.Source, req.Value); err != nil {
|
||||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
|
|
||||||
// Validate checks only invariants introduced by item-event normalization.
|
// Validate checks only invariants introduced by item-event normalization.
|
||||||
// Shape and source-reference failures remain owned by their earlier validators.
|
// Shape and source-reference failures remain owned by their earlier validators.
|
||||||
func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
|
func Validate(doc *source.SourceDocument, value dnd.ItemOccurrenceList) error {
|
||||||
if itemeventshape.Validate(value) != nil {
|
if itemeventshape.Validate(value) != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -57,14 +57,14 @@ func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
|
|||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item event invariants", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item occurrence invariants", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
|
func issuesFor(order shared.SourceRefOrder, value dnd.ItemOccurrenceList) []string {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
seenIdentity := make(map[string]int)
|
seenIdentity := make(map[string]int)
|
||||||
for eventIndex, event := range value.Events {
|
for eventIndex, event := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
prefix := fmt.Sprintf("occurrences[%d]", eventIndex)
|
||||||
if event.Name != itemeventmodel.DisplayValue(event.Name) {
|
if event.Name != itemeventmodel.DisplayValue(event.Name) {
|
||||||
issues = append(issues, prefix+".name is not display-normalized")
|
issues = append(issues, prefix+".name is not display-normalized")
|
||||||
}
|
}
|
||||||
@@ -85,21 +85,21 @@ func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
|
|||||||
}
|
}
|
||||||
key := itemeventmodel.ExactIdentity(order, event)
|
key := itemeventmodel.ExactIdentity(order, event)
|
||||||
if previous, exists := seenIdentity[key]; exists {
|
if previous, exists := seenIdentity[key]; exists {
|
||||||
issues = append(issues, fmt.Sprintf("%s duplicates item event %d under normalized identity", prefix, previous))
|
issues = append(issues, fmt.Sprintf("%s duplicates item occurrence %d under normalized identity", prefix, previous))
|
||||||
} else {
|
} else {
|
||||||
seenIdentity[key] = eventIndex
|
seenIdentity[key] = eventIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for eventIndex := 1; eventIndex < len(value.Events); eventIndex++ {
|
for eventIndex := 1; eventIndex < len(value.Occurrences); eventIndex++ {
|
||||||
if itemeventmodel.Less(order, value.Events[eventIndex], value.Events[eventIndex-1]) {
|
if itemeventmodel.Less(order, value.Occurrences[eventIndex], value.Occurrences[eventIndex-1]) {
|
||||||
issues = append(issues, fmt.Sprintf("events[%d] is out of canonical order", eventIndex))
|
issues = append(issues, fmt.Sprintf("occurrences[%d] is out of canonical order", eventIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemEventList) bool {
|
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemOccurrenceList) bool {
|
||||||
for _, event := range value.Events {
|
for _, event := range value.Occurrences {
|
||||||
if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) {
|
if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -11,20 +11,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
normalizeitemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidatorApprovesNormalizerOutput(t *testing.T) {
|
func TestValidatorApprovesNormalizedOutput(t *testing.T) {
|
||||||
doc := invariantDocument()
|
doc := invariantDocument()
|
||||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: normalizedList()})
|
||||||
{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
|
||||||
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
|
||||||
}}
|
|
||||||
normalized, err := normalizeitemevents.New(normalizeitemevents.Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: normalized.Value})
|
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("Validate() = %#v, %v; want approval", result, err)
|
t.Fatalf("Validate() = %#v, %v; want approval", result, err)
|
||||||
}
|
}
|
||||||
@@ -33,26 +24,30 @@ func TestValidatorApprovesNormalizerOutput(t *testing.T) {
|
|||||||
func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
mutate func(*dnd.ItemEventList)
|
mutate func(*dnd.ItemOccurrenceList)
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "name whitespace", mutate: func(value *dnd.ItemEventList) { value.Events[0].Name = " Coin " }, want: ".name is not display-normalized"},
|
{name: "name whitespace", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].Name = " Coin " }, want: ".name is not display-normalized"},
|
||||||
{name: "from whitespace", mutate: func(value *dnd.ItemEventList) {
|
{name: "from whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||||
value.Events[0].Kind = dnd.ItemEventKindLost
|
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindLost
|
||||||
value.Events[0].From = " Aria "
|
value.Occurrences[0].From = " Aria "
|
||||||
}, want: ".from is not display-normalized"},
|
}, want: ".from is not display-normalized"},
|
||||||
{name: "to whitespace", mutate: func(value *dnd.ItemEventList) {
|
{name: "to whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||||
value.Events[0].Kind = dnd.ItemEventKindAcquired
|
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindAcquired
|
||||||
value.Events[0].To = " Aria "
|
value.Occurrences[0].To = " Aria "
|
||||||
}, want: ".to is not display-normalized"},
|
}, want: ".to is not display-normalized"},
|
||||||
{name: "reference order", mutate: func(value *dnd.ItemEventList) {
|
{name: "reference order", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||||
value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||||
}, want: "not in canonical order"},
|
}, want: "not in canonical order"},
|
||||||
{name: "duplicate reference", mutate: func(value *dnd.ItemEventList) {
|
{name: "duplicate reference", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||||
value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0])
|
value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
|
||||||
}, want: "duplicates the previous reference"},
|
}, want: "duplicates the previous reference"},
|
||||||
{name: "list order", mutate: func(value *dnd.ItemEventList) { value.Events = []dnd.ItemEvent{value.Events[1], value.Events[0]} }, want: "out of canonical order"},
|
{name: "list order", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||||
{name: "duplicate event", mutate: func(value *dnd.ItemEventList) { value.Events = append(value.Events, value.Events[0]) }, want: "duplicates item event"},
|
value.Occurrences = []dnd.ItemOccurrence{value.Occurrences[1], value.Occurrences[0]}
|
||||||
|
}, want: "out of canonical order"},
|
||||||
|
{name: "duplicate event", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||||
|
value.Occurrences = append(value.Occurrences, value.Occurrences[0])
|
||||||
|
}, want: "duplicates item occurrence"},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -62,7 +57,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
|||||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||||
}
|
}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
|
||||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||||
t.Fatalf("validator result = %#v, %v", result, err)
|
t.Fatalf("validator result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -72,20 +67,20 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
|
func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{{
|
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
|
||||||
Name: "Coin", Kind: dnd.ItemEventKindDiscovered,
|
ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
||||||
}}}
|
}}}
|
||||||
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") {
|
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") {
|
||||||
t.Fatalf("Validate() error = %v, want document-order rejection", err)
|
t.Fatalf("Validate() error = %v, want document-order rejection", err)
|
||||||
}
|
}
|
||||||
shapeInvalid := normalizedList()
|
shapeInvalid := normalizedList()
|
||||||
shapeInvalid.Events[0].Name = " "
|
shapeInvalid.Occurrences[0].Name = " "
|
||||||
if err := Validate(invariantDocument(), shapeInvalid); err != nil {
|
if err := Validate(invariantDocument(), shapeInvalid); err != nil {
|
||||||
t.Fatalf("shape failure must be deferred, got %v", err)
|
t.Fatalf("shape failure must be deferred, got %v", err)
|
||||||
}
|
}
|
||||||
sourceInvalid := normalizedList()
|
sourceInvalid := normalizedList()
|
||||||
sourceInvalid.Events[0].SourceRefs[0].StartUnitID = 999
|
sourceInvalid.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||||
if err := Validate(invariantDocument(), sourceInvalid); err != nil {
|
if err := Validate(invariantDocument(), sourceInvalid); err != nil {
|
||||||
t.Fatalf("source-reference failure must be deferred, got %v", err)
|
t.Fatalf("source-reference failure must be deferred, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -93,12 +88,12 @@ func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
|
func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
|
||||||
value := normalizedList()
|
value := normalizedList()
|
||||||
value.Events = make([]dnd.ItemEvent, 30)
|
value.Occurrences = make([]dnd.ItemOccurrence, 30)
|
||||||
for index := range value.Events {
|
for index := range value.Occurrences {
|
||||||
value.Events[index] = normalizedList().Events[0]
|
value.Occurrences[index] = normalizedList().Occurrences[0]
|
||||||
value.Events[index].Name = strings.Repeat("火", 300)
|
value.Occurrences[index].Name = strings.Repeat("火", 300)
|
||||||
}
|
}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
|
||||||
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||||
t.Fatalf("bounded result = %#v, %v", result, err)
|
t.Fatalf("bounded result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -117,10 +112,9 @@ func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizedList() dnd.ItemEventList {
|
func normalizedList() dnd.ItemOccurrenceList {
|
||||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{
|
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Arrow", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||||
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
{ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||||
{Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
119
internal/modules/dnd/validate/itemevents/registry/validator.go
Normal file
119
internal/modules/dnd/validate/itemevents/registry/validator.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// Package registry validates item occurrence identity against item grounding.
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
|
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Key = "extract/dnd/item-events/registry"
|
||||||
|
ReasonCode = "invalid_item_occurrence_registry"
|
||||||
|
policy = "dnd.item_occurrences.validator.registry.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct{}
|
||||||
|
type Validator struct{ itemResolver *itemregistry.Resolver }
|
||||||
|
|
||||||
|
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*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("item occurrence registry validator accepts at most one reference set")
|
||||||
|
}
|
||||||
|
var referenceSet contracts.ReferenceSet
|
||||||
|
if len(references) == 1 {
|
||||||
|
referenceSet = references[0]
|
||||||
|
}
|
||||||
|
resolver, err := itemregistry.NewResolver(referenceSet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare item registry: %w", err)
|
||||||
|
}
|
||||||
|
return &Validator{itemResolver: 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.itemResolver == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
metadata := map[string]any{"policy": policy}
|
||||||
|
seeded := v.itemResolver.Seeded()
|
||||||
|
if seeded.Bound() {
|
||||||
|
metadata["item_registry_digest"] = seeded.Digest()
|
||||||
|
metadata["item_count"] = seeded.Count()
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
|
if v == nil || v.itemResolver == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "item_registry", Value: v.itemResolver.Seeded().ProjectionDigest()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
|
if occurrenceshape.Validate(req.Value) != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
if v == nil || v.itemResolver == nil {
|
||||||
|
return contracts.ValidationResult{}, fmt.Errorf("item occurrence registry validator must not be nil")
|
||||||
|
}
|
||||||
|
registry, err := v.itemResolver.Resolve(req.References)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err)
|
||||||
|
}
|
||||||
|
if !registry.Bound() {
|
||||||
|
return rejection([]string{"item registry reference is required"}), nil
|
||||||
|
}
|
||||||
|
issues := make([]string, 0)
|
||||||
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
|
item, ok := registry.LookupID(occurrence.ItemID)
|
||||||
|
if !ok {
|
||||||
|
issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if occurrence.Name != item.Name {
|
||||||
|
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
return rejection(issues), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejection(issues []string) contracts.ValidationResult {
|
||||||
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues)}
|
||||||
|
}
|
||||||
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
|
}
|
||||||
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], 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,37 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
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/modules/dnd"
|
||||||
|
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
|
||||||
|
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||||
|
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidatorRejectsUnknownAndMismatchedItemPairs(t *testing.T) {
|
||||||
|
id := itemidentity.DeriveID("Torch")
|
||||||
|
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: id, Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{itemregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: itemregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: itemregistry.ReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||||
|
validator, err := New(Options{}, references)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
base := dnd.ItemOccurrence{ItemID: id, Name: "Torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||||
|
for _, occurrence := range []dnd.ItemOccurrence{{ItemID: "unknown", Name: "Unknown", Kind: base.Kind, SourceRefs: base.SourceRefs}, {ItemID: id, Name: "Lantern", Kind: base.Kind, SourceRefs: base.SourceRefs}} {
|
||||||
|
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{occurrence}}})
|
||||||
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||||
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{base}}})
|
||||||
|
if err != nil || !result.Approved {
|
||||||
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
@@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Value); err != nil {
|
if err := Validate(req.Value); err != nil {
|
||||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||||
}
|
}
|
||||||
@@ -42,21 +42,24 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate returns one bounded error for every owned item-event shape issue.
|
// Validate returns one bounded error for every owned item-event shape issue.
|
||||||
func Validate(value dnd.ItemEventList) error {
|
func Validate(value dnd.ItemOccurrenceList) error {
|
||||||
issues := issuesFor(value)
|
issues := issuesFor(value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item event shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item occurrence shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.ItemEventList) []string {
|
func issuesFor(value dnd.ItemOccurrenceList) []string {
|
||||||
if value.Events == nil {
|
if value.Occurrences == nil {
|
||||||
return []string{"events must be present"}
|
return []string{"occurrences must be present"}
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, event := range value.Events {
|
for index, event := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("events[%d]", index)
|
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||||
|
if strings.TrimSpace(event.ItemID) == "" {
|
||||||
|
issues = append(issues, prefix+".item_id must not be empty: "+diagnostics.Quote(event.ItemID))
|
||||||
|
}
|
||||||
if strings.TrimSpace(event.Name) == "" {
|
if strings.TrimSpace(event.Name) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
||||||
}
|
}
|
||||||
@@ -80,7 +83,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -15,18 +15,17 @@ import (
|
|||||||
|
|
||||||
func TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) {
|
func TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) {
|
||||||
quantity := 1
|
quantity := 1
|
||||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: " Hidden Cache ", Kind: dnd.ItemOccurrenceKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
|
||||||
{Name: " Hidden Cache ", Kind: dnd.ItemEventKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
|
{ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
|
||||||
{Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
|
{ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: " party ", SourceRefs: refs(3, 3)},
|
||||||
{Name: "Torch", Kind: dnd.ItemEventKindLost, From: " party ", SourceRefs: refs(3, 3)},
|
{ItemID: "item", Name: "Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
|
||||||
{Name: "Potion", Kind: dnd.ItemEventKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
|
{ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||||
{Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
|
||||||
}}
|
}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: dnd.ItemEventList{Events: []dnd.ItemEvent{}}})
|
empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}})
|
||||||
if err != nil || !empty.Approved {
|
if err != nil || !empty.Approved {
|
||||||
t.Fatalf("empty list result = %#v, %v", empty, err)
|
t.Fatalf("empty list result = %#v, %v", empty, err)
|
||||||
}
|
}
|
||||||
@@ -38,47 +37,47 @@ func TestValidatorRejectsOwnedSemanticBoundaries(t *testing.T) {
|
|||||||
negative := -1
|
negative := -1
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
value dnd.ItemEventList
|
value dnd.ItemOccurrenceList
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"missing events", dnd.ItemEventList{}, "events must be present"},
|
{"missing events", dnd.ItemOccurrenceList{}, "occurrences must be present"},
|
||||||
{"blank name", listWith(dnd.ItemEvent{Name: " \t", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"},
|
{"blank name", listWith(dnd.ItemOccurrence{ItemID: "item", Name: " \t", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"},
|
||||||
{"unsupported kind", listWith(dnd.ItemEvent{Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"},
|
{"unsupported kind", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"},
|
||||||
{"discovered holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
{"discovered holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"acquired without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"},
|
{"acquired without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"lost destination", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
{"lost destination", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"consumed without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"},
|
{"consumed without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"party transfer from", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
{"party transfer from", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"party transfer to", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"},
|
{"party transfer to", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"case equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
{"case equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"unicode equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"},
|
{"unicode equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||||
{"zero quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
{"zero quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||||
{"negative quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
{"negative quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||||
{"missing source refs", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered}), "source_refs must contain"},
|
{"missing source refs", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered}), "source_refs must contain"},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: test.value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: test.value})
|
||||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
|
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)
|
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: valid}); err != nil || !result.Approved {
|
if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: valid}); err != nil || !result.Approved {
|
||||||
t.Fatalf("valid result = %#v, %v", result, err)
|
t.Fatalf("valid result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) {
|
func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) {
|
||||||
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)}
|
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
|
||||||
for index := range value.Events {
|
for index := range value.Occurrences {
|
||||||
value.Events[index] = dnd.ItemEvent{Name: " \n", Kind: "unsupported"}
|
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: " \n", Kind: "unsupported"}
|
||||||
}
|
}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value})
|
||||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
if value.Events[0].Name != " \n" {
|
if value.Occurrences[0].Name != " \n" {
|
||||||
t.Fatal("Validate() mutated input")
|
t.Fatal("Validate() mutated input")
|
||||||
}
|
}
|
||||||
if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) {
|
if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) {
|
||||||
@@ -96,12 +95,12 @@ func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validList() dnd.ItemEventList {
|
func validList() dnd.ItemOccurrenceList {
|
||||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
|
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listWith(event dnd.ItemEvent) dnd.ItemEventList {
|
func listWith(event dnd.ItemOccurrence) dnd.ItemOccurrenceList {
|
||||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{event}}
|
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{event}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refs(start, end int) []source.SourceRef {
|
func refs(start, end int) []source.SourceRef {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
@@ -34,9 +34,9 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
|
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
|
||||||
return contracts.ValidationResult{}, fmt.Errorf("item event source-reference validator requires the current extraction chunk")
|
return contracts.ValidationResult{}, fmt.Errorf("item occurrence source-reference validator requires the current extraction chunk")
|
||||||
}
|
}
|
||||||
if itemeventshape.Validate(req.Value) != nil {
|
if itemeventshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
@@ -47,21 +47,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
coverage = newChunkCoverage(req.Chunk)
|
coverage = newChunkCoverage(req.Chunk)
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for eventIndex, event := range req.Value.Events {
|
for eventIndex, event := range req.Value.Occurrences {
|
||||||
for refIndex, ref := range event.SourceRefs {
|
for refIndex, ref := range event.SourceRefs {
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
if coverage != nil && !coverage.contains(req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item event source references", issues)}, nil
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type chunkCoverage struct {
|
type chunkCoverage struct {
|
||||||
@@ -102,7 +102,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
|
|||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("valid result = %#v, %v", result, err)
|
t.Fatalf("valid result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
value.Events[0].SourceRefs = []source.SourceRef{
|
value.Occurrences[0].SourceRefs = []source.SourceRef{
|
||||||
{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1},
|
{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1},
|
||||||
{SourceID: "session", StartUnitID: 99, EndUnitID: 99},
|
{SourceID: "session", StartUnitID: 99, EndUnitID: 99},
|
||||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
|
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
|
||||||
@@ -28,7 +28,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
|
|||||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||||
t.Fatalf("invalid result = %#v, %v", result, err)
|
t.Fatalf("invalid result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
for _, index := range []string{"events[0].source_refs[0]", "events[0].source_refs[1]", "events[0].source_refs[2]"} {
|
for _, index := range []string{"occurrences[0].source_refs[0]", "occurrences[0].source_refs[1]", "occurrences[0].source_refs[2]"} {
|
||||||
if !strings.Contains(result.Message, index) {
|
if !strings.Contains(result.Message, index) {
|
||||||
t.Fatalf("validation message = %q, missing %q", result.Message, index)
|
t.Fatalf("validation message = %q, missing %q", result.Message, index)
|
||||||
}
|
}
|
||||||
@@ -39,40 +39,40 @@ func TestValidatorEnforcesChunkOnlyDuringExtraction(t *testing.T) {
|
|||||||
doc := document()
|
doc := document()
|
||||||
chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)}
|
chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)}
|
||||||
contained := validList()
|
contained := validList()
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("contained evidence = %#v, %v", result, err)
|
t.Fatalf("contained evidence = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
crossesChunk := validList()
|
crossesChunk := validList()
|
||||||
crossesChunk.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}}
|
crossesChunk.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}}
|
||||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk})
|
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk})
|
||||||
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||||
t.Fatalf("out-of-chunk evidence = %#v, %v", result, err)
|
t.Fatalf("out-of-chunk evidence = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
noncontiguousChunk := &source.Chunk{ID: "chunk-1", SourceID: doc.ID, Units: []source.SourceUnit{doc.Units[0], doc.Units[2]}}
|
noncontiguousChunk := &source.Chunk{ID: "chunk-1", SourceID: doc.ID, Units: []source.SourceUnit{doc.Units[0], doc.Units[2]}}
|
||||||
spansMissingUnit := validList()
|
spansMissingUnit := validList()
|
||||||
spansMissingUnit.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
|
spansMissingUnit.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
|
||||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
|
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
|
||||||
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||||
t.Fatalf("partially contained evidence = %#v, %v", result, err)
|
t.Fatalf("partially contained evidence = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
multiRange := validList()
|
multiRange := validList()
|
||||||
multiRange.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}}
|
multiRange.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}}
|
||||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: multiRange})
|
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: multiRange})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("post-merge evidence = %#v, %v", result, err)
|
t.Fatalf("post-merge evidence = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
|
func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
|
||||||
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()})
|
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()})
|
||||||
if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") {
|
if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") {
|
||||||
t.Fatalf("Validate() error = %v", err)
|
t.Fatalf("Validate() error = %v", err)
|
||||||
}
|
}
|
||||||
malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring"}}}
|
malformed := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring"}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), request(document(), malformed))
|
result, err := New(Options{}).Validate(context.Background(), request(document(), malformed))
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("shape deferral = %#v, %v", result, err)
|
t.Fatalf("shape deferral = %#v, %v", result, err)
|
||||||
@@ -80,12 +80,12 @@ func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||||
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)}
|
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
|
||||||
for index := range value.Events {
|
for index := range value.Occurrences {
|
||||||
value.Events[index] = dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}}
|
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}}
|
||||||
}
|
}
|
||||||
result, err := New(Options{}).Validate(context.Background(), request(document(), value))
|
result, err := New(Options{}).Validate(context.Background(), request(document(), value))
|
||||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
before := validList()
|
before := validList()
|
||||||
@@ -108,14 +108,14 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(doc *source.SourceDocument, value dnd.ItemEventList) contracts.TypedValidationRequest[dnd.ItemEventList] {
|
func request(doc *source.SourceDocument, value dnd.ItemOccurrenceList) contracts.TypedValidationRequest[dnd.ItemOccurrenceList] {
|
||||||
return contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value}
|
return contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value}
|
||||||
}
|
}
|
||||||
|
|
||||||
func document() *source.SourceDocument {
|
func document() *source.SourceDocument {
|
||||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}}
|
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validList() dnd.ItemEventList {
|
func validList() dnd.ItemOccurrenceList {
|
||||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
|
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
@@ -37,7 +37,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
|
|
||||||
// Validate performs advisory source grounding only. Shape and range failures
|
// Validate performs advisory source grounding only. Shape and range failures
|
||||||
// are intentionally left to their blocking owners.
|
// are intentionally left to their blocking owners.
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if itemeventshape.Validate(req.Value) != nil {
|
if itemeventshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -47,15 +47,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, event := range req.Value.Events {
|
for index, event := range req.Value.Occurrences {
|
||||||
citedText, err := resolver.CitedText(event.SourceRefs)
|
citedText, err := resolver.CitedText(event.SourceRefs)
|
||||||
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
|
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
Scope: fmt.Sprintf("events[%d]", index),
|
Scope: fmt.Sprintf("occurrences[%d]", index),
|
||||||
ReasonCode: WarningReasonCode,
|
ReasonCode: WarningReasonCode,
|
||||||
Message: fmt.Sprintf("item event name %s was not found in cited source text", diagnostics.Quote(event.Name)),
|
Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(event.Name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{
|
return contracts.ValidationResult{
|
||||||
@@ -69,7 +69,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -19,29 +19,28 @@ func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) {
|
|||||||
{ID: 2, Text: "of healing in the chest."},
|
{ID: 2, Text: "of healing in the chest."},
|
||||||
{ID: 3, Text: "The group rests."},
|
{ID: 3, Text: "The group rests."},
|
||||||
}}
|
}}
|
||||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "POTION OF HEALING", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
||||||
{Name: "POTION OF HEALING", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
{ItemID: "item", Name: "missing-item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
|
||||||
{Name: "missing-item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
|
|
||||||
}}
|
}}
|
||||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}},
|
"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}},
|
||||||
}}
|
}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, References: references, Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, References: references, Value: value})
|
||||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
if warning := result.Warnings[0]; warning.Scope != "events[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
|
if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
|
||||||
t.Fatalf("warning = %#v", warning)
|
t.Fatalf("warning = %#v", warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}}
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}}
|
||||||
for _, value := range []dnd.ItemEventList{
|
for _, value := range []dnd.ItemOccurrenceList{
|
||||||
{Events: []dnd.ItemEvent{{Name: "", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
|
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
|
||||||
{Events: []dnd.ItemEvent{{Name: "potion", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
|
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "potion", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
|
||||||
} {
|
} {
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value})
|
||||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
t.Fatalf("deferred result = %#v, %v", result, err)
|
t.Fatalf("deferred result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -50,13 +49,13 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) {
|
func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) {
|
||||||
count := diagnostics.MaxWarnings + 5
|
count := diagnostics.MaxWarnings + 5
|
||||||
events := make([]dnd.ItemEvent, count)
|
events := make([]dnd.ItemOccurrence, count)
|
||||||
for index := range events {
|
for index := range events {
|
||||||
events[index] = dnd.ItemEvent{Name: "missing item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
events[index] = dnd.ItemOccurrence{ItemID: "item", Name: "missing item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||||
}
|
}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{
|
||||||
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}},
|
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}},
|
||||||
Value: dnd.ItemEventList{Events: events},
|
Value: dnd.ItemOccurrenceList{Occurrences: events},
|
||||||
})
|
})
|
||||||
if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
|
if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
|
|||||||
@@ -1,170 +1,62 @@
|
|||||||
package integration_test
|
package integration_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
|
||||||
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
|
||||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
|
||||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
|
||||||
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"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProductionItemEventPipelineProducesNormalizedDurableArtifact(t *testing.T) {
|
func TestItemOccurrenceConfigurationRequiresEarlierItemRegistry(t *testing.T) {
|
||||||
raw := readItemEventFixture(t)
|
|
||||||
registries := productionNPCRegistries(t)
|
registries := productionNPCRegistries(t)
|
||||||
resolved := resolveItemEventPipeline(t, registries)
|
cfg, err := configFromYAML(`
|
||||||
client := &itemEventProductionLLMClient{responses: [][]byte{[]byte(`{"events":[
|
version: 4
|
||||||
{"name":"Healing Potion","kind":"transferred","quantity":1,"from":"Aria","to":"Borin","source_refs":[{"start_segment":3,"end_segment":3}]},
|
pipelines:
|
||||||
{"name":" Gold Pieces ","kind":"acquired","quantity":25,"to":" party ","source_refs":[{"start_segment":2,"end_segment":2}]},
|
items:
|
||||||
{"name":"Missing Relic","kind":"discovered","source_refs":[{"start_segment":4,"end_segment":4}]},
|
input: seriatim
|
||||||
{"name":"Ancient Coin","kind":"discovered","source_refs":[{"start_segment":1,"end_segment":1}]},
|
chunk: generic
|
||||||
{"name":"Gold Pieces","kind":"acquired","quantity":25,"to":"party","source_refs":[{"start_segment":2,"end_segment":2}]}
|
steps:
|
||||||
]}`)}}
|
- id: identify-items
|
||||||
|
artifacts:
|
||||||
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: raw})
|
registry:
|
||||||
if err != nil {
|
extract: dnd/item-registry
|
||||||
t.Fatalf("Run() error = %v", err)
|
merge: appendorder
|
||||||
}
|
normalize: dnd/item-registry
|
||||||
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
|
- id: record-occurrences
|
||||||
t.Fatalf("run output = %#v, rejected = %#v", output.NormalizeOutputs, output.Rejected)
|
references:
|
||||||
}
|
item_registry:
|
||||||
serialized := normalizedLane(t, output, "item-events")
|
artifact:
|
||||||
if serialized.NormalizerKey != itemeventnormalize.Key || serialized.Artifact.Schema.ID != itemeventcodec.SchemaID || serialized.Artifact.Schema.Version != itemeventcodec.SchemaVersion {
|
step: identify-items
|
||||||
t.Fatalf("serialized item events = %#v", serialized)
|
lane: registry
|
||||||
}
|
artifacts:
|
||||||
events, err := itemeventcodec.New().Decode(serialized.Artifact.Content)
|
occurrences:
|
||||||
if err != nil {
|
extract: dnd/item-events
|
||||||
t.Fatalf("Decode(item event output) error = %v", err)
|
merge: appendorder
|
||||||
}
|
normalize: dnd/item-events
|
||||||
if len(events.Events) != 4 {
|
`)
|
||||||
t.Fatalf("item events = %#v, want one exact duplicate removed", events)
|
|
||||||
}
|
|
||||||
first, second, third, fourth := events.Events[0], events.Events[1], events.Events[2], events.Events[3]
|
|
||||||
if first.Name != "Ancient Coin" || second.Name != "Gold Pieces" || second.Kind != dnd.ItemEventKindAcquired || second.Quantity == nil || *second.Quantity != 25 || second.To != "party" || third.Name != "Healing Potion" || third.Kind != dnd.ItemEventKindTransferred || third.From != "Aria" || third.To != "Borin" || fourth.Name != "Missing Relic" {
|
|
||||||
t.Fatalf("normalized item event sequence = %#v", events.Events)
|
|
||||||
}
|
|
||||||
for _, event := range events.Events {
|
|
||||||
for _, ref := range event.SourceRefs {
|
|
||||||
if ref.SourceID != "item-events-session" {
|
|
||||||
t.Fatalf("source reference = %#v, want current transcript source", ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasItemEventWarning(output.Warnings, "item_event_source_unrelated") || !hasItemEventWarning(output.Warnings, "duplicate_item_event_collapsed") {
|
|
||||||
t.Fatalf("warnings = %#v, want advisory relatedness and duplicate-collapse warnings", output.Warnings)
|
|
||||||
}
|
|
||||||
if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 {
|
|
||||||
t.Fatalf("manifest = %#v", output.Manifest)
|
|
||||||
}
|
|
||||||
lane := output.Manifest.ArtifactLanes[0]
|
|
||||||
if lane.ID != "item-events" || lane.Extractor != itemeventextract.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != itemeventnormalize.Key {
|
|
||||||
t.Fatalf("manifest lane = %#v", lane)
|
|
||||||
}
|
|
||||||
if len(client.requests) != 1 || client.requests[0].PromptID != itemeventextract.PromptID {
|
|
||||||
t.Fatalf("LLM requests = %#v", client.requests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProductionItemEventPipelineRetriesBlockingCandidates(t *testing.T) {
|
|
||||||
registries := productionNPCRegistries(t)
|
|
||||||
resolved := resolveItemEventPipeline(t, registries)
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
response string
|
|
||||||
reasonCode string
|
|
||||||
validatorName string
|
|
||||||
}{
|
|
||||||
{name: "shape", response: `{"events":[{"name":"","kind":"discovered","source_refs":[{"start_segment":1,"end_segment":1}]}]}`, reasonCode: itemeventshape.ReasonCode, validatorName: itemeventshape.Key},
|
|
||||||
{name: "source references", response: `{"events":[{"name":"Ancient Coin","kind":"discovered","source_refs":[{"start_segment":99,"end_segment":99}]}]}`, reasonCode: itemeventrefs.ReasonCode, validatorName: itemeventrefs.Key},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
client := &itemEventProductionLLMClient{responses: [][]byte{[]byte(test.response), []byte(test.response), []byte(test.response)}}
|
|
||||||
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: readItemEventFixture(t)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run() error = %v; want rejected candidate outcome", err)
|
|
||||||
}
|
|
||||||
if len(output.NormalizeOutputs) != 0 || len(output.Rejected) != 1 || len(client.requests) != 3 {
|
|
||||||
t.Fatalf("output=%#v rejected=%#v requests=%d", output.NormalizeOutputs, output.Rejected, len(client.requests))
|
|
||||||
}
|
|
||||||
rejection := output.Rejected[0]
|
|
||||||
if rejection.ReasonCode != test.reasonCode || rejection.ValidatorName != test.validatorName || rejection.AttemptCount != 3 {
|
|
||||||
t.Fatalf("rejection = %#v", rejection)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveItemEventPipeline(t *testing.T, registries pipeline.Registries) pipeline.ResolvedPipeline {
|
|
||||||
t.Helper()
|
|
||||||
configValue := config.Default()
|
|
||||||
configValue.Pipelines["dnd-item-events-fixture"] = pipeline.PipelineProfile{
|
|
||||||
Input: pipeline.Binding(transcript.Key),
|
|
||||||
Chunk: pipeline.ModuleBinding{Module: pipeline.DefaultChunkModule, Options: map[string]any{"max_units": 100}},
|
|
||||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
|
||||||
"item-events": {
|
|
||||||
Extract: pipeline.ModuleBinding{Module: itemeventextract.Key, Retries: 2},
|
|
||||||
Normalize: pipeline.Binding(itemeventnormalize.Key),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-item-events-fixture", Catalog: moduleCatalog(registries)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
|
||||||
}
|
|
||||||
return effective.ResolvedPipeline
|
|
||||||
}
|
|
||||||
|
|
||||||
func readItemEventFixture(t *testing.T) []byte {
|
|
||||||
t.Helper()
|
|
||||||
raw, err := os.ReadFile("testdata/seriatim_item_events_session.json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return raw
|
if _, err := cfg.Resolve(config.ResolveInput{PipelineID: "items", Catalog: moduleCatalog(registries)}); err != nil {
|
||||||
}
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
func hasItemEventWarning(warnings []contracts.Warning, reasonCode string) bool {
|
broken, err := configFromYAML(strings.Replace(`
|
||||||
for _, warning := range warnings {
|
version: 4
|
||||||
if warning.ReasonCode == reasonCode && strings.HasPrefix(warning.Scope, "events[") {
|
pipelines:
|
||||||
return true
|
items:
|
||||||
}
|
input: seriatim
|
||||||
|
chunk: generic
|
||||||
|
steps:
|
||||||
|
- id: record-occurrences
|
||||||
|
artifacts:
|
||||||
|
occurrences:
|
||||||
|
extract: dnd/item-events
|
||||||
|
`, "record-occurrences", "record-occurrences", 1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := broken.Resolve(config.ResolveInput{PipelineID: "items", Catalog: moduleCatalog(registries)}); err == nil {
|
||||||
|
t.Fatal("Resolve() error = nil, want required item registry")
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type itemEventProductionLLMClient struct {
|
|
||||||
responses [][]byte
|
|
||||||
requests []contracts.StructuredCompletionRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *itemEventProductionLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return contracts.StructuredCompletionResponse{}, err
|
|
||||||
}
|
|
||||||
if request.PromptID != itemeventextract.PromptID {
|
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
|
|
||||||
}
|
|
||||||
index := len(client.requests)
|
|
||||||
if index >= len(client.responses) {
|
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing item event response %d", index)
|
|
||||||
}
|
|
||||||
content := append([]byte(nil), client.responses[index]...)
|
|
||||||
if err := json.Unmarshal(content, out); err != nil {
|
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate item event structured target: %w", err)
|
|
||||||
}
|
|
||||||
client.requests = append(client.requests, request)
|
|
||||||
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "item-events-fake"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ contracts.StructuredLLMClient = (*itemEventProductionLLMClient)(nil)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user