Adopt registry-backed NPC occurrence artifacts
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
Extract Dungeons & Dragons NPC interaction occurrences from the supplied
|
Extract Dungeons & Dragons NPC interaction occurrences from the supplied
|
||||||
transcript. Include an occurrence only when the transcript establishes one
|
transcript. Include an occurrence only when the transcript establishes one
|
||||||
supplied NPC, one interaction kind, and a coherent passage supporting both.
|
supplied NPC, one interaction kind, and a coherent passage supporting both.
|
||||||
Use only names from the supplied NPC registry.
|
Use the exact `npc_id` and matching `name` pair from the supplied NPC registry;
|
||||||
|
never invent an ID or substitute a similar name.
|
||||||
|
|
||||||
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
|
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
|
||||||
or persistent state. Do not identify player characters, anonymous groups, or
|
or persistent state. Do not identify player characters, anonymous groups, or
|
||||||
|
|||||||
@@ -41,5 +41,5 @@ messages:
|
|||||||
output:
|
output:
|
||||||
format: json
|
format: json
|
||||||
validation_mode: json_schema
|
validation_mode: json_schema
|
||||||
schema_path: dnd_npc_interactions_llm.v1.json
|
schema_path: dnd_npc_occurrences_llm.v1.json
|
||||||
repair_attempts: 0
|
repair_attempts: 0
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "notarius.dnd.npc_interactions.llm",
|
"$id": "notarius.dnd.npc_occurrences.llm",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["interactions"],
|
"required": ["occurrences"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"interactions": {
|
"occurrences": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "kind", "source_refs"],
|
"required": ["npc_id", "name", "kind", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"npc_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -9,22 +9,23 @@ or summarize the session.
|
|||||||
|
|
||||||
| Property | Value |
|
| Property | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Artifact kind | `dnd/npc-interaction-list` |
|
| Artifact kind | `dnd/npc-occurrence-list` |
|
||||||
| Schema ID | `notarius.dnd.npc_interactions` |
|
| Schema ID | `notarius.dnd.npc_occurrences` |
|
||||||
| Schema name | `notarius_dnd_npc_interactions_v1` |
|
| Schema name | `notarius_dnd_npc_occurrences_v1` |
|
||||||
| Schema version | `v1` |
|
| Schema version | `v1` |
|
||||||
| Media type | `application/json` |
|
| Media type | `application/json` |
|
||||||
|
|
||||||
`v1` is a strict JSON object with required `interactions`; the array may be
|
`v1` is a strict JSON object with required `occurrences`; the array may be
|
||||||
empty. Interaction and source-reference objects reject unknown fields. An
|
empty. Occurrence and source-reference objects reject unknown fields. An
|
||||||
incompatible shape change requires a new schema version.
|
incompatible shape change requires a new schema version.
|
||||||
|
|
||||||
## Wire shape
|
## Wire shape
|
||||||
|
|
||||||
Each interaction has these required fields:
|
Each occurrence has these required fields:
|
||||||
|
|
||||||
| Field | Contract |
|
| Field | Contract |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| `npc_id` | Exact durable ID from the required NPC registry. |
|
||||||
| `name` | Non-empty canonical display name from the required NPC registry. |
|
| `name` | Non-empty canonical display name from the required NPC registry. |
|
||||||
| `kind` | One of the interaction categories below. |
|
| `kind` | One of the interaction categories below. |
|
||||||
| `source_refs` | One or more transcript evidence ranges. |
|
| `source_refs` | One or more transcript evidence ranges. |
|
||||||
@@ -36,8 +37,9 @@ for an interaction is confined to its accepted chunk.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"interactions": [
|
"occurrences": [
|
||||||
{
|
{
|
||||||
|
"npc_id": "npc:sha256:example",
|
||||||
"name": "Mira Thorn",
|
"name": "Mira Thorn",
|
||||||
"kind": "dialogue",
|
"kind": "dialogue",
|
||||||
"source_refs": [
|
"source_refs": [
|
||||||
@@ -65,12 +67,14 @@ for uncertain classification.
|
|||||||
|
|
||||||
## Identity, evidence, and order
|
## Identity, evidence, and order
|
||||||
|
|
||||||
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves `name`.
|
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves
|
||||||
Registry references are provenance only and never replace an interaction's own
|
the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their
|
||||||
evidence. Normalization canonicalizes recognized registry names, orders and
|
ID are rejected; normalization does not repair names by similarity. Registry
|
||||||
|
references are provenance only and never replace an interaction's own evidence.
|
||||||
|
Normalization validates the exact pair, orders and
|
||||||
deduplicates exact source references, then orders interactions by valid source
|
deduplicates exact source references, then orders interactions by valid source
|
||||||
chronology, NPC comparison identity, display name, kind, and reference sequence.
|
chronology, NPC comparison identity, display name, kind, and reference sequence.
|
||||||
Only entries with the same canonical name, kind, and complete valid evidence
|
Only entries with the same NPC ID, canonical name, kind, and complete valid evidence
|
||||||
sequence are collapsed; distinct categories or evidence remain separate.
|
sequence are collapsed; distinct categories or evidence remain separate.
|
||||||
|
|
||||||
See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action
|
See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action
|
||||||
|
|||||||
@@ -259,9 +259,20 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
|||||||
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 npcinteractions.PromptID:
|
case npcinteractions.PromptID:
|
||||||
if combatScene {
|
if combatScene {
|
||||||
content = []byte(`{"interactions":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
|
var registry struct {
|
||||||
|
NPCs []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"npcs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, ®istry); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err)
|
||||||
|
}
|
||||||
|
if len(registry.NPCs) == 0 {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs")
|
||||||
|
}
|
||||||
|
content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID))
|
||||||
} else {
|
} else {
|
||||||
content = []byte(`{"interactions":[]}`)
|
content = []byte(`{"occurrences":[]}`)
|
||||||
}
|
}
|
||||||
case locationoccurrences.PromptID:
|
case locationoccurrences.PromptID:
|
||||||
var registry struct {
|
var registry struct {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
|
|||||||
t.Fatalf("resolved pipeline = %#v", resolved)
|
t.Fatalf("resolved pipeline = %#v", resolved)
|
||||||
}
|
}
|
||||||
lane := resolved.Steps[1].ArtifactLanes[0]
|
lane := resolved.Steps[1].ArtifactLanes[0]
|
||||||
if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key {
|
if lane.ArtifactKind != dnd.NPCOccurrenceListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key {
|
||||||
t.Fatalf("interaction lane = %#v", lane)
|
t.Fatalf("interaction lane = %#v", lane)
|
||||||
}
|
}
|
||||||
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
|
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
|
||||||
@@ -38,7 +38,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
catalog := catalogFromRegistries(components.registries)
|
catalog := catalogFromRegistries(components.registries)
|
||||||
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind)
|
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCOccurrenceListKind)
|
||||||
if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion {
|
if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion {
|
||||||
t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
|
t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "notarius.dnd.npc_interactions",
|
"$id": "notarius.dnd.npc_occurrences",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["interactions"],
|
"required": ["occurrences"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"interactions": {
|
"occurrences": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "kind", "source_refs"],
|
"required": ["npc_id", "name", "kind", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"npc_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package npcinteractions encodes durable D&D NPC interaction artifacts.
|
// Package npcinteractions encodes durable D&D NPC occurrence artifacts.
|
||||||
package npcinteractions
|
package npcinteractions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,25 +12,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SchemaID = "notarius.dnd.npc_interactions"
|
SchemaID = "notarius.dnd.npc_occurrences"
|
||||||
SchemaName = "notarius_dnd_npc_interactions_v1"
|
SchemaName = "notarius_dnd_npc_occurrences_v1"
|
||||||
SchemaVersion = "v1"
|
SchemaVersion = "v1"
|
||||||
MediaType = "application/json"
|
MediaType = "application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/schemas/dnd_npc_interactions.v1.json
|
//go:embed assets/schemas/dnd_npc_occurrences.v1.json
|
||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*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.NPCInteractionListKind }
|
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCOccurrenceListKind }
|
||||||
|
|
||||||
func (c *Codec) Schema() contracts.ArtifactSchema {
|
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||||
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_interactions.v1.json")
|
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_occurrences.v1.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ArtifactSchema{}
|
return contracts.ArtifactSchema{}
|
||||||
}
|
}
|
||||||
@@ -44,56 +44,59 @@ 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.NPCInteractionList) map[string]any {
|
func (c *Codec) Metadata(value dnd.NPCOccurrenceList) map[string]any {
|
||||||
return map[string]any{"interaction_count": len(value.Interactions)}
|
return map[string]any{"occurrence_count": len(value.Occurrences)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) {
|
func (c *Codec) Encode(value dnd.NPCOccurrenceList) ([]byte, error) {
|
||||||
if err := validate(value); err != nil {
|
if err := validate(value); err != nil {
|
||||||
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
|
return nil, fmt.Errorf("encode dnd npc 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.NPCInteractionList) ([]byte, error) {
|
func (c *Codec) EncodeCandidate(value dnd.NPCOccurrenceList) ([]byte, error) {
|
||||||
return candidatejson.EncodeCandidate("dnd npc interaction list", value)
|
return candidatejson.EncodeCandidate("dnd npc occurrence list", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
|
func (c *Codec) Decode(content []byte) (dnd.NPCOccurrenceList, error) {
|
||||||
value, err := c.DecodeCandidate(content)
|
value, err := c.DecodeCandidate(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dnd.NPCInteractionList{}, err
|
return dnd.NPCOccurrenceList{}, err
|
||||||
}
|
}
|
||||||
if err := validate(value); err != nil {
|
if err := validate(value); err != nil {
|
||||||
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
|
return dnd.NPCOccurrenceList{}, fmt.Errorf("decode dnd npc 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.NPCInteractionList, error) {
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCOccurrenceList, error) {
|
||||||
return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content)
|
return candidatejson.DecodeCandidate[dnd.NPCOccurrenceList]("dnd npc occurrence list", content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.NPCInteractionList) error {
|
func validate(value dnd.NPCOccurrenceList) error {
|
||||||
if value.Interactions == nil {
|
if value.Occurrences == nil {
|
||||||
return fmt.Errorf("interactions must be present")
|
return fmt.Errorf("occurrences must be present")
|
||||||
}
|
}
|
||||||
for index, interaction := range value.Interactions {
|
for index, occurrence := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("interactions[%d]", index)
|
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||||
if strings.TrimSpace(interaction.Name) == "" {
|
if strings.TrimSpace(occurrence.NPCID) == "" {
|
||||||
|
return fmt.Errorf("%s.npc_id must not be empty", prefix)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(occurrence.Name) == "" {
|
||||||
return fmt.Errorf("%s.name must not be empty", prefix)
|
return fmt.Errorf("%s.name must not be empty", prefix)
|
||||||
}
|
}
|
||||||
if !validInteractionKind(interaction.Kind) {
|
if !validOccurrenceKind(occurrence.Kind) {
|
||||||
return fmt.Errorf("%s.kind must be supported", prefix)
|
return fmt.Errorf("%s.kind must be supported", prefix)
|
||||||
}
|
}
|
||||||
if len(interaction.SourceRefs) == 0 {
|
if len(occurrence.SourceRefs) == 0 {
|
||||||
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
|
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
|
||||||
}
|
}
|
||||||
for refIndex, ref := range interaction.SourceRefs {
|
for refIndex, ref := range occurrence.SourceRefs {
|
||||||
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
||||||
if strings.TrimSpace(ref.SourceID) == "" {
|
if strings.TrimSpace(ref.SourceID) == "" {
|
||||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||||
@@ -109,14 +112,14 @@ func validate(value dnd.NPCInteractionList) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validInteractionKind(value dnd.NPCInteractionKind) bool {
|
func validOccurrenceKind(value dnd.NPCOccurrenceKind) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case dnd.NPCInteractionKindMentioned,
|
case dnd.NPCOccurrenceKindMentioned,
|
||||||
dnd.NPCInteractionKindNoncombatPresence,
|
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||||
dnd.NPCInteractionKindDialogue,
|
dnd.NPCOccurrenceKindDialogue,
|
||||||
dnd.NPCInteractionKindCombatAlly,
|
dnd.NPCOccurrenceKindCombatAlly,
|
||||||
dnd.NPCInteractionKindCombatOpponent,
|
dnd.NPCOccurrenceKindCombatOpponent,
|
||||||
dnd.NPCInteractionKindOther:
|
dnd.NPCOccurrenceKindOther:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -15,21 +15,21 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validList() dnd.NPCInteractionList {
|
func validList() dnd.NPCOccurrenceList {
|
||||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||||
{
|
{
|
||||||
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
|
NPCID: "npc:test-mira", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Hooded Guard", Kind: dnd.NPCInteractionKindCombatOpponent,
|
NPCID: "npc:test-guard", Name: "Hooded Guard", Kind: dnd.NPCOccurrenceKindCombatOpponent,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}},
|
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||||
raw, err := os.ReadFile("testdata/dnd_npc_interactions.v1.json")
|
raw, err := os.ReadFile("testdata/dnd_npc_occurrences.v1.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
|||||||
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||||
codec := New()
|
codec := New()
|
||||||
schema := codec.Schema()
|
schema := codec.Schema()
|
||||||
if codec.Kind() != dnd.NPCInteractionListKind || codec.MediaType() != MediaType {
|
if codec.Kind() != dnd.NPCOccurrenceListKind || codec.MediaType() != MediaType {
|
||||||
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
|
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
|
||||||
}
|
}
|
||||||
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
||||||
@@ -67,11 +67,11 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
|||||||
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.NPCInteractionListKind)
|
spec, ok := registry.Spec(dnd.NPCOccurrenceListKind)
|
||||||
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.NPCInteractionListKind, dnd.NPCRegistry{}); err == nil {
|
if _, err := registry.Encode(dnd.NPCOccurrenceListKind, 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
|
||||||
@@ -83,17 +83,17 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
|||||||
|
|
||||||
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
|
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
|
||||||
codec := New()
|
codec := New()
|
||||||
empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}
|
empty := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}}
|
||||||
content, err := codec.Encode(empty)
|
content, err := codec.Encode(empty)
|
||||||
if err != nil || string(content) != `{"interactions":[]}` {
|
if err != nil || string(content) != `{"occurrences":[]}` {
|
||||||
t.Fatalf("Encode() = %s, %v", content, err)
|
t.Fatalf("Encode() = %s, %v", content, err)
|
||||||
}
|
}
|
||||||
for _, candidate := range []dnd.NPCInteractionList{
|
for _, candidate := range []dnd.NPCOccurrenceList{
|
||||||
{},
|
{},
|
||||||
empty,
|
empty,
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}},
|
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: nil}}},
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
|
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
|
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
|
||||||
} {
|
} {
|
||||||
content, err := codec.EncodeCandidate(candidate)
|
content, err := codec.EncodeCandidate(candidate)
|
||||||
if err != nil || !json.Valid(content) {
|
if err != nil || !json.Valid(content) {
|
||||||
@@ -107,13 +107,13 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
||||||
validJSON := `{"interactions":[{"name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
for _, test := range []struct{ name, raw, want string }{
|
for _, test := range []struct{ name, raw, want string }{
|
||||||
{"malformed", `{`, "decode dnd npc interaction list"},
|
{"malformed", `{`, "decode dnd npc occurrence list"},
|
||||||
{"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"},
|
{"unknown top-level", `{"occurrences":[],"unexpected":true}`, "unknown field"},
|
||||||
{"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"},
|
{"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"},
|
||||||
{"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
{"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
||||||
{"trailing", `{"interactions":[]} {}`, "multiple JSON values"},
|
{"trailing", `{"occurrences":[]} {}`, "multiple JSON values"},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
@@ -126,16 +126,17 @@ func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
|||||||
func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
|
func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
value dnd.NPCInteractionList
|
value dnd.NPCOccurrenceList
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"},
|
{"nil occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
|
||||||
{"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"},
|
{"blank NPC ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].NPCID = " " }), "npc_id must not be empty"},
|
||||||
{"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"},
|
{"blank name", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Name = " " }), "name must not be empty"},
|
||||||
{"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"},
|
{"unsupported kind", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Kind = "unsupported" }), "kind must be supported"},
|
||||||
{"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
{"nil source refs", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs = nil }), "source_refs must contain"},
|
||||||
{"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
|
{"empty source ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
||||||
{"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
|
{"non-positive start", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
|
||||||
|
{"non-positive end", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -147,16 +148,16 @@ func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
|
func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
|
||||||
for _, kind := range []dnd.NPCInteractionKind{
|
for _, kind := range []dnd.NPCOccurrenceKind{
|
||||||
dnd.NPCInteractionKindMentioned,
|
dnd.NPCOccurrenceKindMentioned,
|
||||||
dnd.NPCInteractionKindNoncombatPresence,
|
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||||
dnd.NPCInteractionKindDialogue,
|
dnd.NPCOccurrenceKindDialogue,
|
||||||
dnd.NPCInteractionKindCombatAlly,
|
dnd.NPCOccurrenceKindCombatAlly,
|
||||||
dnd.NPCInteractionKindCombatOpponent,
|
dnd.NPCOccurrenceKindCombatOpponent,
|
||||||
dnd.NPCInteractionKindOther,
|
dnd.NPCOccurrenceKindOther,
|
||||||
} {
|
} {
|
||||||
value := validList()
|
value := validList()
|
||||||
value.Interactions[0].Kind = kind
|
value.Occurrences[0].Kind = kind
|
||||||
if _, err := New().Encode(value); err != nil {
|
if _, err := New().Encode(value); err != nil {
|
||||||
t.Fatalf("Encode(%q) error = %v", kind, err)
|
t.Fatalf("Encode(%q) error = %v", kind, err)
|
||||||
}
|
}
|
||||||
@@ -172,12 +173,12 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
metadata := codec.Metadata(validList())
|
metadata := codec.Metadata(validList())
|
||||||
metadata["other"] = true
|
metadata["other"] = true
|
||||||
if next := codec.Metadata(validList()); len(next) != 1 || next["interaction_count"] != 2 {
|
if next := codec.Metadata(validList()); len(next) != 1 || next["occurrence_count"] != 2 {
|
||||||
t.Fatalf("Metadata() = %#v", next)
|
t.Fatalf("Metadata() = %#v", next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList {
|
func mutate(value dnd.NPCOccurrenceList, change func(*dnd.NPCOccurrenceList)) dnd.NPCOccurrenceList {
|
||||||
change(&value)
|
change(&value)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"interactions": [
|
"occurrences": [
|
||||||
{
|
{
|
||||||
|
"npc_id": "npc:test-mira",
|
||||||
"name": "Mira Thorn",
|
"name": "Mira Thorn",
|
||||||
"kind": "dialogue",
|
"kind": "dialogue",
|
||||||
"source_refs": [
|
"source_refs": [
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"npc_id": "npc:test-guard",
|
||||||
"name": "Hooded Guard",
|
"name": "Hooded Guard",
|
||||||
"kind": "combat_opponent",
|
"kind": "combat_opponent",
|
||||||
"source_refs": [
|
"source_refs": [
|
||||||
@@ -71,7 +71,7 @@ func referenceSlots() []contracts.ReferenceSlot {
|
|||||||
Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.",
|
Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.",
|
||||||
Required: true,
|
Required: true,
|
||||||
AcceptedMediaTypes: []string{interactioncodec.MediaType},
|
AcceptedMediaTypes: []string{interactioncodec.MediaType},
|
||||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind},
|
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCOccurrenceListKind},
|
||||||
MaxBytes: ReferenceMaxBytes,
|
MaxBytes: ReferenceMaxBytes,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -223,7 +223,7 @@ func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.L
|
|||||||
}
|
}
|
||||||
content, err := json.Marshal(struct {
|
content, err := json.Marshal(struct {
|
||||||
Interactions []npcInteractionProjection `json:"npc_interactions"`
|
Interactions []npcInteractionProjection `json:"npc_interactions"`
|
||||||
}{Interactions: projectNPCInteractions(value.Interactions)})
|
}{Interactions: projectNPCInteractions(value.Occurrences)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
|
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
|
||||||
}
|
}
|
||||||
@@ -272,15 +272,15 @@ func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type npcInteractionProjection struct {
|
type npcInteractionProjection struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind dnd.NPCInteractionKind `json:"kind"`
|
Kind dnd.NPCOccurrenceKind `json:"kind"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection {
|
func projectNPCInteractions(occurrences []dnd.NPCOccurrence) []npcInteractionProjection {
|
||||||
projection := make([]npcInteractionProjection, 0, len(interactions))
|
projection := make([]npcInteractionProjection, 0, len(occurrences))
|
||||||
for _, interaction := range interactions {
|
for _, occurrence := range occurrences {
|
||||||
if interaction.Kind == dnd.NPCInteractionKindCombatOpponent {
|
if occurrence.Kind == dnd.NPCOccurrenceKindCombatOpponent {
|
||||||
projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind})
|
projection = append(projection, npcInteractionProjection{Name: occurrence.Name, Kind: occurrence.Kind})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return projection
|
return projection
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
|
|||||||
{NPCRegistryReferenceSlot, dnd.NPCRegistryKind},
|
{NPCRegistryReferenceSlot, dnd.NPCRegistryKind},
|
||||||
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
|
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
|
||||||
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
|
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
|
||||||
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind},
|
{NPCInteractionReferenceSlot, dnd.NPCOccurrenceListKind},
|
||||||
} {
|
} {
|
||||||
slot, ok := byName[want.name]
|
slot, ok := byName[want.name]
|
||||||
if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind {
|
if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind {
|
||||||
@@ -220,9 +220,9 @@ func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) co
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
interactionContent, err := interactioncodec.New().Encode(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||||
{Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
|
{NPCID: identity.DeriveID(enemy), Name: enemy, Kind: dnd.NPCOccurrenceKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
|
||||||
{Name: "Aria", Kind: dnd.NPCInteractionKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
|
{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
|
||||||
}})
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
type orderedInteractionResponse struct {
|
type orderedOccurrenceResponse struct {
|
||||||
value interactionResponse
|
value occurrenceResponse
|
||||||
earliest int
|
earliest int
|
||||||
hasEvidence bool
|
hasEvidence bool
|
||||||
}
|
}
|
||||||
@@ -18,11 +18,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
|||||||
if response == nil {
|
if response == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ordered := make([]orderedInteractionResponse, len(response.Interactions))
|
ordered := make([]orderedOccurrenceResponse, len(response.Occurrences))
|
||||||
for index := range response.Interactions {
|
for index := range response.Occurrences {
|
||||||
earliest, hasEvidence := canonicalizeInteraction(&response.Interactions[index], order, sourceID)
|
earliest, hasEvidence := canonicalizeOccurrence(&response.Occurrences[index], order, sourceID)
|
||||||
ordered[index] = orderedInteractionResponse{
|
ordered[index] = orderedOccurrenceResponse{
|
||||||
value: response.Interactions[index],
|
value: response.Occurrences[index],
|
||||||
earliest: earliest,
|
earliest: earliest,
|
||||||
hasEvidence: hasEvidence,
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
@@ -37,32 +37,33 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
|||||||
return ordered[i].earliest < ordered[j].earliest
|
return ordered[i].earliest < ordered[j].earliest
|
||||||
})
|
})
|
||||||
for index := range ordered {
|
for index := range ordered {
|
||||||
response.Interactions[index] = ordered[index].value
|
response.Occurrences[index] = ordered[index].value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeInteraction(interaction *interactionResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||||
if interaction == nil {
|
if occurrence == nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID))
|
refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID))
|
||||||
interaction.SourceRefs = interactionResponseRefs(refs)
|
occurrence.SourceRefs = occurrenceResponseRefs(refs)
|
||||||
return order.EarliestValid(refs)
|
return order.EarliestValid(refs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {
|
func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList {
|
||||||
interactions := make([]dnd.NPCInteraction, len(response.Interactions))
|
occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
|
||||||
for index, interaction := range response.Interactions {
|
for index, occurrence := range response.Occurrences {
|
||||||
interactions[index] = dnd.NPCInteraction{
|
occurrences[index] = dnd.NPCOccurrence{
|
||||||
Name: interaction.Name,
|
NPCID: occurrence.NPCID,
|
||||||
Kind: dnd.NPCInteractionKind(interaction.Kind),
|
Name: occurrence.Name,
|
||||||
SourceRefs: canonicalSourceRefs(interaction.SourceRefs, sourceID),
|
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
|
||||||
|
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if response.Interactions == nil {
|
if response.Occurrences == nil {
|
||||||
interactions = nil
|
occurrences = nil
|
||||||
}
|
}
|
||||||
return dnd.NPCInteractionList{Interactions: interactions}
|
return dnd.NPCOccurrenceList{Occurrences: occurrences}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) []source.SourceRef {
|
func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) []source.SourceRef {
|
||||||
@@ -76,7 +77,7 @@ func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) [
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func interactionResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse {
|
func occurrenceResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse {
|
||||||
if refs == nil {
|
if refs == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func referenceSlots() []contracts.ReferenceSlot {
|
|||||||
return slots
|
return slots
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ contracts.Extractor[dnd.NPCInteractionList] = (*Extractor)(nil)
|
var _ contracts.Extractor[dnd.NPCOccurrenceList] = (*Extractor)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
||||||
|
|
||||||
@@ -132,33 +132,33 @@ 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: "npc_registry", Value: seeded.ProjectionDigest()},
|
{Name: "npc_registry", Value: seeded.IdentityPromptInput().Digest},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCInteractionList], error) {
|
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCOccurrenceList], error) {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("extractor must not be nil")
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("extractor must not be nil")
|
||||||
}
|
}
|
||||||
if e.llm == nil {
|
if e.llm == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil")
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, 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.NPCInteractionList]{}, extractorErrorf("%w", err)
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
order := shared.NewSourceRefOrder(req.Source)
|
order := shared.NewSourceRefOrder(req.Source)
|
||||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
||||||
}
|
}
|
||||||
if !npcRegistry.Bound() {
|
if !npcRegistry.Bound() {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("NPC registry reference is required")
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("NPC registry reference is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||||
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
|
inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
|
||||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: Key,
|
StageName: Key,
|
||||||
PromptID: PromptID,
|
PromptID: PromptID,
|
||||||
@@ -167,10 +167,27 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response, order, req.Source.ID)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{Value: canonicalInteractionList(response, req.Source.ID)}, nil
|
value := canonicalOccurrenceList(response, req.Source.ID)
|
||||||
|
if err := validateRegistryPairs(value, npcRegistry); err != nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("validate NPC registry pairs: %w", err)
|
||||||
|
}
|
||||||
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRegistryPairs(value dnd.NPCOccurrenceList, registry *npcregistry.Registry) error {
|
||||||
|
for index, occurrence := range value.Occurrences {
|
||||||
|
canonical, ok := registry.LookupID(occurrence.NPCID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("occurrences[%d].npc_id is not in the NPC registry", index)
|
||||||
|
}
|
||||||
|
if occurrence.Name != canonical.Name {
|
||||||
|
return fmt.Errorf("occurrences[%d].name does not match npc_id", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
@@ -180,13 +197,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.NPCInteractionListKind,
|
ArtifactKind: dnd.NPCOccurrenceListKind,
|
||||||
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.NPCInteractionList], error) {
|
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
{Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
||||||
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
|
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
|
||||||
{Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
|
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
|
||||||
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
|
{NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
|
||||||
{Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
|
{NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
|
||||||
{Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
|
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
|
||||||
{Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
|
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
|
||||||
}}}
|
}}}
|
||||||
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -37,21 +37,21 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid"}) {
|
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid"}) {
|
||||||
t.Fatalf("interaction order = %#v", got)
|
t.Fatalf("interaction order = %#v", got)
|
||||||
}
|
}
|
||||||
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCInteractionKind{
|
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCOccurrenceKind{
|
||||||
dnd.NPCInteractionKindMentioned,
|
dnd.NPCOccurrenceKindMentioned,
|
||||||
dnd.NPCInteractionKindDialogue,
|
dnd.NPCOccurrenceKindDialogue,
|
||||||
dnd.NPCInteractionKindNoncombatPresence,
|
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||||
dnd.NPCInteractionKindCombatAlly,
|
dnd.NPCOccurrenceKindCombatAlly,
|
||||||
dnd.NPCInteractionKindCombatOpponent,
|
dnd.NPCOccurrenceKindCombatOpponent,
|
||||||
dnd.NPCInteractionKindOther,
|
dnd.NPCOccurrenceKindOther,
|
||||||
"unsupported",
|
"unsupported",
|
||||||
}) {
|
}) {
|
||||||
t.Fatalf("interaction kinds = %#v", got)
|
t.Fatalf("interaction kinds = %#v", got)
|
||||||
}
|
}
|
||||||
if refs := result.Value.Interactions[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
||||||
t.Fatalf("canonical source refs = %#v", refs)
|
t.Fatalf("canonical source refs = %#v", refs)
|
||||||
}
|
}
|
||||||
if invalid := result.Value.Interactions[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
||||||
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
||||||
}
|
}
|
||||||
if len(client.requests) != 1 {
|
if len(client.requests) != 1 {
|
||||||
@@ -60,15 +60,15 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
|
func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
|
||||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
{Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
|
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
|
||||||
{Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
|
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
|
||||||
{StartUnitID: 10, EndUnitID: 10},
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
{StartUnitID: 30, EndUnitID: 30},
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
{StartUnitID: 30, EndUnitID: 30},
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
{StartUnitID: 999, EndUnitID: 0},
|
{StartUnitID: 999, EndUnitID: 0},
|
||||||
}},
|
}},
|
||||||
{Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
{NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
||||||
}}}
|
}}}
|
||||||
references := requiredRegistryReferences(t, "Later", "First", "Second")
|
references := requiredRegistryReferences(t, "Later", "First", "Second")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -84,12 +84,12 @@ func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
|
|||||||
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
|
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
|
||||||
t.Fatalf("interaction order = %#v, want document chronology with stable equal-evidence ties", got)
|
t.Fatalf("interaction order = %#v, want document chronology with stable equal-evidence ties", got)
|
||||||
}
|
}
|
||||||
refs := result.Value.Interactions[0].SourceRefs
|
refs := result.Value.Occurrences[0].SourceRefs
|
||||||
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
|
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
|
||||||
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
||||||
}
|
}
|
||||||
refs[0].StartUnitID = 777
|
refs[0].StartUnitID = 777
|
||||||
for _, interaction := range client.response.Interactions {
|
for _, interaction := range client.response.Occurrences {
|
||||||
for _, ref := range interaction.SourceRefs {
|
for _, ref := range interaction.SourceRefs {
|
||||||
if ref.StartUnitID == 777 {
|
if ref.StartUnitID == 777 {
|
||||||
t.Fatal("result source references alias the model response")
|
t.Fatal("result source references alias the model response")
|
||||||
@@ -107,9 +107,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
|
func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
|
||||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{{
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||||
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
|
NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
|
||||||
}}}}
|
}}}}
|
||||||
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -119,10 +119,10 @@ func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T)
|
|||||||
}
|
}
|
||||||
request := client.requests[0]
|
request := client.requests[0]
|
||||||
registry := request.Inputs[NPCRegistryReferenceSlot]
|
registry := request.Inputs[NPCRegistryReferenceSlot]
|
||||||
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` {
|
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"},{"id":"`+identity.DeriveID("Hooded Guard")+`","name":"Hooded Guard"}]}` {
|
||||||
t.Fatalf("registry prompt input = %#v, want exact names-only projection", registry)
|
t.Fatalf("registry prompt input = %#v, want exact ID and name projection", registry)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} {
|
for _, forbidden := range []string{"other-session", "start_unit_id"} {
|
||||||
if strings.Contains(string(registry.Content), forbidden) {
|
if strings.Contains(string(registry.Content), forbidden) {
|
||||||
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
|
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
|
||||||
}
|
}
|
||||||
@@ -162,8 +162,29 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
|
||||||
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
occurrence occurrenceResponse
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, "npc_id is not in the NPC registry"},
|
||||||
|
{"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, "name does not match npc_id"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = references
|
||||||
|
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Extract() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
|
func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
|
||||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}}
|
||||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = references
|
req.References = references
|
||||||
@@ -171,7 +192,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
|
|||||||
if _, err := extractor.Extract(context.Background(), req); err != nil {
|
if _, err := extractor.Extract(context.Background(), req); err != nil {
|
||||||
t.Fatalf("Extract() error = %v", err)
|
t.Fatalf("Extract() error = %v", err)
|
||||||
}
|
}
|
||||||
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
|
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" {
|
||||||
t.Fatalf("generated registry prompt input = %#v", input)
|
t.Fatalf("generated registry prompt input = %#v", input)
|
||||||
}
|
}
|
||||||
metadata := extractor.ManifestMetadata()
|
metadata := extractor.ManifestMetadata()
|
||||||
@@ -191,11 +212,11 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
|
|||||||
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
|
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}}
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = references
|
req.References = references
|
||||||
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||||
if err != nil || result.Value.Interactions == nil || len(result.Value.Interactions) != 0 {
|
if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 {
|
||||||
t.Fatalf("Extract() = %#v, %v; want present empty interactions", result, err)
|
t.Fatalf("Extract() = %#v, %v; want present empty interactions", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +251,7 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
|
func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
|
||||||
got := ModuleSpec()
|
got := ModuleSpec()
|
||||||
if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCInteractionListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) {
|
if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCOccurrenceListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) {
|
||||||
t.Fatalf("ModuleSpec() = %#v", got)
|
t.Fatalf("ModuleSpec() = %#v", got)
|
||||||
}
|
}
|
||||||
var registrySlot contracts.ReferenceSlot
|
var registrySlot contracts.ReferenceSlot
|
||||||
@@ -342,17 +363,17 @@ func interactionRefs(start, end int) []interactionSourceRefResponse {
|
|||||||
return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
|
return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func interactionNames(value dnd.NPCInteractionList) []string {
|
func interactionNames(value dnd.NPCOccurrenceList) []string {
|
||||||
names := make([]string, len(value.Interactions))
|
names := make([]string, len(value.Occurrences))
|
||||||
for index, interaction := range value.Interactions {
|
for index, interaction := range value.Occurrences {
|
||||||
names[index] = interaction.Name
|
names[index] = interaction.Name
|
||||||
}
|
}
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
|
func interactionKinds(value dnd.NPCOccurrenceList) []dnd.NPCOccurrenceKind {
|
||||||
kinds := make([]dnd.NPCInteractionKind, len(value.Interactions))
|
kinds := make([]dnd.NPCOccurrenceKind, len(value.Occurrences))
|
||||||
for index, interaction := range value.Interactions {
|
for index, interaction := range value.Occurrences {
|
||||||
kinds[index] = interaction.Kind
|
kinds[index] = interaction.Kind
|
||||||
}
|
}
|
||||||
return kinds
|
return kinds
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package npcinteractions
|
package npcinteractions
|
||||||
|
|
||||||
type extractionResponse struct {
|
type extractionResponse struct {
|
||||||
Interactions []interactionResponse `json:"interactions"`
|
Occurrences []occurrenceResponse `json:"occurrences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type interactionResponse struct {
|
type occurrenceResponse struct {
|
||||||
|
NPCID string `json:"npc_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
SourceRefs []interactionSourceRefResponse `json:"source_refs"`
|
SourceRefs []interactionSourceRefResponse `json:"source_refs"`
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
|
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
|
||||||
content := []byte(`{"interactions":[{"name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
content := []byte(`{"occurrences":[{"npc_id":"","name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
if err := json.Unmarshal(content, &response); err != nil {
|
if err := json.Unmarshal(content, &response); err != nil {
|
||||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
interaction := response.Interactions[0]
|
occurrence := response.Occurrences[0]
|
||||||
if interaction.Name != "" || interaction.Kind != "unsupported" || interaction.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||||
t.Fatalf("decoded response = %#v", interaction)
|
t.Fatalf("decoded response = %#v", occurrence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := fs.ReadFile(schemaFS, "dnd_npc_interactions_llm.v1.json"); err != nil {
|
if _, err := fs.ReadFile(schemaFS, "dnd_npc_occurrences_llm.v1.json"); err != nil {
|
||||||
t.Fatalf("response schema asset: %v", err)
|
t.Fatalf("response schema asset: %v", err)
|
||||||
}
|
}
|
||||||
options, err := registry.PromptKitOptions()
|
options, err := registry.PromptKitOptions()
|
||||||
@@ -42,13 +42,13 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
|
|||||||
"players": promptkit.Inline("interaction-player"),
|
"players": promptkit.Inline("interaction-player"),
|
||||||
"party": promptkit.Inline("Mira: ranger"),
|
"party": promptkit.Inline("Mira: ranger"),
|
||||||
"glossary": promptkit.Inline("Greencloak: title"),
|
"glossary": promptkit.Inline("Greencloak: title"),
|
||||||
"npc_registry": promptkit.Inline(`{"npcs":[{"name":"interaction-npc"}]}`),
|
"npc_registry": promptkit.Inline(`{"npcs":[{"id":"npc:sha256:test","name":"interaction-npc"}]}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" {
|
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_occurrences_llm.v1.json" {
|
||||||
t.Fatalf("prepared prompt = %#v", prepared)
|
t.Fatalf("prepared prompt = %#v", prepared)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ func TestPromptMetadataDoesNotExposeAssetContent(t *testing.T) {
|
|||||||
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
|
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
|
||||||
}
|
}
|
||||||
metadata := newExtractor(t, &fakeInteractionsLLMClient{}).ManifestMetadata()
|
metadata := newExtractor(t, &fakeInteractionsLLMClient{}).ManifestMetadata()
|
||||||
for _, forbidden := range []string{"common-dnd-system", "dnd_npc_interactions_llm.v1.json"} {
|
for _, forbidden := range []string{"common-dnd-system", "dnd_npc_occurrences_llm.v1.json"} {
|
||||||
if strings.Contains(strings.Join(mapValues(metadata), " "), forbidden) {
|
if strings.Contains(strings.Join(mapValues(metadata), " "), forbidden) {
|
||||||
t.Fatalf("metadata leaked prompt or schema content %q: %#v", forbidden, metadata)
|
t.Fatalf("metadata leaked prompt or schema content %q: %#v", forbidden, metadata)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
PromptID = "dnd.npc_interactions"
|
PromptID = "dnd.npc_interactions"
|
||||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_interactions_llm")
|
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_occurrences_llm")
|
||||||
ResponseSchemaID = "notarius.dnd.npc_interactions.llm"
|
ResponseSchemaID = "notarius.dnd.npc_occurrences.llm"
|
||||||
ResponseSchemaName = "notarius_dnd_npc_interactions_llm_v1"
|
ResponseSchemaName = "notarius_dnd_npc_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_npc_interactions_llm.v1.json",
|
AssetPath: "schemas/dnd_npc_occurrences_llm.v1.json",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
semanticCandidate := validInteractionResponse()
|
semanticCandidate := validInteractionResponse()
|
||||||
interaction := semanticCandidate["interactions"].([]any)[0].(map[string]any)
|
interaction := semanticCandidate["occurrences"].([]any)[0].(map[string]any)
|
||||||
interaction["name"] = ""
|
interaction["name"] = ""
|
||||||
interaction["kind"] = "unsupported"
|
interaction["kind"] = "unsupported"
|
||||||
ref := interaction["source_refs"].([]any)[0].(map[string]any)
|
ref := interaction["source_refs"].([]any)[0].(map[string]any)
|
||||||
@@ -42,6 +42,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, mutate := range []func(map[string]any){
|
for _, mutate := range []func(map[string]any){
|
||||||
|
func(record map[string]any) { delete(record, "npc_id") },
|
||||||
func(record map[string]any) { delete(record, "name") },
|
func(record map[string]any) { delete(record, "name") },
|
||||||
func(record map[string]any) { record["kind"] = 1 },
|
func(record map[string]any) { record["kind"] = 1 },
|
||||||
func(record map[string]any) { record["unexpected"] = true },
|
func(record map[string]any) { record["unexpected"] = true },
|
||||||
@@ -50,7 +51,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
candidate := validInteractionResponse()
|
candidate := validInteractionResponse()
|
||||||
mutate(candidate["interactions"].([]any)[0].(map[string]any))
|
mutate(candidate["occurrences"].([]any)[0].(map[string]any))
|
||||||
content, err := json.Marshal(candidate)
|
content, err := json.Marshal(candidate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -79,8 +80,8 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validInteractionResponse() map[string]any {
|
func validInteractionResponse() map[string]any {
|
||||||
return map[string]any{"interactions": []any{map[string]any{
|
return map[string]any{"occurrences": []any{map[string]any{
|
||||||
"name": "Mira Thorn", "kind": "dialogue",
|
"npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue",
|
||||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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"
|
||||||
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
|
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
|
||||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
@@ -22,7 +21,6 @@ const (
|
|||||||
normalizationPolicy = "dnd.npc_interactions.normalize.v2"
|
normalizationPolicy = "dnd.npc_interactions.normalize.v2"
|
||||||
NormalizationPolicy = normalizationPolicy
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|
||||||
ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized"
|
|
||||||
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||||
ReasonCodeInteractionsReordered = "npc_interactions_reordered"
|
ReasonCodeInteractionsReordered = "npc_interactions_reordered"
|
||||||
ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed"
|
ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed"
|
||||||
@@ -44,7 +42,7 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
|||||||
Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.",
|
Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil)
|
var _ contracts.Normalizer[dnd.NPCOccurrenceList] = (*Normalizer)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||||
|
|
||||||
@@ -79,7 +77,6 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
|||||||
}
|
}
|
||||||
metadata := map[string]any{
|
metadata := map[string]any{
|
||||||
"normalization_policy": normalizationPolicy,
|
"normalization_policy": normalizationPolicy,
|
||||||
"identity_policy": identity.Policy,
|
|
||||||
}
|
}
|
||||||
seeded := n.npcResolver.Seeded()
|
seeded := n.npcResolver.Seeded()
|
||||||
if seeded.Bound() {
|
if seeded.Bound() {
|
||||||
@@ -95,82 +92,74 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
{Name: "identity_policy", Value: identity.Policy},
|
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
|
||||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCInteractionList]) (contracts.TypedNormalizeResult[dnd.NPCInteractionList], error) {
|
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]) (contracts.TypedNormalizeResult[dnd.NPCOccurrenceList], error) {
|
||||||
if n == nil || n.npcResolver == nil {
|
if n == nil || n.npcResolver == nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("normalizer must not be nil")
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("normalizer must not be nil")
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context must not be nil")
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context must not be nil")
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context error before normalize: %w", err)
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
registry, err := n.npcResolver.Resolve(req.References)
|
registry, err := n.npcResolver.Resolve(req.References)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("resolve NPC registry: %w", err)
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("resolve NPC registry: %w", err)
|
||||||
}
|
}
|
||||||
if !registry.Bound() {
|
if !registry.Bound() {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("NPC registry reference is required")
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
order := shared.NewSourceRefOrderFromIndex(index)
|
order := shared.NewSourceRefOrderFromIndex(index)
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry)
|
value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry)
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
|
if err != nil {
|
||||||
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("validate NPC registry pairs: %w", err)
|
||||||
|
}
|
||||||
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{Value: value, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type normalizedRecord struct {
|
type normalizedRecord struct {
|
||||||
interaction dnd.NPCInteraction
|
occurrence dnd.NPCOccurrence
|
||||||
inputIndex int
|
inputIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
type nameCanonicalization struct {
|
func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []contracts.Warning, error) {
|
||||||
from string
|
if input.Occurrences == nil {
|
||||||
to string
|
return dnd.NPCOccurrenceList{}, nil, nil
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
|
|
||||||
if input.Interactions == nil {
|
|
||||||
return dnd.NPCInteractionList{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
records := make([]normalizedRecord, len(input.Interactions))
|
records := make([]normalizedRecord, len(input.Occurrences))
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, inputInteraction := range input.Interactions {
|
for index, inputOccurrence := range input.Occurrences {
|
||||||
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry)
|
occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry)
|
||||||
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
|
if err != nil {
|
||||||
if nameChange != nil {
|
return dnd.NPCOccurrenceList{}, nil, fmt.Errorf("occurrences[%d]: %w", index, err)
|
||||||
warnings = append(warnings, contracts.Warning{
|
|
||||||
Scope: interactionScope(index),
|
|
||||||
ReasonCode: ReasonCodeNameCanonicalized,
|
|
||||||
Message: fmt.Sprintf("input index %d: NPC name canonicalized from %s to %s",
|
|
||||||
index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
|
||||||
if refsChanged {
|
if refsChanged {
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
Scope: interactionScope(index),
|
Scope: occurrenceScope(index),
|
||||||
ReasonCode: ReasonCodeSourceRefsNormalized,
|
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
||||||
index, len(inputInteraction.SourceRefs), len(interaction.SourceRefs)),
|
index, len(inputOccurrence.SourceRefs), len(occurrence.SourceRefs)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(records, func(left, right int) bool {
|
sort.SliceStable(records, func(left, right int) bool {
|
||||||
return interactionmodel.Less(order, records[left].interaction, records[right].interaction)
|
return interactionmodel.Less(order, records[left].occurrence, records[right].occurrence)
|
||||||
})
|
})
|
||||||
for position, record := range records {
|
for position, record := range records {
|
||||||
if position == record.inputIndex {
|
if position == record.inputIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
Scope: interactionScope(record.inputIndex),
|
Scope: occurrenceScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeInteractionsReordered,
|
ReasonCode: ReasonCodeInteractionsReordered,
|
||||||
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position),
|
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position),
|
||||||
})
|
})
|
||||||
@@ -178,24 +167,24 @@ func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIn
|
|||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
|
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
warnings = append(warnings, duplicateWarnings...)
|
||||||
return dnd.NPCInteractionList{Interactions: output},
|
return dnd.NPCOccurrenceList{Occurrences: output},
|
||||||
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
|
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
|
func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) {
|
||||||
output := cloneInteraction(input)
|
canonical, ok := registry.LookupID(input.NPCID)
|
||||||
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
|
if !ok {
|
||||||
output.Name = canonical.Name
|
return dnd.NPCOccurrence{}, false, fmt.Errorf("npc_id is not in the NPC registry")
|
||||||
}
|
}
|
||||||
var nameChange *nameCanonicalization
|
if input.Name != canonical.Name {
|
||||||
if input.Name != output.Name {
|
return dnd.NPCOccurrence{}, false, fmt.Errorf("name does not match npc_id")
|
||||||
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
|
|
||||||
}
|
}
|
||||||
|
output := cloneOccurrence(input)
|
||||||
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||||
return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
return output, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction {
|
func cloneOccurrence(input dnd.NPCOccurrence) dnd.NPCOccurrence {
|
||||||
output := input
|
output := input
|
||||||
if input.SourceRefs != nil {
|
if input.SourceRefs != nil {
|
||||||
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
|
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
|
||||||
@@ -208,19 +197,19 @@ type duplicateGroup struct {
|
|||||||
removed []int
|
removed []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCInteraction, []contracts.Warning) {
|
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCOccurrence, []contracts.Warning) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return make([]dnd.NPCInteraction, 0), nil
|
return make([]dnd.NPCOccurrence, 0), nil
|
||||||
}
|
}
|
||||||
keep := make([]bool, len(records))
|
keep := make([]bool, len(records))
|
||||||
groups := make([]duplicateGroup, 0)
|
groups := make([]duplicateGroup, 0)
|
||||||
groupByKey := make(map[string]int)
|
groupByKey := make(map[string]int)
|
||||||
for index, record := range records {
|
for index, record := range records {
|
||||||
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
|
if !interactionmodel.ValidSourceRefs(documentIndex, record.occurrence.SourceRefs) {
|
||||||
keep[index] = true
|
keep[index] = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := interactionmodel.ExactIdentity(record.interaction)
|
key := interactionmodel.ExactIdentity(record.occurrence)
|
||||||
groupIndex, exists := groupByKey[key]
|
groupIndex, exists := groupByKey[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
groupByKey[key] = len(groups)
|
groupByKey[key] = len(groups)
|
||||||
@@ -230,10 +219,10 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen
|
|||||||
}
|
}
|
||||||
groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex)
|
groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex)
|
||||||
}
|
}
|
||||||
output := make([]dnd.NPCInteraction, 0, len(records))
|
output := make([]dnd.NPCOccurrence, 0, len(records))
|
||||||
for index, record := range records {
|
for index, record := range records {
|
||||||
if keep[index] {
|
if keep[index] {
|
||||||
output = append(output, cloneInteraction(record.interaction))
|
output = append(output, cloneOccurrence(record.occurrence))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
@@ -251,14 +240,14 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
|
|||||||
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
||||||
}
|
}
|
||||||
return contracts.Warning{
|
return contracts.Warning{
|
||||||
Scope: interactionScope(retainedIndex),
|
Scope: occurrenceScope(retainedIndex),
|
||||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||||
Message: diagnostics.Aggregate(
|
Message: diagnostics.Aggregate(
|
||||||
fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues),
|
fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func interactionScope(index int) string { return fmt.Sprintf("interactions[%d]", index) }
|
func occurrenceScope(index int) string { return fmt.Sprintf("occurrences[%d]", index) }
|
||||||
|
|
||||||
func referenceSlots() []contracts.ReferenceSlot {
|
func referenceSlots() []contracts.ReferenceSlot {
|
||||||
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||||
@@ -281,13 +270,13 @@ 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.NPCInteractionListKind,
|
ArtifactKind: dnd.NPCOccurrenceListKind,
|
||||||
ReferenceSlots: referenceSlots(),
|
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.NPCInteractionList], error) {
|
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -14,37 +14,37 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeCanonicalizesAndClones(t *testing.T) {
|
func TestNormalizeValidatesPairsAndClones(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}}}
|
||||||
normalizer, err := New(Options{}, npcReferences(t))
|
normalizer, err := New(Options{}, npcReferences(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||||
Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue,
|
NPCID: identity.DeriveID("Ária"), Name: "Ária", Kind: dnd.NPCOccurrenceKindDialogue,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
||||||
}}}
|
}}}
|
||||||
original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...)
|
original := append([]source.SourceRef(nil), input.Occurrences[0].SourceRefs...)
|
||||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Normalize() error = %v", err)
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
}
|
}
|
||||||
got := result.Value.Interactions[0]
|
got := result.Value.Occurrences[0]
|
||||||
if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
|
if got.NPCID != identity.DeriveID("Ária") || got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
|
||||||
t.Fatalf("normalized interaction = %#v", got)
|
t.Fatalf("normalized interaction = %#v", got)
|
||||||
}
|
}
|
||||||
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
if !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
||||||
t.Fatalf("warnings = %#v", result.Warnings)
|
t.Fatalf("warnings = %#v", result.Warnings)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(input.Interactions[0].SourceRefs, original) {
|
if !reflect.DeepEqual(input.Occurrences[0].SourceRefs, original) {
|
||||||
t.Fatalf("Normalize() mutated input: %#v", input)
|
t.Fatalf("Normalize() mutated input: %#v", input)
|
||||||
}
|
}
|
||||||
second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: result.Value}})
|
second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: result.Value}})
|
||||||
if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 {
|
if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 {
|
||||||
t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err)
|
t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err)
|
||||||
}
|
}
|
||||||
result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999
|
result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||||
if input.Interactions[0].SourceRefs[0].StartUnitID == 999 {
|
if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 {
|
||||||
t.Fatal("normalized source refs share input storage")
|
t.Fatal("normalized source refs share input storage")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,14 +54,14 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{}); err == nil {
|
if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{}); err == nil {
|
||||||
t.Fatal("Normalize() accepted an unbound NPC registry")
|
t.Fatal("Normalize() accepted an unbound NPC registry")
|
||||||
}
|
}
|
||||||
for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} {
|
for _, input := range []dnd.NPCOccurrenceList{{}, {Occurrences: []dnd.NPCOccurrence{}}} {
|
||||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{
|
||||||
MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t),
|
MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}, References: npcReferences(t),
|
||||||
})
|
})
|
||||||
if err != nil || (result.Value.Interactions == nil) != (input.Interactions == nil) {
|
if err != nil || (result.Value.Occurrences == nil) != (input.Occurrences == nil) {
|
||||||
t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input)
|
t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,16 +70,20 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) {
|
func TestNormalizeRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
|
||||||
doc := testDocument()
|
doc := testDocument()
|
||||||
normalizer, err := New(Options{}, npcReferences(t))
|
normalizer, err := New(Options{}, npcReferences(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}}
|
for _, occurrence := range []dnd.NPCOccurrence{
|
||||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
|
interaction("Unknown NPC", dnd.NPCOccurrenceKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
|
||||||
if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) {
|
{NPCID: identity.DeriveID("Ária"), Name: "Borin", Kind: dnd.NPCOccurrenceKindOther, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
||||||
t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err)
|
} {
|
||||||
|
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{occurrence}}
|
||||||
|
if result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}}); err == nil {
|
||||||
|
t.Fatalf("Normalize() = %#v, %v; want registry pair rejection", result, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,28 +92,28 @@ func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) {
|
|||||||
ref := func(unit int) source.SourceRef {
|
ref := func(unit int) source.SourceRef {
|
||||||
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
|
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
|
||||||
}
|
}
|
||||||
first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50))
|
first := interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(50))
|
||||||
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||||
interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)),
|
interaction("Borin", dnd.NPCOccurrenceKindMentioned, ref(90)),
|
||||||
first,
|
first,
|
||||||
first,
|
first,
|
||||||
interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)),
|
interaction("Ária", dnd.NPCOccurrenceKindCombatAlly, ref(50)),
|
||||||
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)),
|
interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(10)),
|
||||||
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)),
|
interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(999)),
|
||||||
}}
|
}}
|
||||||
normalizer, err := New(Options{}, npcReferences(t))
|
normalizer, err := New(Options{}, npcReferences(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got := result.Value.Interactions
|
got := result.Value.Occurrences
|
||||||
if len(got) != 5 {
|
if len(got) != 5 {
|
||||||
t.Fatalf("interaction count = %d, want 5: %#v", len(got), got)
|
t.Fatalf("interaction count = %d, want 5: %#v", len(got), got)
|
||||||
}
|
}
|
||||||
if got[0].Kind != dnd.NPCInteractionKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 {
|
if got[0].Kind != dnd.NPCOccurrenceKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 {
|
||||||
t.Fatalf("canonical order = %#v", got)
|
t.Fatalf("canonical order = %#v", got)
|
||||||
}
|
}
|
||||||
if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
||||||
@@ -122,13 +126,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCInteractionListKind || len(spec.ReferenceSlots) == 0 {
|
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCOccurrenceListKind || len(spec.ReferenceSlots) == 0 {
|
||||||
t.Fatalf("ModuleSpec() = %#v", spec)
|
t.Fatalf("ModuleSpec() = %#v", spec)
|
||||||
}
|
}
|
||||||
if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 {
|
if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 {
|
||||||
t.Fatalf("metadata = %#v", metadata)
|
t.Fatalf("metadata = %#v", metadata)
|
||||||
}
|
}
|
||||||
if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 3 || fingerprints[2].Name != "npc_registry" || fingerprints[2].Value == "" {
|
if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 2 || fingerprints[1].Name != "npc_registry" || fingerprints[1].Value == "" {
|
||||||
t.Fatalf("fingerprints = %#v", fingerprints)
|
t.Fatalf("fingerprints = %#v", fingerprints)
|
||||||
}
|
}
|
||||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
@@ -139,13 +143,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
|
|||||||
func TestNormalizeBoundsWarnings(t *testing.T) {
|
func TestNormalizeBoundsWarnings(t *testing.T) {
|
||||||
count := diagnostics.MaxWarnings + 5
|
count := diagnostics.MaxWarnings + 5
|
||||||
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
||||||
input := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, count)}
|
input := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, count)}
|
||||||
for index := range doc.Units {
|
for index := range doc.Units {
|
||||||
doc.Units[index].ID = index + 1
|
doc.Units[index].ID = index + 1
|
||||||
unitID := count - index
|
unitID := count - index
|
||||||
input.Interactions[index] = interaction(
|
input.Occurrences[index] = interaction(
|
||||||
"Ária",
|
"Ária",
|
||||||
dnd.NPCInteractionKindDialogue,
|
dnd.NPCOccurrenceKindDialogue,
|
||||||
source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID},
|
source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,8 +157,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{
|
||||||
Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input},
|
Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -165,8 +169,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction {
|
func interaction(name string, kind dnd.NPCOccurrenceKind, ref source.SourceRef) dnd.NPCOccurrence {
|
||||||
return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
|
return dnd.NPCOccurrence{NPCID: identity.DeriveID(name), Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDocument() *source.SourceDocument {
|
func testDocument() *source.SourceDocument {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,8 +25,8 @@ func SourceRefsEqual(left, right []source.SourceRef) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Less defines the canonical order for NPC interaction occurrences.
|
// Less defines the canonical order for NPC occurrences.
|
||||||
func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
|
func Less(order shared.SourceRefOrder, left, right dnd.NPCOccurrence) 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 {
|
||||||
@@ -36,8 +35,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
|
|||||||
if leftHasEvidence && leftPosition != rightPosition {
|
if leftHasEvidence && leftPosition != rightPosition {
|
||||||
return leftPosition < rightPosition
|
return leftPosition < rightPosition
|
||||||
}
|
}
|
||||||
leftKey := identity.ComparisonKey(left.Name)
|
leftKey := left.NPCID
|
||||||
rightKey := identity.ComparisonKey(right.Name)
|
rightKey := right.NPCID
|
||||||
if leftKey != rightKey {
|
if leftKey != rightKey {
|
||||||
return leftKey < rightKey
|
return leftKey < rightKey
|
||||||
}
|
}
|
||||||
@@ -50,7 +49,7 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
|
|||||||
return sourceRefsLess(order, left.SourceRefs, right.SourceRefs)
|
return sourceRefsLess(order, left.SourceRefs, right.SourceRefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidSourceRefs reports whether an interaction has non-empty, valid
|
// ValidSourceRefs reports whether an occurrence has non-empty, valid
|
||||||
// current-document evidence.
|
// current-document evidence.
|
||||||
func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
||||||
if len(refs) == 0 {
|
if len(refs) == 0 {
|
||||||
@@ -64,13 +63,14 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExactIdentity returns a collision-safe key over every durable interaction
|
// ExactIdentity returns a collision-safe key over every durable occurrence
|
||||||
// field. Callers decide whether the record is eligible for duplicate handling.
|
// field. Callers decide whether the record is eligible for duplicate handling.
|
||||||
func ExactIdentity(interaction dnd.NPCInteraction) string {
|
func ExactIdentity(occurrence dnd.NPCOccurrence) string {
|
||||||
var key strings.Builder
|
var key strings.Builder
|
||||||
writeKeyString(&key, interaction.Name)
|
writeKeyString(&key, occurrence.NPCID)
|
||||||
writeKeyString(&key, string(interaction.Kind))
|
writeKeyString(&key, occurrence.Name)
|
||||||
for _, ref := range interaction.SourceRefs {
|
writeKeyString(&key, string(occurrence.Kind))
|
||||||
|
for _, ref := range occurrence.SourceRefs {
|
||||||
writeKeyString(&key, ref.SourceID)
|
writeKeyString(&key, ref.SourceID)
|
||||||
writeKeyInt(&key, ref.StartUnitID)
|
writeKeyInt(&key, ref.StartUnitID)
|
||||||
writeKeyInt(&key, ref.EndUnitID)
|
writeKeyInt(&key, ref.EndUnitID)
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ type Registry struct {
|
|||||||
digest string
|
digest string
|
||||||
projectionDigest string
|
projectionDigest string
|
||||||
promptInput contracts.LLMInputMaterial
|
promptInput contracts.LLMInputMaterial
|
||||||
|
identityInput contracts.LLMInputMaterial
|
||||||
lookupByKey map[string]int
|
lookupByKey map[string]int
|
||||||
|
lookupByID map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolver selects and memoizes immutable NPC registry views.
|
// Resolver selects and memoizes immutable NPC registry views.
|
||||||
@@ -109,7 +111,9 @@ func emptyRegistry() *Registry {
|
|||||||
canonical: append([]byte(nil), content...),
|
canonical: append([]byte(nil), content...),
|
||||||
projectionDigest: projectionDigest,
|
projectionDigest: projectionDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||||
|
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||||
lookupByKey: map[string]int{},
|
lookupByKey: map[string]int{},
|
||||||
|
lookupByID: map[string]int{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +133,10 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
|||||||
|
|
||||||
list := cloneNPCRegistry(value)
|
list := cloneNPCRegistry(value)
|
||||||
lookupByKey := make(map[string]int, len(list.NPCs))
|
lookupByKey := make(map[string]int, len(list.NPCs))
|
||||||
|
lookupByID := make(map[string]int, len(list.NPCs))
|
||||||
for index, npc := range list.NPCs {
|
for index, npc := range list.NPCs {
|
||||||
lookupByKey[identity.ComparisonKey(npc.Name)] = index
|
lookupByKey[identity.ComparisonKey(npc.Name)] = index
|
||||||
|
lookupByID[npc.ID] = index
|
||||||
}
|
}
|
||||||
digest := semanticDigest(content)
|
digest := semanticDigest(content)
|
||||||
projection, err := nameProjection(list)
|
projection, err := nameProjection(list)
|
||||||
@@ -138,6 +144,11 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
|||||||
return nil, fmt.Errorf("encode NPC name projection: %w", err)
|
return nil, fmt.Errorf("encode NPC name projection: %w", err)
|
||||||
}
|
}
|
||||||
projectionDigest := semanticDigest(projection)
|
projectionDigest := semanticDigest(projection)
|
||||||
|
identityProjection, err := identityProjection(list)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode NPC identity projection: %w", err)
|
||||||
|
}
|
||||||
|
identityProjectionDigest := semanticDigest(identityProjection)
|
||||||
return &Registry{
|
return &Registry{
|
||||||
bound: true,
|
bound: true,
|
||||||
list: list,
|
list: list,
|
||||||
@@ -146,6 +157,8 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
|||||||
projectionDigest: projectionDigest,
|
projectionDigest: projectionDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
||||||
lookupByKey: lookupByKey,
|
lookupByKey: lookupByKey,
|
||||||
|
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
|
||||||
|
lookupByID: lookupByID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +224,15 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
|||||||
return r.promptInput.Clone()
|
return r.promptInput.Clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityPromptInput returns the ordered ID/name projection for consumers
|
||||||
|
// that must bind output records to exact registry identities.
|
||||||
|
func (r *Registry) IdentityPromptInput() contracts.LLMInputMaterial {
|
||||||
|
if r == nil {
|
||||||
|
return contracts.LLMInputMaterial{}
|
||||||
|
}
|
||||||
|
return r.identityInput.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup returns the canonical NPC for an exact canonical-name match under the
|
// Lookup returns the canonical NPC for an exact canonical-name match under the
|
||||||
// NPC identity comparison policy.
|
// NPC identity comparison policy.
|
||||||
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
||||||
@@ -224,6 +246,18 @@ func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
|||||||
return cloneNPC(r.list.NPCs[index]), true
|
return cloneNPC(r.list.NPCs[index]), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LookupID returns the canonical NPC for an exact durable ID.
|
||||||
|
func (r *Registry) LookupID(value string) (dnd.NPC, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return dnd.NPC{}, false
|
||||||
|
}
|
||||||
|
index, ok := r.lookupByID[value]
|
||||||
|
if !ok {
|
||||||
|
return dnd.NPC{}, false
|
||||||
|
}
|
||||||
|
return cloneNPC(r.list.NPCs[index]), true
|
||||||
|
}
|
||||||
|
|
||||||
func semanticDigest(content []byte) string {
|
func semanticDigest(content []byte) string {
|
||||||
sum := sha256.Sum256(content)
|
sum := sha256.Sum256(content)
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
@@ -233,10 +267,19 @@ type projectedNPC struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type identityProjectedNPC struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type projectedNPCRegistry struct {
|
type projectedNPCRegistry struct {
|
||||||
NPCs []projectedNPC `json:"npcs"`
|
NPCs []projectedNPC `json:"npcs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type identityProjectedNPCRegistry struct {
|
||||||
|
NPCs []identityProjectedNPC `json:"npcs"`
|
||||||
|
}
|
||||||
|
|
||||||
func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
|
func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
|
||||||
projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))}
|
projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))}
|
||||||
for index, npc := range list.NPCs {
|
for index, npc := range list.NPCs {
|
||||||
@@ -245,6 +288,14 @@ func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
|
|||||||
return json.Marshal(projection)
|
return json.Marshal(projection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func identityProjection(list dnd.NPCRegistry) ([]byte, error) {
|
||||||
|
projection := identityProjectedNPCRegistry{NPCs: make([]identityProjectedNPC, len(list.NPCs))}
|
||||||
|
for index, npc := range list.NPCs {
|
||||||
|
projection.NPCs[index] = identityProjectedNPC{ID: npc.ID, Name: npc.Name}
|
||||||
|
}
|
||||||
|
return json.Marshal(projection)
|
||||||
|
}
|
||||||
|
|
||||||
func formatIdentityIssues(issues []identity.Issue) string {
|
func formatIdentityIssues(issues []identity.Issue) string {
|
||||||
parts := make([]string, len(issues))
|
parts := make([]string, len(issues))
|
||||||
for index, issue := range issues {
|
for index, issue := range issues {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
|||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
input := registry.PromptInput()
|
input := registry.PromptInput()
|
||||||
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt {
|
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || string(registry.IdentityPromptInput().Content) != emptyPrompt {
|
||||||
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
|
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
|
||||||
}
|
}
|
||||||
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
||||||
@@ -46,6 +46,21 @@ func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIdentityPromptInputProjectsOrderedIDsAndNames(t *testing.T) {
|
||||||
|
registry := resolveList(t, registryFixture())
|
||||||
|
input := registry.IdentityPromptInput()
|
||||||
|
want := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Captain Vale") + `","name":"Captain Vale"}]}`
|
||||||
|
if string(input.Content) != want || input.Digest == registry.ProjectionDigest() {
|
||||||
|
t.Fatalf("identity projection = %#v, want %s", input, want)
|
||||||
|
}
|
||||||
|
if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" {
|
||||||
|
t.Fatalf("LookupID() = %#v, %t", npc, ok)
|
||||||
|
}
|
||||||
|
if _, ok := registry.LookupID("npc:unknown"); ok {
|
||||||
|
t.Fatal("LookupID() accepted an unknown ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNameProjectionDigestTracksOnlyNamesAndOrder(t *testing.T) {
|
func TestNameProjectionDigestTracksOnlyNamesAndOrder(t *testing.T) {
|
||||||
base := registryFixture()
|
base := registryFixture()
|
||||||
evidenceChanged := registryFixture()
|
evidenceChanged := registryFixture()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
|
|||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
||||||
}},
|
}},
|
||||||
{name: "npc interactions evidence", register: func() error {
|
{name: "npc interactions evidence", register: func() error {
|
||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.NPCInteractionListKind, npcInteractionEvidence)
|
return pipeline.RegisterArtifactEvidence(registry, dnd.NPCOccurrenceListKind, npcInteractionEvidence)
|
||||||
}},
|
}},
|
||||||
{name: "scene descriptions evidence", register: func() error {
|
{name: "scene descriptions evidence", register: func() error {
|
||||||
return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence)
|
return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence)
|
||||||
@@ -74,9 +74,9 @@ func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
|
|||||||
return append([]source.SourceRef(nil), refs...)
|
return append([]source.SourceRef(nil), refs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func npcInteractionEvidence(value dnd.NPCInteractionList) []source.SourceRef {
|
func npcInteractionEvidence(value dnd.NPCOccurrenceList) []source.SourceRef {
|
||||||
var refs []source.SourceRef
|
var refs []source.SourceRef
|
||||||
for _, record := range value.Interactions {
|
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...)
|
||||||
|
|||||||
@@ -112,22 +112,22 @@ func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error)
|
|||||||
return combined, nil
|
return combined, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) {
|
func appendNPCInteractionLists(values []dnd.NPCOccurrenceList) (dnd.NPCOccurrenceList, error) {
|
||||||
count := 0
|
count := 0
|
||||||
present := false
|
present := false
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if value.Interactions != nil {
|
if value.Occurrences != nil {
|
||||||
present = true
|
present = true
|
||||||
}
|
}
|
||||||
count += len(value.Interactions)
|
count += len(value.Occurrences)
|
||||||
}
|
}
|
||||||
if !present {
|
if !present {
|
||||||
return dnd.NPCInteractionList{}, nil
|
return dnd.NPCOccurrenceList{}, nil
|
||||||
}
|
}
|
||||||
combined := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 0, count)}
|
combined := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 0, count)}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
for _, interaction := range value.Interactions {
|
for _, occurrence := range value.Occurrences {
|
||||||
combined.Interactions = append(combined.Interactions, cloneNPCInteraction(interaction))
|
combined.Occurrences = append(combined.Occurrences, cloneNPCOccurrence(occurrence))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return combined, nil
|
return combined, nil
|
||||||
@@ -228,7 +228,7 @@ func cloneNPC(value dnd.NPC) dnd.NPC {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
|
func cloneNPCOccurrence(value dnd.NPCOccurrence) dnd.NPCOccurrence {
|
||||||
clone := value
|
clone := value
|
||||||
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
|
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
|
||||||
return clone
|
return clone
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
|
||||||
}},
|
}},
|
||||||
{name: "npc-interaction-list appendorder merger", register: func() error {
|
{name: "npc-interaction-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCOccurrenceListKind, appendNPCInteractionLists)
|
||||||
}},
|
}},
|
||||||
{name: "scene-description-list appendorder merger", register: func() error {
|
{name: "scene-description-list appendorder merger", register: func() error {
|
||||||
return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists)
|
return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists)
|
||||||
@@ -115,7 +115,7 @@ func registerModules(registries pipeline.Registries) error {
|
|||||||
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
|
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
|
||||||
}},
|
}},
|
||||||
{name: "npc-interaction-list noop normalizer", register: func() error {
|
{name: "npc-interaction-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind)
|
return noop.RegisterTyped[dnd.NPCOccurrenceList](registries.Normalizers, dnd.NPCOccurrenceListKind)
|
||||||
}},
|
}},
|
||||||
{name: "scene-description-list noop normalizer", register: func() error {
|
{name: "scene-description-list noop normalizer", register: func() error {
|
||||||
return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind)
|
return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind)
|
||||||
|
|||||||
@@ -84,15 +84,15 @@ 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, interactionextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key})
|
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key})
|
||||||
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.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, interactionnormalize.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.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, 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.ItemEventListKind})
|
||||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind})
|
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.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})
|
||||||
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||||
"extract/dnd/locations/shape", "normalize/dnd/locations/identity", "extract/dnd/locations/source_refs", "extract/dnd/locations/source_relatedness",
|
"extract/dnd/locations/shape", "normalize/dnd/locations/identity", "extract/dnd/locations/source_refs", "extract/dnd/locations/source_relatedness",
|
||||||
@@ -302,7 +302,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
"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_events_llm.v1.json",
|
||||||
"dnd_npc_interactions_llm.v1.json",
|
"dnd_npc_occurrences_llm.v1.json",
|
||||||
"dnd_scene_descriptions_llm.v1.json",
|
"dnd_scene_descriptions_llm.v1.json",
|
||||||
"dnd_locations_llm.v1.json",
|
"dnd_locations_llm.v1.json",
|
||||||
"dnd_location_occurrences_llm.v1.json",
|
"dnd_location_occurrences_llm.v1.json",
|
||||||
@@ -353,7 +353,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
|||||||
}
|
}
|
||||||
interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key)
|
interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key)
|
||||||
interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key)
|
interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key)
|
||||||
if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize {
|
if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCOccurrenceListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCOccurrenceListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize {
|
||||||
t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK)
|
t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK)
|
||||||
}
|
}
|
||||||
locationExtractSpec, locationExtractOK := registries.Extractors.Spec(locationextract.Key)
|
locationExtractSpec, locationExtractOK := registries.Extractors.Spec(locationextract.Key)
|
||||||
@@ -419,7 +419,7 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes
|
|||||||
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||||
}, want: []source.SourceRef{first, second}},
|
}, want: []source.SourceRef{first, second}},
|
||||||
{name: "npc interactions", project: func() []source.SourceRef {
|
{name: "npc interactions", project: func() []source.SourceRef {
|
||||||
return npcInteractionEvidence(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{first, second}}}})
|
return npcInteractionEvidence(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{first, second}}}})
|
||||||
}, want: []source.SourceRef{first, second}},
|
}, want: []source.SourceRef{first, second}},
|
||||||
{name: "scene descriptions", project: func() []source.SourceRef {
|
{name: "scene descriptions", project: func() []source.SourceRef {
|
||||||
return sceneDescriptionEvidence(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{SourceRef: first}, {SourceRef: second}}})
|
return sceneDescriptionEvidence(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{SourceRef: first}, {SourceRef: second}}})
|
||||||
@@ -561,26 +561,26 @@ func TestAppendSpellListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
|||||||
|
|
||||||
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||||
input := []dnd.NPCInteractionList{
|
input := []dnd.NPCOccurrenceList{
|
||||||
{},
|
{},
|
||||||
{Interactions: []dnd.NPCInteraction{}},
|
{Occurrences: []dnd.NPCOccurrence{}},
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}},
|
{Occurrences: []dnd.NPCOccurrence{{Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: refs}}},
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
|
{Occurrences: []dnd.NPCOccurrence{{Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
|
||||||
}
|
}
|
||||||
got, err := appendNPCInteractionLists(input)
|
got, err := appendNPCInteractionLists(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("appendNPCInteractionLists() error = %v", err)
|
t.Fatalf("appendNPCInteractionLists() error = %v", err)
|
||||||
}
|
}
|
||||||
if got.Interactions == nil || !reflect.DeepEqual([]string{got.Interactions[0].Name, got.Interactions[1].Name}, []string{"Aria", "Borin"}) {
|
if got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"Aria", "Borin"}) {
|
||||||
t.Fatalf("combined interactions = %#v", got)
|
t.Fatalf("combined interactions = %#v", got)
|
||||||
}
|
}
|
||||||
got.Interactions[0].SourceRefs[0].StartUnitID = 999
|
got.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||||
if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 {
|
if input[2].Occurrences[0].SourceRefs[0].StartUnitID == 999 {
|
||||||
t.Fatal("merged interactions share source reference storage")
|
t.Fatal("merged interactions share source reference storage")
|
||||||
}
|
}
|
||||||
for _, values := range [][]dnd.NPCInteractionList{nil, []dnd.NPCInteractionList{{}, {}}} {
|
for _, values := range [][]dnd.NPCOccurrenceList{nil, []dnd.NPCOccurrenceList{{}, {}}} {
|
||||||
result, err := appendNPCInteractionLists(values)
|
result, err := appendNPCInteractionLists(values)
|
||||||
if err != nil || result.Interactions != nil {
|
if err != nil || result.Occurrences != nil {
|
||||||
t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err)
|
t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,8 +711,8 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) {
|
|||||||
t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err)
|
t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}})
|
interactions, err := appendNPCInteractionLists([]dnd.NPCOccurrenceList{{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{}}}}})
|
||||||
if err != nil || interactions.Interactions[0].SourceRefs == nil {
|
if err != nil || interactions.Occurrences[0].SourceRefs == nil {
|
||||||
t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err)
|
t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ func registerValidators(registries pipeline.Registries) error {
|
|||||||
return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
||||||
}},
|
}},
|
||||||
{name: "npc-interaction-list always accept validator", register: func() error {
|
{name: "npc-interaction-list always accept validator", register: func() error {
|
||||||
return alwaysaccept.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind)
|
return alwaysaccept.RegisterTyped[dnd.NPCOccurrenceList](registries.Validators, dnd.NPCOccurrenceListKind)
|
||||||
}},
|
}},
|
||||||
{name: "npc-interaction-list always reject validator", register: func() error {
|
{name: "npc-interaction-list always reject validator", register: func() error {
|
||||||
return alwaysreject.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind)
|
return alwaysreject.RegisterTyped[dnd.NPCOccurrenceList](registries.Validators, dnd.NPCOccurrenceListKind)
|
||||||
}},
|
}},
|
||||||
{name: "scene-description-list always accept validator", register: func() error {
|
{name: "scene-description-list always accept validator", register: func() error {
|
||||||
return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind)
|
return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const NPCRegistryKind contracts.ArtifactKind = "dnd/npc-registry"
|
|||||||
|
|
||||||
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
|
||||||
|
|
||||||
const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
|
const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list"
|
||||||
|
|
||||||
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
||||||
|
|
||||||
@@ -64,24 +64,25 @@ type CombatTurn struct {
|
|||||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NPCInteractionKind string
|
type NPCOccurrenceKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NPCInteractionKindMentioned NPCInteractionKind = "mentioned"
|
NPCOccurrenceKindMentioned NPCOccurrenceKind = "mentioned"
|
||||||
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence"
|
NPCOccurrenceKindNoncombatPresence NPCOccurrenceKind = "noncombat_presence"
|
||||||
NPCInteractionKindDialogue NPCInteractionKind = "dialogue"
|
NPCOccurrenceKindDialogue NPCOccurrenceKind = "dialogue"
|
||||||
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally"
|
NPCOccurrenceKindCombatAlly NPCOccurrenceKind = "combat_ally"
|
||||||
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent"
|
NPCOccurrenceKindCombatOpponent NPCOccurrenceKind = "combat_opponent"
|
||||||
NPCInteractionKindOther NPCInteractionKind = "other"
|
NPCOccurrenceKindOther NPCOccurrenceKind = "other"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NPCInteractionList struct {
|
type NPCOccurrenceList struct {
|
||||||
Interactions []NPCInteraction `json:"interactions"`
|
Occurrences []NPCOccurrence `json:"occurrences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NPCInteraction struct {
|
type NPCOccurrence struct {
|
||||||
|
NPCID string `json:"npc_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind NPCInteractionKind `json:"kind"`
|
Kind NPCOccurrenceKind `json:"kind"`
|
||||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type Validator struct {
|
|||||||
npcResolver *npcregistry.Resolver
|
npcResolver *npcregistry.Resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
@@ -72,11 +72,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "policy", Value: policy},
|
{Name: "policy", Value: policy},
|
||||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()},
|
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if interactionshape.Validate(req.Value) != nil {
|
if interactionshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -105,25 +105,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool {
|
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCOccurrenceList) bool {
|
||||||
for _, interaction := range value.Interactions {
|
for _, occurrence := range value.Occurrences {
|
||||||
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
|
if !interactionmodel.ValidSourceRefs(index, occurrence.SourceRefs) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string {
|
func issuesFor(order shared.SourceRefOrder, value dnd.NPCOccurrenceList, npcRegistry *npcregistry.Registry) []string {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, interaction := range value.Interactions {
|
for index, occurrence := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("interactions[%d]", index)
|
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||||
if canonical, ok := npcRegistry.Lookup(interaction.Name); ok && interaction.Name != canonical.Name {
|
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
|
||||||
issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(interaction.Name))
|
if !ok {
|
||||||
|
issues = append(issues, prefix+".npc_id is not in the NPC registry: "+diagnostics.Quote(occurrence.NPCID))
|
||||||
|
} else if occurrence.Name != canonical.Name {
|
||||||
|
issues = append(issues, prefix+".name does not match npc_id: "+diagnostics.Quote(occurrence.Name))
|
||||||
}
|
}
|
||||||
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ {
|
for refIndex := 1; refIndex < len(occurrence.SourceRefs); refIndex++ {
|
||||||
previous := interaction.SourceRefs[refIndex-1]
|
previous := occurrence.SourceRefs[refIndex-1]
|
||||||
current := interaction.SourceRefs[refIndex]
|
current := occurrence.SourceRefs[refIndex]
|
||||||
if order.Less(current, previous) {
|
if order.Less(current, previous) {
|
||||||
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
||||||
} else if current == previous {
|
} else if current == previous {
|
||||||
@@ -132,16 +135,16 @@ func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcReg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool {
|
if !sort.SliceIsSorted(value.Occurrences, func(left, right int) bool {
|
||||||
return interactionmodel.Less(order, value.Interactions[left], value.Interactions[right])
|
return interactionmodel.Less(order, value.Occurrences[left], value.Occurrences[right])
|
||||||
}) {
|
}) {
|
||||||
issues = append(issues, "interactions are not in canonical order")
|
issues = append(issues, "occurrences are not in canonical order")
|
||||||
}
|
}
|
||||||
seen := make(map[string]int)
|
seen := make(map[string]int)
|
||||||
for index, interaction := range value.Interactions {
|
for index, occurrence := range value.Occurrences {
|
||||||
key := interactionmodel.ExactIdentity(interaction)
|
key := interactionmodel.ExactIdentity(occurrence)
|
||||||
if previous, ok := seen[key]; ok {
|
if previous, ok := seen[key]; ok {
|
||||||
issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous))
|
issues = append(issues, fmt.Sprintf("occurrences[%d] duplicates occurrence %d", index, previous))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = index
|
seen[key] = index
|
||||||
@@ -154,7 +157,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -28,26 +28,26 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(
|
|||||||
references := registryReferences(t, "Aria", "Borin")
|
references := registryReferences(t, "Aria", "Borin")
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
mutate func(*dnd.NPCInteractionList)
|
mutate func(*dnd.NPCOccurrenceList)
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"canonical name", func(value *dnd.NPCInteractionList) { value.Interactions[0].Name = " aria " }, "canonical NPC display name"},
|
{"mismatched name", func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].Name = " aria " }, "does not match npc_id"},
|
||||||
{"reference order", func(value *dnd.NPCInteractionList) {
|
{"reference order", func(value *dnd.NPCOccurrenceList) {
|
||||||
value.Interactions[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}}
|
||||||
}, "not in canonical order"},
|
}, "not in canonical order"},
|
||||||
{"duplicate reference", func(value *dnd.NPCInteractionList) {
|
{"duplicate reference", func(value *dnd.NPCOccurrenceList) {
|
||||||
value.Interactions[0].SourceRefs = append(value.Interactions[0].SourceRefs, value.Interactions[0].SourceRefs[0])
|
value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
|
||||||
}, "duplicates the previous reference"},
|
}, "duplicates the previous reference"},
|
||||||
{"list order", func(value *dnd.NPCInteractionList) {
|
{"list order", func(value *dnd.NPCOccurrenceList) {
|
||||||
value.Interactions[0], value.Interactions[1] = value.Interactions[1], value.Interactions[0]
|
value.Occurrences[0], value.Occurrences[1] = value.Occurrences[1], value.Occurrences[0]
|
||||||
}, "interactions are not in canonical order"},
|
}, "occurrences are not in canonical order"},
|
||||||
{"name tie breaker", func(value *dnd.NPCInteractionList) {
|
{"NPC ID tie breaker", func(value *dnd.NPCOccurrenceList) {
|
||||||
value.Interactions[0] = dnd.NPCInteraction{Name: "Borin", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
|
value.Occurrences[0] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
|
||||||
value.Interactions[1] = dnd.NPCInteraction{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
|
value.Occurrences[1] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
|
||||||
}, "interactions are not in canonical order"},
|
}, "occurrences are not in canonical order"},
|
||||||
{"duplicate record", func(value *dnd.NPCInteractionList) {
|
{"duplicate record", func(value *dnd.NPCOccurrenceList) {
|
||||||
value.Interactions = append(value.Interactions, value.Interactions[0])
|
value.Occurrences = append(value.Occurrences, value.Occurrences[0])
|
||||||
}, "duplicates interaction"},
|
}, "duplicates occurrence"},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
value := normalizedList()
|
value := normalizedList()
|
||||||
@@ -62,9 +62,9 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(
|
|||||||
|
|
||||||
func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
|
func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
|
||||||
references := registryReferences(t, "Aria", "Borin")
|
references := registryReferences(t, "Aria", "Borin")
|
||||||
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||||
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
{NPCID: "npc:test", Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||||
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
|
{NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
|
||||||
}}
|
}}
|
||||||
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
|
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
@@ -74,9 +74,9 @@ func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) {
|
func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) {
|
||||||
references := registryReferences(t, "Aria", "Borin")
|
references := registryReferences(t, "Aria", "Borin")
|
||||||
for _, value := range []dnd.NPCInteractionList{
|
for _, value := range []dnd.NPCOccurrenceList{
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: "Aria"}}},
|
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria"}}},
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
|
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
|
||||||
} {
|
} {
|
||||||
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
|
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
@@ -126,26 +126,26 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
|
|||||||
return validator
|
return validator
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
|
func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
|
||||||
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: document(), References: references, Value: value}
|
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: document(), References: references, Value: value}
|
||||||
}
|
}
|
||||||
|
|
||||||
func document() *source.SourceDocument {
|
func document() *source.SourceDocument {
|
||||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
|
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizedList() dnd.NPCInteractionList {
|
func normalizedList() dnd.NPCOccurrenceList {
|
||||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||||
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||||
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
{NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneList(value dnd.NPCInteractionList) dnd.NPCInteractionList {
|
func cloneList(value dnd.NPCOccurrenceList) dnd.NPCOccurrenceList {
|
||||||
copyValue := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, len(value.Interactions))}
|
copyValue := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, len(value.Occurrences))}
|
||||||
for index, interaction := range value.Interactions {
|
for index, interaction := range value.Occurrences {
|
||||||
copyValue.Interactions[index] = interaction
|
copyValue.Occurrences[index] = interaction
|
||||||
copyValue.Interactions[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...)
|
copyValue.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...)
|
||||||
}
|
}
|
||||||
return copyValue
|
return copyValue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type Validator struct {
|
|||||||
npcResolver *npcregistry.Resolver
|
npcResolver *npcregistry.Resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||||
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
@@ -68,11 +68,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "policy", Value: policy},
|
{Name: "policy", Value: policy},
|
||||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()},
|
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if interactionshape.Validate(req.Value) != nil {
|
if interactionshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -87,9 +87,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return rejection([]string{"NPC registry reference is required"}), nil
|
return rejection([]string{"NPC registry reference is required"}), nil
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, interaction := range req.Value.Interactions {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
if _, ok := npcRegistry.Lookup(interaction.Name); !ok {
|
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
|
||||||
issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name)))
|
if !ok {
|
||||||
|
issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if occurrence.Name != canonical.Name {
|
||||||
|
issues = append(issues, fmt.Sprintf("occurrences[%d].name does not match npc_id: %s", index, diagnostics.Quote(occurrence.Name)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
@@ -111,7 +116,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -16,19 +16,25 @@ import (
|
|||||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidatorRecognizesRegistryNamesAndRejectsUnknownNames(t *testing.T) {
|
func TestValidatorRecognizesExactRegistryPairsAndRejectsUnknownIDs(t *testing.T) {
|
||||||
references := registryReferences(t, "Mira Thorn")
|
references := registryReferences(t, "Mira Thorn")
|
||||||
validator := newValidator(t, references)
|
validator := newValidator(t, references)
|
||||||
value := validList(" mira thorn ")
|
value := validList("Mira Thorn")
|
||||||
result, err := validator.Validate(context.Background(), request(references, value))
|
result, err := validator.Validate(context.Background(), request(references, value))
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("recognized result = %#v, %v", result, err)
|
t.Fatalf("recognized result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
value.Interactions[0].Name = "Unknown NPC"
|
value.Occurrences[0].NPCID = "npc:unknown"
|
||||||
result, err = validator.Validate(context.Background(), request(references, value))
|
result, err = validator.Validate(context.Background(), request(references, value))
|
||||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].name") {
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].npc_id") {
|
||||||
t.Fatalf("unknown result = %#v, %v", result, err)
|
t.Fatalf("unknown result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
|
value = validList("Mira Thorn")
|
||||||
|
value.Occurrences[0].Name = "Hooded Guard"
|
||||||
|
result, err = validator.Validate(context.Background(), request(references, value))
|
||||||
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "does not match npc_id") {
|
||||||
|
t.Fatalf("mismatched result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) {
|
func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) {
|
||||||
@@ -45,7 +51,7 @@ func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t
|
|||||||
}
|
}
|
||||||
|
|
||||||
empty := registryReferences(t)
|
empty := registryReferences(t)
|
||||||
result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}))
|
result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}}))
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("empty registry result = %#v, %v", result, err)
|
t.Fatalf("empty registry result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -78,7 +84,7 @@ func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t *
|
|||||||
func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) {
|
func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) {
|
||||||
references := registryReferences(t, "Mira Thorn")
|
references := registryReferences(t, "Mira Thorn")
|
||||||
validator := newValidator(t, references)
|
validator := newValidator(t, references)
|
||||||
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}
|
malformed := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}}
|
||||||
result, err := validator.Validate(context.Background(), request(references, malformed))
|
result, err := validator.Validate(context.Background(), request(references, 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)
|
||||||
@@ -107,13 +113,13 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
|
|||||||
return validator
|
return validator
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
|
func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
|
||||||
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value}
|
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{References: references, Value: value}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validList(name string) dnd.NPCInteractionList {
|
func validList(name string) dnd.NPCOccurrenceList {
|
||||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||||
Name: name, Kind: dnd.NPCInteractionKindDialogue,
|
NPCID: identity.DeriveID(name), Name: name, Kind: dnd.NPCOccurrenceKindDialogue,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
@@ -33,14 +33,14 @@ 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.NPCInteractionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (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
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.NPCInteractionList) error {
|
func Validate(value dnd.NPCOccurrenceList) error {
|
||||||
issues := issuesFor(value)
|
issues := issuesFor(value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -48,34 +48,37 @@ func Validate(value dnd.NPCInteractionList) error {
|
|||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.NPCInteractionList) []string {
|
func issuesFor(value dnd.NPCOccurrenceList) []string {
|
||||||
if value.Interactions == nil {
|
if value.Occurrences == nil {
|
||||||
return []string{"interactions must be present"}
|
return []string{"occurrences must be present"}
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, interaction := range value.Interactions {
|
for index, occurrence := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("interactions[%d]", index)
|
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||||
if strings.TrimSpace(interaction.Name) == "" {
|
if strings.TrimSpace(occurrence.NPCID) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(interaction.Name))
|
issues = append(issues, prefix+".npc_id must not be empty: "+diagnostics.Quote(occurrence.NPCID))
|
||||||
}
|
}
|
||||||
if !validKind(interaction.Kind) {
|
if strings.TrimSpace(occurrence.Name) == "" {
|
||||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(interaction.Kind)))
|
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(occurrence.Name))
|
||||||
}
|
}
|
||||||
if len(interaction.SourceRefs) == 0 {
|
if !validKind(occurrence.Kind) {
|
||||||
|
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
|
||||||
|
}
|
||||||
|
if len(occurrence.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func validKind(value dnd.NPCInteractionKind) bool {
|
func validKind(value dnd.NPCOccurrenceKind) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case dnd.NPCInteractionKindMentioned,
|
case dnd.NPCOccurrenceKindMentioned,
|
||||||
dnd.NPCInteractionKindNoncombatPresence,
|
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||||
dnd.NPCInteractionKindDialogue,
|
dnd.NPCOccurrenceKindDialogue,
|
||||||
dnd.NPCInteractionKindCombatAlly,
|
dnd.NPCOccurrenceKindCombatAlly,
|
||||||
dnd.NPCInteractionKindCombatOpponent,
|
dnd.NPCOccurrenceKindCombatOpponent,
|
||||||
dnd.NPCInteractionKindOther:
|
dnd.NPCOccurrenceKindOther:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -87,7 +90,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -15,22 +15,22 @@ import (
|
|||||||
|
|
||||||
func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) {
|
func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) {
|
||||||
valid := validList()
|
valid := validList()
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: valid})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: valid})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
value dnd.NPCInteractionList
|
value dnd.NPCOccurrenceList
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"missing interactions", dnd.NPCInteractionList{}, "interactions must be present"},
|
{"missing occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
|
||||||
{"blank name", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: valid.Interactions[0].SourceRefs}}}, "name must not be empty"},
|
{"blank name", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: valid.Occurrences[0].SourceRefs}}}, "name must not be empty"},
|
||||||
{"unsupported kind", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Interactions[0].SourceRefs}}}, "kind is unsupported"},
|
{"unsupported kind", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Occurrences[0].SourceRefs}}}, "kind is unsupported"},
|
||||||
{"missing evidence", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue}}}, "source_refs must contain"},
|
{"missing evidence", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue}}}, "source_refs must contain"},
|
||||||
} {
|
} {
|
||||||
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.NPCInteractionList]{Value: test.value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{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)
|
||||||
}
|
}
|
||||||
@@ -39,15 +39,15 @@ func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *testing.T) {
|
func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *testing.T) {
|
||||||
value := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 24)}
|
value := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 24)}
|
||||||
for index := range value.Interactions {
|
for index := range value.Occurrences {
|
||||||
value.Interactions[index] = dnd.NPCInteraction{Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"}
|
value.Occurrences[index] = dnd.NPCOccurrence{NPCID: "npc:test", Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"}
|
||||||
}
|
}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: value})
|
||||||
if err != nil || result.Approved || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
if err != nil || result.Approved || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
if value.Interactions[0].Name != strings.Repeat("火", 220)+"\n" {
|
if value.Occurrences[0].Name != strings.Repeat("火", 220)+"\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}}) {
|
||||||
@@ -62,9 +62,9 @@ func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validList() dnd.NPCInteractionList {
|
func validList() dnd.NPCOccurrenceList {
|
||||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||||
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
|
NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*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.NPCInteractionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (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("NPC interaction source-reference validator requires the current extraction chunk")
|
return contracts.ValidationResult{}, fmt.Errorf("NPC interaction source-reference validator requires the current extraction chunk")
|
||||||
}
|
}
|
||||||
@@ -43,16 +43,16 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for interactionIndex, interaction := range req.Value.Interactions {
|
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||||
for refIndex, ref := range interaction.SourceRefs {
|
for refIndex, ref := range occurrence.SourceRefs {
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
||||||
issues = append(issues, fmt.Sprintf(
|
issues = append(issues, fmt.Sprintf(
|
||||||
"interactions[%d].source_refs[%d]: source reference is outside the current extraction chunk",
|
"occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk",
|
||||||
interactionIndex, refIndex,
|
occurrenceIndex, refIndex,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ func TestValidatorOwnsCurrentSourceUnitAndRangeValidation(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.Interactions[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},
|
||||||
}
|
}
|
||||||
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 || !strings.Contains(result.Message, "interactions[0].source_refs[0]") {
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") {
|
||||||
t.Fatalf("invalid result = %#v, %v", result, err)
|
t.Fatalf("invalid result = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
|
|||||||
Units: append([]source.SourceUnit(nil), doc.Units[:2]...),
|
Units: append([]source.SourceUnit(nil), doc.Units[:2]...),
|
||||||
}
|
}
|
||||||
value := validList()
|
value := validList()
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
|
||||||
Stage: string(pipeline.StageExtract),
|
Stage: string(pipeline.StageExtract),
|
||||||
Source: doc,
|
Source: doc,
|
||||||
Chunk: chunk,
|
Chunk: chunk,
|
||||||
@@ -47,10 +47,10 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
|
|||||||
t.Fatalf("contained evidence = %#v, %v", result, err)
|
t.Fatalf("contained evidence = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
value.Interactions[0].SourceRefs = []source.SourceRef{{
|
value.Occurrences[0].SourceRefs = []source.SourceRef{{
|
||||||
SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3,
|
SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3,
|
||||||
}}
|
}}
|
||||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
|
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
|
||||||
Stage: string(pipeline.StageExtract),
|
Stage: string(pipeline.StageExtract),
|
||||||
Source: doc,
|
Source: doc,
|
||||||
Chunk: chunk,
|
Chunk: chunk,
|
||||||
@@ -62,7 +62,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) {
|
func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) {
|
||||||
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
|
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
|
||||||
Stage: string(pipeline.StageExtract),
|
Stage: string(pipeline.StageExtract),
|
||||||
Source: document(),
|
Source: document(),
|
||||||
Value: validList(),
|
Value: validList(),
|
||||||
@@ -73,7 +73,7 @@ func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) {
|
func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) {
|
||||||
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}
|
malformed := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}}
|
||||||
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)
|
||||||
@@ -93,17 +93,17 @@ func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(doc *source.SourceDocument, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
|
func request(doc *source.SourceDocument, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
|
||||||
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, Value: value}
|
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{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}}}
|
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validList() dnd.NPCInteractionList {
|
func validList() dnd.NPCOccurrenceList {
|
||||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||||
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
|
NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}},
|
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.NPCInteractionList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
@@ -35,7 +35,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.NPCInteractionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if interactionshape.Validate(req.Value) != nil {
|
if interactionshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -43,23 +43,23 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
citedTexts := make([]string, len(req.Value.Interactions))
|
citedTexts := make([]string, len(req.Value.Occurrences))
|
||||||
for index, interaction := range req.Value.Interactions {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
citedText, err := resolver.CitedText(interaction.SourceRefs)
|
citedText, err := resolver.CitedText(occurrence.SourceRefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
citedTexts[index] = citedText
|
citedTexts[index] = citedText
|
||||||
}
|
}
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, interaction := range req.Value.Interactions {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
if shared.ContainsTokenSequence(citedTexts[index], interaction.Name) {
|
if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
Scope: fmt.Sprintf("interactions[%d]", index),
|
Scope: fmt.Sprintf("occurrences[%d]", index),
|
||||||
ReasonCode: WarningReasonCode,
|
ReasonCode: WarningReasonCode,
|
||||||
Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(interaction.Name)),
|
Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{
|
return contracts.ValidationResult{
|
||||||
@@ -73,7 +73,7 @@ func Spec() pipeline.ValidatorSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
|
||||||
options, err := DecodeOptions(request.Options)
|
options, err := DecodeOptions(request.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -14,34 +14,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) {
|
func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) {
|
||||||
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||||
{Name: "O'Rin Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
|
{NPCID: "npc:test", Name: "O'Rin Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
|
||||||
{Name: "Missing\nNPC", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
|
{NPCID: "npc:test", Name: "Missing\nNPC", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
|
||||||
}}
|
}}
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "O’Rin Thorn speaks."}, {ID: 2, Text: "The party waits."}}}
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "O’Rin Thorn speaks."}, {ID: 2, Text: "The party waits."}}}
|
||||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Missing NPC")}}}}}
|
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Missing NPC")}}}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, References: references, Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{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 != "interactions[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nNPC`) {
|
if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nNPC`) {
|
||||||
t.Fatalf("warning = %#v", warning)
|
t.Fatalf("warning = %#v", warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
||||||
for _, value := range []dnd.NPCInteractionList{
|
for _, value := range []dnd.NPCOccurrenceList{
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}},
|
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}},
|
||||||
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
|
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
|
||||||
} {
|
} {
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value})
|
||||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
t.Fatalf("deferral = %#v, %v", result, err)
|
t.Fatalf("deferral = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||||
before := value
|
before := value
|
||||||
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Mira Thorn"}}}, Value: value})
|
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Mira Thorn"}}}, Value: value})
|
||||||
if err != nil || !reflect.DeepEqual(value, before) {
|
if err != nil || !reflect.DeepEqual(value, before) {
|
||||||
t.Fatalf("Validate() mutated value: %#v", value)
|
t.Fatalf("Validate() mutated value: %#v", value)
|
||||||
}
|
}
|
||||||
@@ -53,17 +53,18 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidatorBoundsWarnings(t *testing.T) {
|
func TestValidatorBoundsWarnings(t *testing.T) {
|
||||||
count := diagnostics.MaxWarnings + 5
|
count := diagnostics.MaxWarnings + 5
|
||||||
interactions := make([]dnd.NPCInteraction, count)
|
interactions := make([]dnd.NPCOccurrence, count)
|
||||||
for index := range interactions {
|
for index := range interactions {
|
||||||
interactions[index] = dnd.NPCInteraction{
|
interactions[index] = dnd.NPCOccurrence{
|
||||||
|
NPCID: "npc:test",
|
||||||
Name: "Missing NPC",
|
Name: "Missing NPC",
|
||||||
Kind: dnd.NPCInteractionKindMentioned,
|
Kind: dnd.NPCOccurrenceKindMentioned,
|
||||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
|
||||||
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}},
|
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}},
|
||||||
Value: dnd.NPCInteractionList{Interactions: interactions},
|
Value: dnd.NPCOccurrenceList{Occurrences: interactions},
|
||||||
})
|
})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
request := client.requestFor(t, interactionextract.PromptID)
|
request := client.requestFor(t, interactionextract.PromptID)
|
||||||
wantRegistry := `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}`
|
wantRegistry := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Hooded Guard") + `","name":"Hooded Guard"}]}`
|
||||||
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
|
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
|
||||||
t.Fatalf("interaction registry input = %s, want names-only projection %s", got, wantRegistry)
|
t.Fatalf("interaction registry input = %s, want names-only projection %s", got, wantRegistry)
|
||||||
}
|
}
|
||||||
@@ -52,25 +52,25 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decode(interaction output) error = %v", err)
|
t.Fatalf("Decode(interaction output) error = %v", err)
|
||||||
}
|
}
|
||||||
if len(interactions.Interactions) != 2 {
|
if len(interactions.Occurrences) != 2 {
|
||||||
t.Fatalf("interactions = %#v, want two occurrences", interactions)
|
t.Fatalf("interactions = %#v, want two occurrences", interactions)
|
||||||
}
|
}
|
||||||
first, second := interactions.Interactions[0], interactions.Interactions[1]
|
first, second := interactions.Occurrences[0], interactions.Occurrences[1]
|
||||||
if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" {
|
if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" {
|
||||||
t.Fatalf("interactions = %#v, want canonical names, kinds, and source chronology", interactions)
|
t.Fatalf("interactions = %#v, want canonical names, kinds, and source chronology", interactions)
|
||||||
}
|
}
|
||||||
assertInteractionEvidence(t, first.SourceRefs)
|
assertInteractionEvidence(t, first.SourceRefs)
|
||||||
assertInteractionEvidence(t, second.SourceRefs)
|
assertInteractionEvidence(t, second.SourceRefs)
|
||||||
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
|
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
|
||||||
t.Fatalf("interaction chronology = %#v, want source order", interactions.Interactions)
|
t.Fatalf("interaction chronology = %#v, want source order", interactions.Occurrences)
|
||||||
}
|
}
|
||||||
|
|
||||||
var durable map[string]json.RawMessage
|
var durable map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(serialized.Artifact.Content, &durable); err != nil {
|
if err := json.Unmarshal(serialized.Artifact.Content, &durable); err != nil {
|
||||||
t.Fatalf("unmarshal durable interaction payload: %v", err)
|
t.Fatalf("unmarshal durable interaction payload: %v", err)
|
||||||
}
|
}
|
||||||
if len(durable) != 1 || durable["interactions"] == nil {
|
if len(durable) != 1 || durable["occurrences"] == nil {
|
||||||
t.Fatalf("durable interaction payload = %#v, want only interactions", durable)
|
t.Fatalf("durable interaction payload = %#v, want only occurrences", durable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +113,8 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
|
|||||||
t.Fatalf("interaction output step = %q, want ordered downstream step", interactionOutput.StepID)
|
t.Fatalf("interaction output step = %q, want ordered downstream step", interactionOutput.StepID)
|
||||||
}
|
}
|
||||||
registryRequest := client.requestFor(t, interactionextract.PromptID)
|
registryRequest := client.requestFor(t, interactionextract.PromptID)
|
||||||
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
|
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` {
|
||||||
t.Fatalf("downstream registry = %s, want one canonical names-only NPC", got)
|
t.Fatalf("downstream registry = %s, want one canonical NPC identity", got)
|
||||||
}
|
}
|
||||||
manifestContent, err := json.Marshal(output.Manifest)
|
manifestContent, err := json.Marshal(output.Manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -228,9 +228,9 @@ func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, r
|
|||||||
case npcnormalize.PromptID:
|
case npcnormalize.PromptID:
|
||||||
payload = map[string]any{"duplicate_groups": []any{}}
|
payload = map[string]any{"duplicate_groups": []any{}}
|
||||||
case interactionextract.PromptID:
|
case interactionextract.PromptID:
|
||||||
payload = map[string]any{"interactions": []any{
|
payload = map[string]any{"occurrences": []any{
|
||||||
map[string]any{"name": "Hooded Guard", "kind": "noncombat_presence", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}},
|
map[string]any{"npc_id": identity.DeriveID("Hooded Guard"), "name": "Hooded Guard", "kind": "noncombat_presence", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}},
|
||||||
map[string]any{"name": " mira thorn ", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}},
|
map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}},
|
||||||
}}
|
}}
|
||||||
default:
|
default:
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected interaction prompt %q", request.PromptID)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected interaction prompt %q", request.PromptID)
|
||||||
@@ -291,7 +291,7 @@ func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context
|
|||||||
case npcnormalize.PromptID:
|
case npcnormalize.PromptID:
|
||||||
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}}
|
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}}
|
||||||
case interactionextract.PromptID:
|
case interactionextract.PromptID:
|
||||||
payload = map[string]any{"interactions": []any{map[string]any{"name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
|
payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
|
||||||
default:
|
default:
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user