Adopt registry-backed NPC occurrence artifacts
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
Extract Dungeons & Dragons NPC interaction occurrences from the supplied
|
||||
transcript. Include an occurrence only when the transcript establishes one
|
||||
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,
|
||||
or persistent state. Do not identify player characters, anonymous groups, or
|
||||
|
||||
@@ -41,5 +41,5 @@ messages:
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: dnd_npc_interactions_llm.v1.json
|
||||
schema_path: dnd_npc_occurrences_llm.v1.json
|
||||
repair_attempts: 0
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.npc_interactions.llm",
|
||||
"$id": "notarius.dnd.npc_occurrences.llm",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["interactions"],
|
||||
"required": ["occurrences"],
|
||||
"properties": {
|
||||
"interactions": {
|
||||
"occurrences": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "kind", "source_refs"],
|
||||
"required": ["npc_id", "name", "kind", "source_refs"],
|
||||
"properties": {
|
||||
"npc_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9,22 +9,23 @@ or summarize the session.
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| Artifact kind | `dnd/npc-interaction-list` |
|
||||
| Schema ID | `notarius.dnd.npc_interactions` |
|
||||
| Schema name | `notarius_dnd_npc_interactions_v1` |
|
||||
| Artifact kind | `dnd/npc-occurrence-list` |
|
||||
| Schema ID | `notarius.dnd.npc_occurrences` |
|
||||
| Schema name | `notarius_dnd_npc_occurrences_v1` |
|
||||
| Schema version | `v1` |
|
||||
| Media type | `application/json` |
|
||||
|
||||
`v1` is a strict JSON object with required `interactions`; the array may be
|
||||
empty. Interaction and source-reference objects reject unknown fields. An
|
||||
`v1` is a strict JSON object with required `occurrences`; the array may be
|
||||
empty. Occurrence and source-reference objects reject unknown fields. An
|
||||
incompatible shape change requires a new schema version.
|
||||
|
||||
## Wire shape
|
||||
|
||||
Each interaction has these required fields:
|
||||
Each occurrence has these required fields:
|
||||
|
||||
| Field | Contract |
|
||||
| --- | --- |
|
||||
| `npc_id` | Exact durable ID from the required NPC registry. |
|
||||
| `name` | Non-empty canonical display name from the required NPC registry. |
|
||||
| `kind` | One of the interaction categories below. |
|
||||
| `source_refs` | One or more transcript evidence ranges. |
|
||||
@@ -36,8 +37,9 @@ for an interaction is confined to its accepted chunk.
|
||||
|
||||
```json
|
||||
{
|
||||
"interactions": [
|
||||
"occurrences": [
|
||||
{
|
||||
"npc_id": "npc:sha256:example",
|
||||
"name": "Mira Thorn",
|
||||
"kind": "dialogue",
|
||||
"source_refs": [
|
||||
@@ -65,12 +67,14 @@ for uncertain classification.
|
||||
|
||||
## Identity, evidence, and order
|
||||
|
||||
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves `name`.
|
||||
Registry references are provenance only and never replace an interaction's own
|
||||
evidence. Normalization canonicalizes recognized registry names, orders and
|
||||
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves
|
||||
the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their
|
||||
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
|
||||
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.
|
||||
|
||||
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}]}]}`)
|
||||
case npcinteractions.PromptID:
|
||||
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 {
|
||||
content = []byte(`{"interactions":[]}`)
|
||||
content = []byte(`{"occurrences":[]}`)
|
||||
}
|
||||
case locationoccurrences.PromptID:
|
||||
var registry struct {
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
|
||||
t.Fatalf("resolved pipeline = %#v", resolved)
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
|
||||
@@ -38,7 +38,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.npc_interactions",
|
||||
"$id": "notarius.dnd.npc_occurrences",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["interactions"],
|
||||
"required": ["occurrences"],
|
||||
"properties": {
|
||||
"interactions": {
|
||||
"occurrences": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "kind", "source_refs"],
|
||||
"required": ["npc_id", "name", "kind", "source_refs"],
|
||||
"properties": {
|
||||
"npc_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"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
|
||||
|
||||
import (
|
||||
@@ -12,25 +12,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaID = "notarius.dnd.npc_interactions"
|
||||
SchemaName = "notarius_dnd_npc_interactions_v1"
|
||||
SchemaID = "notarius.dnd.npc_occurrences"
|
||||
SchemaName = "notarius_dnd_npc_occurrences_v1"
|
||||
SchemaVersion = "v1"
|
||||
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 _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil)
|
||||
var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return contracts.ArtifactSchema{}
|
||||
}
|
||||
@@ -44,56 +44,59 @@ func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||
|
||||
func (c *Codec) MediaType() string { return MediaType }
|
||||
|
||||
func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any {
|
||||
return map[string]any{"interaction_count": len(value.Interactions)}
|
||||
func (c *Codec) Metadata(value dnd.NPCOccurrenceList) map[string]any {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// EncodeCandidate provides the durable representation before semantic
|
||||
// validators have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
|
||||
return candidatejson.EncodeCandidate("dnd npc interaction list", value)
|
||||
func (c *Codec) EncodeCandidate(value dnd.NPCOccurrenceList) ([]byte, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return dnd.NPCInteractionList{}, err
|
||||
return dnd.NPCOccurrenceList{}, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
|
||||
return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content)
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCOccurrenceList, error) {
|
||||
return candidatejson.DecodeCandidate[dnd.NPCOccurrenceList]("dnd npc occurrence list", content)
|
||||
}
|
||||
|
||||
func validate(value dnd.NPCInteractionList) error {
|
||||
if value.Interactions == nil {
|
||||
return fmt.Errorf("interactions must be present")
|
||||
func validate(value dnd.NPCOccurrenceList) error {
|
||||
if value.Occurrences == nil {
|
||||
return fmt.Errorf("occurrences must be present")
|
||||
}
|
||||
for index, interaction := range value.Interactions {
|
||||
prefix := fmt.Sprintf("interactions[%d]", index)
|
||||
if strings.TrimSpace(interaction.Name) == "" {
|
||||
for index, occurrence := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
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)
|
||||
}
|
||||
if !validInteractionKind(interaction.Kind) {
|
||||
if !validOccurrenceKind(occurrence.Kind) {
|
||||
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)
|
||||
}
|
||||
for refIndex, ref := range interaction.SourceRefs {
|
||||
for refIndex, ref := range occurrence.SourceRefs {
|
||||
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
|
||||
@@ -109,14 +112,14 @@ func validate(value dnd.NPCInteractionList) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validInteractionKind(value dnd.NPCInteractionKind) bool {
|
||||
func validOccurrenceKind(value dnd.NPCOccurrenceKind) bool {
|
||||
switch value {
|
||||
case dnd.NPCInteractionKindMentioned,
|
||||
dnd.NPCInteractionKindNoncombatPresence,
|
||||
dnd.NPCInteractionKindDialogue,
|
||||
dnd.NPCInteractionKindCombatAlly,
|
||||
dnd.NPCInteractionKindCombatOpponent,
|
||||
dnd.NPCInteractionKindOther:
|
||||
case dnd.NPCOccurrenceKindMentioned,
|
||||
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||
dnd.NPCOccurrenceKindDialogue,
|
||||
dnd.NPCOccurrenceKindCombatAlly,
|
||||
dnd.NPCOccurrenceKindCombatOpponent,
|
||||
dnd.NPCOccurrenceKindOther:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -15,21 +15,21 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func validList() dnd.NPCInteractionList {
|
||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||
func validList() dnd.NPCOccurrenceList {
|
||||
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}},
|
||||
},
|
||||
{
|
||||
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}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||
codec := New()
|
||||
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())
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec, ok := registry.Spec(dnd.NPCInteractionListKind)
|
||||
spec, ok := registry.Spec(dnd.NPCOccurrenceListKind)
|
||||
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||
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")
|
||||
} else {
|
||||
var typeErr *pipeline.ArtifactCodecTypeError
|
||||
@@ -83,17 +83,17 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||
|
||||
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
|
||||
codec := New()
|
||||
empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}
|
||||
empty := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}}
|
||||
content, err := codec.Encode(empty)
|
||||
if err != nil || string(content) != `{"interactions":[]}` {
|
||||
if err != nil || string(content) != `{"occurrences":[]}` {
|
||||
t.Fatalf("Encode() = %s, %v", content, err)
|
||||
}
|
||||
for _, candidate := range []dnd.NPCInteractionList{
|
||||
for _, candidate := range []dnd.NPCOccurrenceList{
|
||||
{},
|
||||
empty,
|
||||
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}},
|
||||
{Interactions: []dnd.NPCInteraction{{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: nil}}},
|
||||
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
|
||||
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
|
||||
} {
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
if err != nil || !json.Valid(content) {
|
||||
@@ -107,13 +107,13 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes
|
||||
}
|
||||
|
||||
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 }{
|
||||
{"malformed", `{`, "decode dnd npc interaction list"},
|
||||
{"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"},
|
||||
{"malformed", `{`, "decode dnd npc occurrence list"},
|
||||
{"unknown top-level", `{"occurrences":[],"unexpected":true}`, "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"},
|
||||
{"trailing", `{"interactions":[]} {}`, "multiple JSON values"},
|
||||
{"trailing", `{"occurrences":[]} {}`, "multiple JSON values"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value dnd.NPCInteractionList
|
||||
value dnd.NPCOccurrenceList
|
||||
want string
|
||||
}{
|
||||
{"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"},
|
||||
{"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"},
|
||||
{"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"},
|
||||
{"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"},
|
||||
{"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
||||
{"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
|
||||
{"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
|
||||
{"nil occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
|
||||
{"blank NPC ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].NPCID = " " }), "npc_id must not be empty"},
|
||||
{"blank name", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Name = " " }), "name must not be empty"},
|
||||
{"unsupported kind", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Kind = "unsupported" }), "kind must be supported"},
|
||||
{"nil source refs", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs = nil }), "source_refs must contain"},
|
||||
{"empty source ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
||||
{"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 {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -147,16 +148,16 @@ func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
|
||||
for _, kind := range []dnd.NPCInteractionKind{
|
||||
dnd.NPCInteractionKindMentioned,
|
||||
dnd.NPCInteractionKindNoncombatPresence,
|
||||
dnd.NPCInteractionKindDialogue,
|
||||
dnd.NPCInteractionKindCombatAlly,
|
||||
dnd.NPCInteractionKindCombatOpponent,
|
||||
dnd.NPCInteractionKindOther,
|
||||
for _, kind := range []dnd.NPCOccurrenceKind{
|
||||
dnd.NPCOccurrenceKindMentioned,
|
||||
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||
dnd.NPCOccurrenceKindDialogue,
|
||||
dnd.NPCOccurrenceKindCombatAlly,
|
||||
dnd.NPCOccurrenceKindCombatOpponent,
|
||||
dnd.NPCOccurrenceKindOther,
|
||||
} {
|
||||
value := validList()
|
||||
value.Interactions[0].Kind = kind
|
||||
value.Occurrences[0].Kind = kind
|
||||
if _, err := New().Encode(value); err != nil {
|
||||
t.Fatalf("Encode(%q) error = %v", kind, err)
|
||||
}
|
||||
@@ -172,12 +173,12 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||
}
|
||||
metadata := codec.Metadata(validList())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList {
|
||||
func mutate(value dnd.NPCOccurrenceList, change func(*dnd.NPCOccurrenceList)) dnd.NPCOccurrenceList {
|
||||
change(&value)
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"interactions": [
|
||||
"occurrences": [
|
||||
{
|
||||
"npc_id": "npc:test-mira",
|
||||
"name": "Mira Thorn",
|
||||
"kind": "dialogue",
|
||||
"source_refs": [
|
||||
@@ -8,6 +9,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"npc_id": "npc:test-guard",
|
||||
"name": "Hooded Guard",
|
||||
"kind": "combat_opponent",
|
||||
"source_refs": [
|
||||
@@ -71,7 +71,7 @@ func referenceSlots() []contracts.ReferenceSlot {
|
||||
Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{interactioncodec.MediaType},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCOccurrenceListKind},
|
||||
MaxBytes: ReferenceMaxBytes,
|
||||
},
|
||||
)
|
||||
@@ -223,7 +223,7 @@ func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.L
|
||||
}
|
||||
content, err := json.Marshal(struct {
|
||||
Interactions []npcInteractionProjection `json:"npc_interactions"`
|
||||
}{Interactions: projectNPCInteractions(value.Interactions)})
|
||||
}{Interactions: projectNPCInteractions(value.Occurrences)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
|
||||
}
|
||||
@@ -272,15 +272,15 @@ func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection {
|
||||
}
|
||||
|
||||
type npcInteractionProjection struct {
|
||||
Name string `json:"name"`
|
||||
Kind dnd.NPCInteractionKind `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Kind dnd.NPCOccurrenceKind `json:"kind"`
|
||||
}
|
||||
|
||||
func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection {
|
||||
projection := make([]npcInteractionProjection, 0, len(interactions))
|
||||
for _, interaction := range interactions {
|
||||
if interaction.Kind == dnd.NPCInteractionKindCombatOpponent {
|
||||
projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind})
|
||||
func projectNPCInteractions(occurrences []dnd.NPCOccurrence) []npcInteractionProjection {
|
||||
projection := make([]npcInteractionProjection, 0, len(occurrences))
|
||||
for _, occurrence := range occurrences {
|
||||
if occurrence.Kind == dnd.NPCOccurrenceKindCombatOpponent {
|
||||
projection = append(projection, npcInteractionProjection{Name: occurrence.Name, Kind: occurrence.Kind})
|
||||
}
|
||||
}
|
||||
return projection
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
|
||||
{NPCRegistryReferenceSlot, dnd.NPCRegistryKind},
|
||||
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
|
||||
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
|
||||
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind},
|
||||
{NPCInteractionReferenceSlot, dnd.NPCOccurrenceListKind},
|
||||
} {
|
||||
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 {
|
||||
@@ -220,9 +220,9 @@ func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) co
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||
{Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{Name: "Aria", Kind: dnd.NPCInteractionKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
|
||||
interactionContent, err := interactioncodec.New().Encode(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||
{NPCID: identity.DeriveID(enemy), Name: enemy, Kind: dnd.NPCOccurrenceKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
type orderedInteractionResponse struct {
|
||||
value interactionResponse
|
||||
type orderedOccurrenceResponse struct {
|
||||
value occurrenceResponse
|
||||
earliest int
|
||||
hasEvidence bool
|
||||
}
|
||||
@@ -18,11 +18,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
ordered := make([]orderedInteractionResponse, len(response.Interactions))
|
||||
for index := range response.Interactions {
|
||||
earliest, hasEvidence := canonicalizeInteraction(&response.Interactions[index], order, sourceID)
|
||||
ordered[index] = orderedInteractionResponse{
|
||||
value: response.Interactions[index],
|
||||
ordered := make([]orderedOccurrenceResponse, len(response.Occurrences))
|
||||
for index := range response.Occurrences {
|
||||
earliest, hasEvidence := canonicalizeOccurrence(&response.Occurrences[index], order, sourceID)
|
||||
ordered[index] = orderedOccurrenceResponse{
|
||||
value: response.Occurrences[index],
|
||||
earliest: earliest,
|
||||
hasEvidence: hasEvidence,
|
||||
}
|
||||
@@ -37,32 +37,33 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
||||
return ordered[i].earliest < ordered[j].earliest
|
||||
})
|
||||
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) {
|
||||
if interaction == nil {
|
||||
func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||
if occurrence == nil {
|
||||
return 0, false
|
||||
}
|
||||
refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID))
|
||||
interaction.SourceRefs = interactionResponseRefs(refs)
|
||||
refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID))
|
||||
occurrence.SourceRefs = occurrenceResponseRefs(refs)
|
||||
return order.EarliestValid(refs)
|
||||
}
|
||||
|
||||
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {
|
||||
interactions := make([]dnd.NPCInteraction, len(response.Interactions))
|
||||
for index, interaction := range response.Interactions {
|
||||
interactions[index] = dnd.NPCInteraction{
|
||||
Name: interaction.Name,
|
||||
Kind: dnd.NPCInteractionKind(interaction.Kind),
|
||||
SourceRefs: canonicalSourceRefs(interaction.SourceRefs, sourceID),
|
||||
func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList {
|
||||
occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
|
||||
for index, occurrence := range response.Occurrences {
|
||||
occurrences[index] = dnd.NPCOccurrence{
|
||||
NPCID: occurrence.NPCID,
|
||||
Name: occurrence.Name,
|
||||
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
|
||||
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
|
||||
}
|
||||
}
|
||||
if response.Interactions == nil {
|
||||
interactions = nil
|
||||
if response.Occurrences == nil {
|
||||
occurrences = nil
|
||||
}
|
||||
return dnd.NPCInteractionList{Interactions: interactions}
|
||||
return dnd.NPCOccurrenceList{Occurrences: occurrences}
|
||||
}
|
||||
|
||||
func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) []source.SourceRef {
|
||||
@@ -76,7 +77,7 @@ func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) [
|
||||
return out
|
||||
}
|
||||
|
||||
func interactionResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse {
|
||||
func occurrenceResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse {
|
||||
if refs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func referenceSlots() []contracts.ReferenceSlot {
|
||||
return slots
|
||||
}
|
||||
|
||||
var _ contracts.Extractor[dnd.NPCInteractionList] = (*Extractor)(nil)
|
||||
var _ contracts.Extractor[dnd.NPCOccurrenceList] = (*Extractor)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
||||
|
||||
@@ -132,33 +132,33 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "prompt", Value: e.promptSHA},
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
{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 {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err)
|
||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||
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() {
|
||||
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
|
||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
|
||||
inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
|
||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key,
|
||||
PromptID: PromptID,
|
||||
@@ -167,10 +167,27 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
SessionID: req.SessionID,
|
||||
Inputs: inputs,
|
||||
}, &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)
|
||||
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 {
|
||||
@@ -180,13 +197,13 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
ExecutionClass: contracts.ExecutionClassLLMBacked,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.NPCInteractionListKind,
|
||||
ArtifactKind: dnd.NPCOccurrenceListKind,
|
||||
ReferenceSlots: referenceSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -17,14 +17,14 @@ import (
|
||||
)
|
||||
|
||||
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
|
||||
{Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
||||
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
|
||||
{Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
|
||||
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
|
||||
{Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
|
||||
{Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
|
||||
{Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
||||
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
|
||||
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
|
||||
{NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
|
||||
{NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
|
||||
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
|
||||
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
|
||||
}}}
|
||||
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
||||
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"}) {
|
||||
t.Fatalf("interaction order = %#v", got)
|
||||
}
|
||||
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCInteractionKind{
|
||||
dnd.NPCInteractionKindMentioned,
|
||||
dnd.NPCInteractionKindDialogue,
|
||||
dnd.NPCInteractionKindNoncombatPresence,
|
||||
dnd.NPCInteractionKindCombatAlly,
|
||||
dnd.NPCInteractionKindCombatOpponent,
|
||||
dnd.NPCInteractionKindOther,
|
||||
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCOccurrenceKind{
|
||||
dnd.NPCOccurrenceKindMentioned,
|
||||
dnd.NPCOccurrenceKindDialogue,
|
||||
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||
dnd.NPCOccurrenceKindCombatAlly,
|
||||
dnd.NPCOccurrenceKindCombatOpponent,
|
||||
dnd.NPCOccurrenceKindOther,
|
||||
"unsupported",
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
@@ -60,15 +60,15 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
|
||||
{Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
|
||||
{Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
|
||||
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
|
||||
{StartUnitID: 10, EndUnitID: 10},
|
||||
{StartUnitID: 30, EndUnitID: 30},
|
||||
{StartUnitID: 30, EndUnitID: 30},
|
||||
{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")
|
||||
req := extractionRequest()
|
||||
@@ -84,12 +84,12 @@ func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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}) {
|
||||
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
||||
}
|
||||
refs[0].StartUnitID = 777
|
||||
for _, interaction := range client.response.Interactions {
|
||||
for _, interaction := range client.response.Occurrences {
|
||||
for _, ref := range interaction.SourceRefs {
|
||||
if ref.StartUnitID == 777 {
|
||||
t.Fatal("result source references alias the model response")
|
||||
@@ -107,9 +107,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{{
|
||||
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
|
||||
func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||
NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
|
||||
}}}}
|
||||
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
||||
req := extractionRequest()
|
||||
@@ -119,10 +119,10 @@ func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T)
|
||||
}
|
||||
request := client.requests[0]
|
||||
registry := request.Inputs[NPCRegistryReferenceSlot]
|
||||
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` {
|
||||
t.Fatalf("registry prompt input = %#v, want exact names-only projection", registry)
|
||||
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 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) {
|
||||
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) {
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
|
||||
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}}
|
||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||
req := extractionRequest()
|
||||
req.References = references
|
||||
@@ -171,7 +192,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
|
||||
if _, err := extractor.Extract(context.Background(), req); err != nil {
|
||||
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)
|
||||
}
|
||||
metadata := extractor.ManifestMetadata()
|
||||
@@ -191,11 +212,11 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
|
||||
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.References = references
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -230,7 +251,7 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
||||
|
||||
func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
var registrySlot contracts.ReferenceSlot
|
||||
@@ -342,17 +363,17 @@ func interactionRefs(start, end int) []interactionSourceRefResponse {
|
||||
return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
|
||||
}
|
||||
|
||||
func interactionNames(value dnd.NPCInteractionList) []string {
|
||||
names := make([]string, len(value.Interactions))
|
||||
for index, interaction := range value.Interactions {
|
||||
func interactionNames(value dnd.NPCOccurrenceList) []string {
|
||||
names := make([]string, len(value.Occurrences))
|
||||
for index, interaction := range value.Occurrences {
|
||||
names[index] = interaction.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
|
||||
kinds := make([]dnd.NPCInteractionKind, len(value.Interactions))
|
||||
for index, interaction := range value.Interactions {
|
||||
func interactionKinds(value dnd.NPCOccurrenceList) []dnd.NPCOccurrenceKind {
|
||||
kinds := make([]dnd.NPCOccurrenceKind, len(value.Occurrences))
|
||||
for index, interaction := range value.Occurrences {
|
||||
kinds[index] = interaction.Kind
|
||||
}
|
||||
return kinds
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package npcinteractions
|
||||
|
||||
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"`
|
||||
Kind string `json:"kind"`
|
||||
SourceRefs []interactionSourceRefResponse `json:"source_refs"`
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
if err := json.Unmarshal(content, &response); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
interaction := response.Interactions[0]
|
||||
if interaction.Name != "" || interaction.Kind != "unsupported" || interaction.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||
t.Fatalf("decoded response = %#v", interaction)
|
||||
occurrence := response.Occurrences[0]
|
||||
if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||
t.Fatalf("decoded response = %#v", occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
options, err := registry.PromptKitOptions()
|
||||
@@ -42,13 +42,13 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
|
||||
"players": promptkit.Inline("interaction-player"),
|
||||
"party": promptkit.Inline("Mira: ranger"),
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestPromptMetadataDoesNotExposeAssetContent(t *testing.T) {
|
||||
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
|
||||
}
|
||||
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) {
|
||||
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 (
|
||||
PromptID = "dnd.npc_interactions"
|
||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_interactions_llm")
|
||||
ResponseSchemaID = "notarius.dnd.npc_interactions.llm"
|
||||
ResponseSchemaName = "notarius_dnd_npc_interactions_llm_v1"
|
||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_occurrences_llm")
|
||||
ResponseSchemaID = "notarius.dnd.npc_occurrences.llm"
|
||||
ResponseSchemaName = "notarius_dnd_npc_occurrences_llm_v1"
|
||||
SchemaVersion = "v1"
|
||||
)
|
||||
|
||||
@@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) {
|
||||
ID: ResponseSchemaID,
|
||||
Version: SchemaVersion,
|
||||
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()
|
||||
interaction := semanticCandidate["interactions"].([]any)[0].(map[string]any)
|
||||
interaction := semanticCandidate["occurrences"].([]any)[0].(map[string]any)
|
||||
interaction["name"] = ""
|
||||
interaction["kind"] = "unsupported"
|
||||
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){
|
||||
func(record map[string]any) { delete(record, "npc_id") },
|
||||
func(record map[string]any) { delete(record, "name") },
|
||||
func(record map[string]any) { record["kind"] = 1 },
|
||||
func(record map[string]any) { record["unexpected"] = true },
|
||||
@@ -50,7 +51,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
candidate := validInteractionResponse()
|
||||
mutate(candidate["interactions"].([]any)[0].(map[string]any))
|
||||
mutate(candidate["occurrences"].([]any)[0].(map[string]any))
|
||||
content, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -79,8 +80,8 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
|
||||
}
|
||||
|
||||
func validInteractionResponse() map[string]any {
|
||||
return map[string]any{"interactions": []any{map[string]any{
|
||||
"name": "Mira Thorn", "kind": "dialogue",
|
||||
return map[string]any{"occurrences": []any{map[string]any{
|
||||
"npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue",
|
||||
"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/modules/dnd"
|
||||
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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
@@ -22,7 +21,6 @@ const (
|
||||
normalizationPolicy = "dnd.npc_interactions.normalize.v2"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized"
|
||||
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||
ReasonCodeInteractionsReordered = "npc_interactions_reordered"
|
||||
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.",
|
||||
}
|
||||
|
||||
var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil)
|
||||
var _ contracts.Normalizer[dnd.NPCOccurrenceList] = (*Normalizer)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||
|
||||
@@ -79,7 +77,6 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"normalization_policy": normalizationPolicy,
|
||||
"identity_policy": identity.Policy,
|
||||
}
|
||||
seeded := n.npcResolver.Seeded()
|
||||
if seeded.Bound() {
|
||||
@@ -95,82 +92,74 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
{Name: "identity_policy", Value: identity.Policy},
|
||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()},
|
||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
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() {
|
||||
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)
|
||||
order := shared.NewSourceRefOrderFromIndex(index)
|
||||
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry)
|
||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
|
||||
value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry)
|
||||
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 {
|
||||
interaction dnd.NPCInteraction
|
||||
inputIndex int
|
||||
occurrence dnd.NPCOccurrence
|
||||
inputIndex int
|
||||
}
|
||||
|
||||
type nameCanonicalization struct {
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
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
|
||||
func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []contracts.Warning, error) {
|
||||
if input.Occurrences == nil {
|
||||
return dnd.NPCOccurrenceList{}, nil, nil
|
||||
}
|
||||
|
||||
records := make([]normalizedRecord, len(input.Interactions))
|
||||
records := make([]normalizedRecord, len(input.Occurrences))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputInteraction := range input.Interactions {
|
||||
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry)
|
||||
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
|
||||
if nameChange != nil {
|
||||
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)),
|
||||
})
|
||||
for index, inputOccurrence := range input.Occurrences {
|
||||
occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry)
|
||||
if err != nil {
|
||||
return dnd.NPCOccurrenceList{}, nil, fmt.Errorf("occurrences[%d]: %w", index, err)
|
||||
}
|
||||
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
|
||||
if refsChanged {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: interactionScope(index),
|
||||
Scope: occurrenceScope(index),
|
||||
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||
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 {
|
||||
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 {
|
||||
if position == record.inputIndex {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: interactionScope(record.inputIndex),
|
||||
Scope: occurrenceScope(record.inputIndex),
|
||||
ReasonCode: ReasonCodeInteractionsReordered,
|
||||
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)
|
||||
warnings = append(warnings, duplicateWarnings...)
|
||||
return dnd.NPCInteractionList{Interactions: output},
|
||||
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
|
||||
return dnd.NPCOccurrenceList{Occurrences: output},
|
||||
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted), nil
|
||||
}
|
||||
|
||||
func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
|
||||
output := cloneInteraction(input)
|
||||
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
|
||||
output.Name = canonical.Name
|
||||
func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) {
|
||||
canonical, ok := registry.LookupID(input.NPCID)
|
||||
if !ok {
|
||||
return dnd.NPCOccurrence{}, false, fmt.Errorf("npc_id is not in the NPC registry")
|
||||
}
|
||||
var nameChange *nameCanonicalization
|
||||
if input.Name != output.Name {
|
||||
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
|
||||
if input.Name != canonical.Name {
|
||||
return dnd.NPCOccurrence{}, false, fmt.Errorf("name does not match npc_id")
|
||||
}
|
||||
output := cloneOccurrence(input)
|
||||
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
|
||||
if input.SourceRefs != nil {
|
||||
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
|
||||
@@ -208,19 +197,19 @@ type duplicateGroup struct {
|
||||
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 {
|
||||
return make([]dnd.NPCInteraction, 0), nil
|
||||
return make([]dnd.NPCOccurrence, 0), nil
|
||||
}
|
||||
keep := make([]bool, len(records))
|
||||
groups := make([]duplicateGroup, 0)
|
||||
groupByKey := make(map[string]int)
|
||||
for index, record := range records {
|
||||
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
|
||||
if !interactionmodel.ValidSourceRefs(documentIndex, record.occurrence.SourceRefs) {
|
||||
keep[index] = true
|
||||
continue
|
||||
}
|
||||
key := interactionmodel.ExactIdentity(record.interaction)
|
||||
key := interactionmodel.ExactIdentity(record.occurrence)
|
||||
groupIndex, exists := groupByKey[key]
|
||||
if !exists {
|
||||
groupByKey[key] = len(groups)
|
||||
@@ -230,10 +219,10 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen
|
||||
}
|
||||
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 {
|
||||
if keep[index] {
|
||||
output = append(output, cloneInteraction(record.interaction))
|
||||
output = append(output, cloneOccurrence(record.occurrence))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
return contracts.Warning{
|
||||
Scope: interactionScope(retainedIndex),
|
||||
Scope: occurrenceScope(retainedIndex),
|
||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||
Message: diagnostics.Aggregate(
|
||||
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 {
|
||||
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
@@ -281,13 +270,13 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.NPCInteractionListKind,
|
||||
ArtifactKind: dnd.NPCOccurrenceListKind,
|
||||
ReferenceSlots: referenceSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -14,37 +14,37 @@ import (
|
||||
"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}}}
|
||||
normalizer, err := New(Options{}, npcReferences(t))
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
||||
Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue,
|
||||
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||
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}},
|
||||
}}}
|
||||
original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...)
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
|
||||
original := append([]source.SourceRef(nil), input.Occurrences[0].SourceRefs...)
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
got := result.Value.Interactions[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}}) {
|
||||
got := result.Value.Occurrences[0]
|
||||
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)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
||||
if !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err)
|
||||
}
|
||||
result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999
|
||||
if input.Interactions[0].SourceRefs[0].StartUnitID == 999 {
|
||||
result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||
if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 {
|
||||
t.Fatal("normalized source refs share input storage")
|
||||
}
|
||||
}
|
||||
@@ -54,14 +54,14 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} {
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{
|
||||
MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t),
|
||||
for _, input := range []dnd.NPCOccurrenceList{{}, {Occurrences: []dnd.NPCOccurrence{}}} {
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -70,16 +70,20 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) {
|
||||
func TestNormalizeRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
|
||||
doc := testDocument()
|
||||
normalizer, err := New(Options{}, npcReferences(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}}
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
|
||||
if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) {
|
||||
t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err)
|
||||
for _, occurrence := range []dnd.NPCOccurrence{
|
||||
interaction("Unknown NPC", dnd.NPCOccurrenceKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
|
||||
{NPCID: identity.DeriveID("Ária"), Name: "Borin", Kind: dnd.NPCOccurrenceKindOther, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
||||
} {
|
||||
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 {
|
||||
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
|
||||
}
|
||||
first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50))
|
||||
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||
interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)),
|
||||
first := interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(50))
|
||||
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||
interaction("Borin", dnd.NPCOccurrenceKindMentioned, ref(90)),
|
||||
first,
|
||||
first,
|
||||
interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)),
|
||||
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)),
|
||||
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)),
|
||||
interaction("Ária", dnd.NPCOccurrenceKindCombatAlly, ref(50)),
|
||||
interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(10)),
|
||||
interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(999)),
|
||||
}}
|
||||
normalizer, err := New(Options{}, npcReferences(t))
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := result.Value.Interactions
|
||||
got := result.Value.Occurrences
|
||||
if len(got) != 5 {
|
||||
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)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
||||
@@ -122,13 +126,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
@@ -139,13 +143,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
|
||||
func TestNormalizeBoundsWarnings(t *testing.T) {
|
||||
count := diagnostics.MaxWarnings + 5
|
||||
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 {
|
||||
doc.Units[index].ID = index + 1
|
||||
unitID := count - index
|
||||
input.Interactions[index] = interaction(
|
||||
input.Occurrences[index] = interaction(
|
||||
"Ária",
|
||||
dnd.NPCInteractionKindDialogue,
|
||||
dnd.NPCOccurrenceKindDialogue,
|
||||
source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID},
|
||||
)
|
||||
}
|
||||
@@ -153,8 +157,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
@@ -165,8 +169,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction {
|
||||
return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
|
||||
func interaction(name string, kind dnd.NPCOccurrenceKind, ref source.SourceRef) dnd.NPCOccurrence {
|
||||
return dnd.NPCOccurrence{NPCID: identity.DeriveID(name), Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
|
||||
}
|
||||
|
||||
func testDocument() *source.SourceDocument {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
@@ -26,8 +25,8 @@ func SourceRefsEqual(left, right []source.SourceRef) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Less defines the canonical order for NPC interaction occurrences.
|
||||
func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
|
||||
// Less defines the canonical order for NPC occurrences.
|
||||
func Less(order shared.SourceRefOrder, left, right dnd.NPCOccurrence) bool {
|
||||
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
||||
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
||||
if leftHasEvidence != rightHasEvidence {
|
||||
@@ -36,8 +35,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
|
||||
if leftHasEvidence && leftPosition != rightPosition {
|
||||
return leftPosition < rightPosition
|
||||
}
|
||||
leftKey := identity.ComparisonKey(left.Name)
|
||||
rightKey := identity.ComparisonKey(right.Name)
|
||||
leftKey := left.NPCID
|
||||
rightKey := right.NPCID
|
||||
if 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)
|
||||
}
|
||||
|
||||
// ValidSourceRefs reports whether an interaction has non-empty, valid
|
||||
// ValidSourceRefs reports whether an occurrence has non-empty, valid
|
||||
// current-document evidence.
|
||||
func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
||||
if len(refs) == 0 {
|
||||
@@ -64,13 +63,14 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
||||
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.
|
||||
func ExactIdentity(interaction dnd.NPCInteraction) string {
|
||||
func ExactIdentity(occurrence dnd.NPCOccurrence) string {
|
||||
var key strings.Builder
|
||||
writeKeyString(&key, interaction.Name)
|
||||
writeKeyString(&key, string(interaction.Kind))
|
||||
for _, ref := range interaction.SourceRefs {
|
||||
writeKeyString(&key, occurrence.NPCID)
|
||||
writeKeyString(&key, occurrence.Name)
|
||||
writeKeyString(&key, string(occurrence.Kind))
|
||||
for _, ref := range occurrence.SourceRefs {
|
||||
writeKeyString(&key, ref.SourceID)
|
||||
writeKeyInt(&key, ref.StartUnitID)
|
||||
writeKeyInt(&key, ref.EndUnitID)
|
||||
|
||||
@@ -32,7 +32,9 @@ type Registry struct {
|
||||
digest string
|
||||
projectionDigest string
|
||||
promptInput contracts.LLMInputMaterial
|
||||
identityInput contracts.LLMInputMaterial
|
||||
lookupByKey map[string]int
|
||||
lookupByID map[string]int
|
||||
}
|
||||
|
||||
// Resolver selects and memoizes immutable NPC registry views.
|
||||
@@ -109,7 +111,9 @@ func emptyRegistry() *Registry {
|
||||
canonical: append([]byte(nil), content...),
|
||||
projectionDigest: projectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||
lookupByKey: map[string]int{},
|
||||
lookupByID: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +133,10 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
||||
|
||||
list := cloneNPCRegistry(value)
|
||||
lookupByKey := make(map[string]int, len(list.NPCs))
|
||||
lookupByID := make(map[string]int, len(list.NPCs))
|
||||
for index, npc := range list.NPCs {
|
||||
lookupByKey[identity.ComparisonKey(npc.Name)] = index
|
||||
lookupByID[npc.ID] = index
|
||||
}
|
||||
digest := semanticDigest(content)
|
||||
projection, err := nameProjection(list)
|
||||
@@ -138,6 +144,11 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
||||
return nil, fmt.Errorf("encode NPC name projection: %w", err)
|
||||
}
|
||||
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{
|
||||
bound: true,
|
||||
list: list,
|
||||
@@ -146,6 +157,8 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
||||
projectionDigest: projectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
||||
lookupByKey: lookupByKey,
|
||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
|
||||
lookupByID: lookupByID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -211,6 +224,15 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
||||
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
|
||||
// NPC identity comparison policy.
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
sum := sha256.Sum256(content)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
@@ -233,10 +267,19 @@ type projectedNPC struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type identityProjectedNPC struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type projectedNPCRegistry struct {
|
||||
NPCs []projectedNPC `json:"npcs"`
|
||||
}
|
||||
|
||||
type identityProjectedNPCRegistry struct {
|
||||
NPCs []identityProjectedNPC `json:"npcs"`
|
||||
}
|
||||
|
||||
func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
|
||||
projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))}
|
||||
for index, npc := range list.NPCs {
|
||||
@@ -245,6 +288,14 @@ func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
|
||||
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 {
|
||||
parts := make([]string, len(issues))
|
||||
for index, issue := range issues {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
base := registryFixture()
|
||||
evidenceChanged := registryFixture()
|
||||
|
||||
@@ -20,7 +20,7 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
||||
}},
|
||||
{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 {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence)
|
||||
@@ -74,9 +74,9 @@ func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
|
||||
return append([]source.SourceRef(nil), refs...)
|
||||
}
|
||||
|
||||
func npcInteractionEvidence(value dnd.NPCInteractionList) []source.SourceRef {
|
||||
func npcInteractionEvidence(value dnd.NPCOccurrenceList) []source.SourceRef {
|
||||
var refs []source.SourceRef
|
||||
for _, record := range value.Interactions {
|
||||
for _, record := range value.Occurrences {
|
||||
refs = append(refs, record.SourceRefs...)
|
||||
}
|
||||
return append([]source.SourceRef(nil), refs...)
|
||||
|
||||
@@ -112,22 +112,22 @@ func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error)
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) {
|
||||
func appendNPCInteractionLists(values []dnd.NPCOccurrenceList) (dnd.NPCOccurrenceList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.Interactions != nil {
|
||||
if value.Occurrences != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.Interactions)
|
||||
count += len(value.Occurrences)
|
||||
}
|
||||
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 _, interaction := range value.Interactions {
|
||||
combined.Interactions = append(combined.Interactions, cloneNPCInteraction(interaction))
|
||||
for _, occurrence := range value.Occurrences {
|
||||
combined.Occurrences = append(combined.Occurrences, cloneNPCOccurrence(occurrence))
|
||||
}
|
||||
}
|
||||
return combined, nil
|
||||
@@ -228,7 +228,7 @@ func cloneNPC(value dnd.NPC) dnd.NPC {
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
|
||||
func cloneNPCOccurrence(value dnd.NPCOccurrence) dnd.NPCOccurrence {
|
||||
clone := value
|
||||
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
|
||||
return clone
|
||||
|
||||
@@ -79,7 +79,7 @@ func registerModules(registries pipeline.Registries) error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
|
||||
}},
|
||||
{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 {
|
||||
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)
|
||||
}},
|
||||
{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 {
|
||||
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, "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})
|
||||
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.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.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.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.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.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.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.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
|
||||
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(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind})
|
||||
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
|
||||
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",
|
||||
@@ -302,7 +302,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
"dnd_combat_turns_llm.v1.json",
|
||||
"dnd_enemy_events_llm.v1.json",
|
||||
"dnd_item_events_llm.v1.json",
|
||||
"dnd_npc_interactions_llm.v1.json",
|
||||
"dnd_npc_occurrences_llm.v1.json",
|
||||
"dnd_scene_descriptions_llm.v1.json",
|
||||
"dnd_locations_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)
|
||||
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)
|
||||
}
|
||||
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}}}})
|
||||
}, want: []source.SourceRef{first, second}},
|
||||
{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}},
|
||||
{name: "scene descriptions", project: func() []source.SourceRef {
|
||||
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) {
|
||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
input := []dnd.NPCInteractionList{
|
||||
input := []dnd.NPCOccurrenceList{
|
||||
{},
|
||||
{Interactions: []dnd.NPCInteraction{}},
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}},
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
|
||||
{Occurrences: []dnd.NPCOccurrence{}},
|
||||
{Occurrences: []dnd.NPCOccurrence{{Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: refs}}},
|
||||
{Occurrences: []dnd.NPCOccurrence{{Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
|
||||
}
|
||||
got, err := appendNPCInteractionLists(input)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
got.Interactions[0].SourceRefs[0].StartUnitID = 999
|
||||
if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 {
|
||||
got.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||
if input[2].Occurrences[0].SourceRefs[0].StartUnitID == 999 {
|
||||
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)
|
||||
if err != nil || result.Interactions != nil {
|
||||
if err != nil || result.Occurrences != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}})
|
||||
if err != nil || interactions.Interactions[0].SourceRefs == nil {
|
||||
interactions, err := appendNPCInteractionLists([]dnd.NPCOccurrenceList{{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{}}}}})
|
||||
if err != nil || interactions.Occurrences[0].SourceRefs == nil {
|
||||
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)
|
||||
}},
|
||||
{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 {
|
||||
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 {
|
||||
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 NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
|
||||
const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list"
|
||||
|
||||
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
||||
|
||||
@@ -64,24 +64,25 @@ type CombatTurn struct {
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type NPCInteractionKind string
|
||||
type NPCOccurrenceKind string
|
||||
|
||||
const (
|
||||
NPCInteractionKindMentioned NPCInteractionKind = "mentioned"
|
||||
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence"
|
||||
NPCInteractionKindDialogue NPCInteractionKind = "dialogue"
|
||||
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally"
|
||||
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent"
|
||||
NPCInteractionKindOther NPCInteractionKind = "other"
|
||||
NPCOccurrenceKindMentioned NPCOccurrenceKind = "mentioned"
|
||||
NPCOccurrenceKindNoncombatPresence NPCOccurrenceKind = "noncombat_presence"
|
||||
NPCOccurrenceKindDialogue NPCOccurrenceKind = "dialogue"
|
||||
NPCOccurrenceKindCombatAlly NPCOccurrenceKind = "combat_ally"
|
||||
NPCOccurrenceKindCombatOpponent NPCOccurrenceKind = "combat_opponent"
|
||||
NPCOccurrenceKindOther NPCOccurrenceKind = "other"
|
||||
)
|
||||
|
||||
type NPCInteractionList struct {
|
||||
Interactions []NPCInteraction `json:"interactions"`
|
||||
type NPCOccurrenceList struct {
|
||||
Occurrences []NPCOccurrence `json:"occurrences"`
|
||||
}
|
||||
|
||||
type NPCInteraction struct {
|
||||
type NPCOccurrence struct {
|
||||
NPCID string `json:"npc_id"`
|
||||
Name string `json:"name"`
|
||||
Kind NPCInteractionKind `json:"kind"`
|
||||
Kind NPCOccurrenceKind `json:"kind"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ type Validator struct {
|
||||
npcResolver *npcregistry.Resolver
|
||||
}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
@@ -72,11 +72,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{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 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -105,25 +105,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}, nil
|
||||
}
|
||||
|
||||
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool {
|
||||
for _, interaction := range value.Interactions {
|
||||
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
|
||||
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCOccurrenceList) bool {
|
||||
for _, occurrence := range value.Occurrences {
|
||||
if !interactionmodel.ValidSourceRefs(index, occurrence.SourceRefs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
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)
|
||||
for index, interaction := range value.Interactions {
|
||||
prefix := fmt.Sprintf("interactions[%d]", index)
|
||||
if canonical, ok := npcRegistry.Lookup(interaction.Name); ok && interaction.Name != canonical.Name {
|
||||
issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(interaction.Name))
|
||||
for index, occurrence := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
|
||||
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++ {
|
||||
previous := interaction.SourceRefs[refIndex-1]
|
||||
current := interaction.SourceRefs[refIndex]
|
||||
for refIndex := 1; refIndex < len(occurrence.SourceRefs); refIndex++ {
|
||||
previous := occurrence.SourceRefs[refIndex-1]
|
||||
current := occurrence.SourceRefs[refIndex]
|
||||
if order.Less(current, previous) {
|
||||
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
||||
} else if current == previous {
|
||||
@@ -132,16 +135,16 @@ func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcReg
|
||||
}
|
||||
}
|
||||
|
||||
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool {
|
||||
return interactionmodel.Less(order, value.Interactions[left], value.Interactions[right])
|
||||
if !sort.SliceIsSorted(value.Occurrences, func(left, right int) bool {
|
||||
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)
|
||||
for index, interaction := range value.Interactions {
|
||||
key := interactionmodel.ExactIdentity(interaction)
|
||||
for index, occurrence := range value.Occurrences {
|
||||
key := interactionmodel.ExactIdentity(occurrence)
|
||||
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
|
||||
}
|
||||
seen[key] = index
|
||||
@@ -154,7 +157,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -28,26 +28,26 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(
|
||||
references := registryReferences(t, "Aria", "Borin")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*dnd.NPCInteractionList)
|
||||
mutate func(*dnd.NPCOccurrenceList)
|
||||
want string
|
||||
}{
|
||||
{"canonical name", func(value *dnd.NPCInteractionList) { value.Interactions[0].Name = " aria " }, "canonical NPC display name"},
|
||||
{"reference order", func(value *dnd.NPCInteractionList) {
|
||||
value.Interactions[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
{"mismatched name", func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].Name = " aria " }, "does not match npc_id"},
|
||||
{"reference order", func(value *dnd.NPCOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
}, "not in canonical order"},
|
||||
{"duplicate reference", func(value *dnd.NPCInteractionList) {
|
||||
value.Interactions[0].SourceRefs = append(value.Interactions[0].SourceRefs, value.Interactions[0].SourceRefs[0])
|
||||
{"duplicate reference", func(value *dnd.NPCOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
|
||||
}, "duplicates the previous reference"},
|
||||
{"list order", func(value *dnd.NPCInteractionList) {
|
||||
value.Interactions[0], value.Interactions[1] = value.Interactions[1], value.Interactions[0]
|
||||
}, "interactions are not in canonical order"},
|
||||
{"name tie breaker", func(value *dnd.NPCInteractionList) {
|
||||
value.Interactions[0] = dnd.NPCInteraction{Name: "Borin", Kind: dnd.NPCInteractionKindDialogue, 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}}}
|
||||
}, "interactions are not in canonical order"},
|
||||
{"duplicate record", func(value *dnd.NPCInteractionList) {
|
||||
value.Interactions = append(value.Interactions, value.Interactions[0])
|
||||
}, "duplicates interaction"},
|
||||
{"list order", func(value *dnd.NPCOccurrenceList) {
|
||||
value.Occurrences[0], value.Occurrences[1] = value.Occurrences[1], value.Occurrences[0]
|
||||
}, "occurrences are not in canonical order"},
|
||||
{"NPC ID tie breaker", func(value *dnd.NPCOccurrenceList) {
|
||||
value.Occurrences[0] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, 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}}}
|
||||
}, "occurrences are not in canonical order"},
|
||||
{"duplicate record", func(value *dnd.NPCOccurrenceList) {
|
||||
value.Occurrences = append(value.Occurrences, value.Occurrences[0])
|
||||
}, "duplicates occurrence"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
value := normalizedList()
|
||||
@@ -62,9 +62,9 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(
|
||||
|
||||
func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
|
||||
references := registryReferences(t, "Aria", "Borin")
|
||||
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
|
||||
value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||
{NPCID: "npc:test", Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
{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))
|
||||
if err != nil || !result.Approved {
|
||||
@@ -74,9 +74,9 @@ func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
|
||||
|
||||
func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) {
|
||||
references := registryReferences(t, "Aria", "Borin")
|
||||
for _, value := range []dnd.NPCInteractionList{
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Aria"}}},
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
|
||||
for _, value := range []dnd.NPCOccurrenceList{
|
||||
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria"}}},
|
||||
{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))
|
||||
if err != nil || !result.Approved {
|
||||
@@ -126,26 +126,26 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
|
||||
return validator
|
||||
}
|
||||
|
||||
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
|
||||
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: document(), References: references, Value: value}
|
||||
func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
|
||||
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: document(), References: references, Value: value}
|
||||
}
|
||||
|
||||
func document() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
|
||||
}
|
||||
|
||||
func normalizedList() dnd.NPCInteractionList {
|
||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
func normalizedList() dnd.NPCOccurrenceList {
|
||||
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||
{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
{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 {
|
||||
copyValue := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, len(value.Interactions))}
|
||||
for index, interaction := range value.Interactions {
|
||||
copyValue.Interactions[index] = interaction
|
||||
copyValue.Interactions[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...)
|
||||
func cloneList(value dnd.NPCOccurrenceList) dnd.NPCOccurrenceList {
|
||||
copyValue := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, len(value.Occurrences))}
|
||||
for index, interaction := range value.Occurrences {
|
||||
copyValue.Occurrences[index] = interaction
|
||||
copyValue.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...)
|
||||
}
|
||||
return copyValue
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type Validator struct {
|
||||
npcResolver *npcregistry.Resolver
|
||||
}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
@@ -68,11 +68,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{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 {
|
||||
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
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, interaction := range req.Value.Interactions {
|
||||
if _, ok := npcRegistry.Lookup(interaction.Name); !ok {
|
||||
issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name)))
|
||||
for index, occurrence := range req.Value.Occurrences {
|
||||
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
|
||||
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 {
|
||||
@@ -111,7 +116,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -16,19 +16,25 @@ import (
|
||||
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")
|
||||
validator := newValidator(t, references)
|
||||
value := validList(" mira thorn ")
|
||||
value := validList("Mira Thorn")
|
||||
result, err := validator.Validate(context.Background(), request(references, value))
|
||||
if err != nil || !result.Approved {
|
||||
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))
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
@@ -45,7 +51,7 @@ func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(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 {
|
||||
t.Fatalf("empty registry result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -78,7 +84,7 @@ func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t *
|
||||
func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) {
|
||||
references := registryReferences(t, "Mira Thorn")
|
||||
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))
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("shape deferral = %#v, %v", result, err)
|
||||
@@ -107,13 +113,13 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
|
||||
return validator
|
||||
}
|
||||
|
||||
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
|
||||
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value}
|
||||
func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
|
||||
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{References: references, Value: value}
|
||||
}
|
||||
|
||||
func validList(name string) dnd.NPCInteractionList {
|
||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
||||
Name: name, Kind: dnd.NPCInteractionKindDialogue,
|
||||
func validList(name string) dnd.NPCOccurrenceList {
|
||||
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||
NPCID: identity.DeriveID(name), Name: name, Kind: dnd.NPCOccurrenceKindDialogue,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -33,14 +33,14 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
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 {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.NPCInteractionList) error {
|
||||
func Validate(value dnd.NPCOccurrenceList) error {
|
||||
issues := issuesFor(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
@@ -48,34 +48,37 @@ func Validate(value dnd.NPCInteractionList) error {
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.NPCInteractionList) []string {
|
||||
if value.Interactions == nil {
|
||||
return []string{"interactions must be present"}
|
||||
func issuesFor(value dnd.NPCOccurrenceList) []string {
|
||||
if value.Occurrences == nil {
|
||||
return []string{"occurrences must be present"}
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, interaction := range value.Interactions {
|
||||
prefix := fmt.Sprintf("interactions[%d]", index)
|
||||
if strings.TrimSpace(interaction.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(interaction.Name))
|
||||
for index, occurrence := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
if strings.TrimSpace(occurrence.NPCID) == "" {
|
||||
issues = append(issues, prefix+".npc_id must not be empty: "+diagnostics.Quote(occurrence.NPCID))
|
||||
}
|
||||
if !validKind(interaction.Kind) {
|
||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(interaction.Kind)))
|
||||
if strings.TrimSpace(occurrence.Name) == "" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func validKind(value dnd.NPCInteractionKind) bool {
|
||||
func validKind(value dnd.NPCOccurrenceKind) bool {
|
||||
switch value {
|
||||
case dnd.NPCInteractionKindMentioned,
|
||||
dnd.NPCInteractionKindNoncombatPresence,
|
||||
dnd.NPCInteractionKindDialogue,
|
||||
dnd.NPCInteractionKindCombatAlly,
|
||||
dnd.NPCInteractionKindCombatOpponent,
|
||||
dnd.NPCInteractionKindOther:
|
||||
case dnd.NPCOccurrenceKindMentioned,
|
||||
dnd.NPCOccurrenceKindNoncombatPresence,
|
||||
dnd.NPCOccurrenceKindDialogue,
|
||||
dnd.NPCOccurrenceKindCombatAlly,
|
||||
dnd.NPCOccurrenceKindCombatOpponent,
|
||||
dnd.NPCOccurrenceKindOther:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -87,7 +90,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,22 +15,22 @@ import (
|
||||
|
||||
func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value dnd.NPCInteractionList
|
||||
value dnd.NPCOccurrenceList
|
||||
want string
|
||||
}{
|
||||
{"missing interactions", dnd.NPCInteractionList{}, "interactions must be present"},
|
||||
{"blank name", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: valid.Interactions[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"},
|
||||
{"missing evidence", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue}}}, "source_refs must contain"},
|
||||
{"missing occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
|
||||
{"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.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Occurrences[0].SourceRefs}}}, "kind is unsupported"},
|
||||
{"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) {
|
||||
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) {
|
||||
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) {
|
||||
value := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 24)}
|
||||
for index := range value.Interactions {
|
||||
value.Interactions[index] = dnd.NPCInteraction{Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"}
|
||||
value := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 24)}
|
||||
for index := range value.Occurrences {
|
||||
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") {
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
||||
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
|
||||
func validList() dnd.NPCOccurrenceList {
|
||||
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||
NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
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 {
|
||||
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)
|
||||
issues := make([]string, 0)
|
||||
for interactionIndex, interaction := range req.Value.Interactions {
|
||||
for refIndex, ref := range interaction.SourceRefs {
|
||||
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||
for refIndex, ref := range occurrence.SourceRefs {
|
||||
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
|
||||
}
|
||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
||||
issues = append(issues, fmt.Sprintf(
|
||||
"interactions[%d].source_refs[%d]: source reference is outside the current extraction chunk",
|
||||
interactionIndex, refIndex,
|
||||
"occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk",
|
||||
occurrenceIndex, refIndex,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -18,13 +18,13 @@ func TestValidatorOwnsCurrentSourceUnitAndRangeValidation(t *testing.T) {
|
||||
if err != nil || !result.Approved {
|
||||
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: "session", StartUnitID: 99, EndUnitID: 99},
|
||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
|
||||
Units: append([]source.SourceUnit(nil), doc.Units[:2]...),
|
||||
}
|
||||
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),
|
||||
Source: doc,
|
||||
Chunk: chunk,
|
||||
@@ -47,10 +47,10 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
|
||||
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,
|
||||
}}
|
||||
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),
|
||||
Source: doc,
|
||||
Chunk: chunk,
|
||||
@@ -62,7 +62,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *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),
|
||||
Source: document(),
|
||||
Value: validList(),
|
||||
@@ -73,7 +73,7 @@ func TestValidatorRequiresChunkDuringExtractValidation(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))
|
||||
if err != nil || !result.Approved {
|
||||
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] {
|
||||
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, Value: value}
|
||||
func request(doc *source.SourceDocument, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
|
||||
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: doc, Value: value}
|
||||
}
|
||||
|
||||
func document() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
|
||||
}
|
||||
|
||||
func validList() dnd.NPCInteractionList {
|
||||
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
|
||||
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
|
||||
func validList() dnd.NPCOccurrenceList {
|
||||
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
|
||||
NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -35,7 +35,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
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 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -43,23 +43,23 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
citedTexts := make([]string, len(req.Value.Interactions))
|
||||
for index, interaction := range req.Value.Interactions {
|
||||
citedText, err := resolver.CitedText(interaction.SourceRefs)
|
||||
citedTexts := make([]string, len(req.Value.Occurrences))
|
||||
for index, occurrence := range req.Value.Occurrences {
|
||||
citedText, err := resolver.CitedText(occurrence.SourceRefs)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
citedTexts[index] = citedText
|
||||
}
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, interaction := range req.Value.Interactions {
|
||||
if shared.ContainsTokenSequence(citedTexts[index], interaction.Name) {
|
||||
for index, occurrence := range req.Value.Occurrences {
|
||||
if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: fmt.Sprintf("interactions[%d]", index),
|
||||
Scope: fmt.Sprintf("occurrences[%d]", index),
|
||||
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{
|
||||
@@ -73,7 +73,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -14,34 +14,34 @@ import (
|
||||
)
|
||||
|
||||
func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) {
|
||||
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
|
||||
{Name: "O'Rin Thorn", Kind: dnd.NPCInteractionKindDialogue, 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}}},
|
||||
value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
|
||||
{NPCID: "npc:test", Name: "O'Rin Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{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."}}}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
||||
for _, value := range []dnd.NPCInteractionList{
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}},
|
||||
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
|
||||
for _, value := range []dnd.NPCOccurrenceList{
|
||||
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}},
|
||||
{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 {
|
||||
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
|
||||
_, 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) {
|
||||
t.Fatalf("Validate() mutated value: %#v", value)
|
||||
}
|
||||
@@ -53,17 +53,18 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
||||
|
||||
func TestValidatorBoundsWarnings(t *testing.T) {
|
||||
count := diagnostics.MaxWarnings + 5
|
||||
interactions := make([]dnd.NPCInteraction, count)
|
||||
interactions := make([]dnd.NPCOccurrence, count)
|
||||
for index := range interactions {
|
||||
interactions[index] = dnd.NPCInteraction{
|
||||
interactions[index] = dnd.NPCOccurrence{
|
||||
NPCID: "npc:test",
|
||||
Name: "Missing NPC",
|
||||
Kind: dnd.NPCInteractionKindMentioned,
|
||||
Kind: dnd.NPCOccurrenceKindMentioned,
|
||||
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."}}},
|
||||
Value: dnd.NPCInteractionList{Interactions: interactions},
|
||||
Value: dnd.NPCOccurrenceList{Occurrences: interactions},
|
||||
})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("interaction registry input = %s, want names-only projection %s", got, wantRegistry)
|
||||
}
|
||||
@@ -52,25 +52,25 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
t.Fatalf("interactions = %#v, want canonical names, kinds, and source chronology", interactions)
|
||||
}
|
||||
assertInteractionEvidence(t, first.SourceRefs)
|
||||
assertInteractionEvidence(t, second.SourceRefs)
|
||||
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
|
||||
if err := json.Unmarshal(serialized.Artifact.Content, &durable); err != nil {
|
||||
t.Fatalf("unmarshal durable interaction payload: %v", err)
|
||||
}
|
||||
if len(durable) != 1 || durable["interactions"] == nil {
|
||||
t.Fatalf("durable interaction payload = %#v, want only interactions", durable)
|
||||
if len(durable) != 1 || durable["occurrences"] == nil {
|
||||
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)
|
||||
}
|
||||
registryRequest := client.requestFor(t, interactionextract.PromptID)
|
||||
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
|
||||
t.Fatalf("downstream registry = %s, want one canonical names-only NPC", got)
|
||||
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 NPC identity", got)
|
||||
}
|
||||
manifestContent, err := json.Marshal(output.Manifest)
|
||||
if err != nil {
|
||||
@@ -228,9 +228,9 @@ func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, r
|
||||
case npcnormalize.PromptID:
|
||||
payload = map[string]any{"duplicate_groups": []any{}}
|
||||
case interactionextract.PromptID:
|
||||
payload = map[string]any{"interactions": []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{"name": " mira thorn ", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}},
|
||||
payload = map[string]any{"occurrences": []any{
|
||||
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{"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:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected interaction prompt %q", request.PromptID)
|
||||
@@ -291,7 +291,7 @@ func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context
|
||||
case npcnormalize.PromptID:
|
||||
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}}
|
||||
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:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user