Migrate NPC registry durable contract

This commit is contained in:
2026-08-05 18:25:04 +00:00
parent 916a32195b
commit 9653e06297
56 changed files with 256 additions and 237 deletions

View File

@@ -48,7 +48,7 @@ func referenceSlots() []contracts.ReferenceSlot {
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical actor grounding.",
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind},
MaxBytes: NPCRegistryMaxBytes,
})
slots = append(slots, contracts.ReferenceSlot{

View File

@@ -519,7 +519,7 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
func npcRegistryJSON(t *testing.T) []byte {
t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}}}}
value := dnd.NPCRegistry{NPCs: []dnd.NPC{{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}}}}
content, err := npccodec.New().Encode(value)
if err != nil {
t.Fatalf("encode NPC registry: %v", err)

View File

@@ -47,7 +47,7 @@ func referenceSlots() []contracts.ReferenceSlot {
Description: "Required normalized NPC registry used only for enemy-subject grounding, never as event evidence.",
Required: true,
AcceptedMediaTypes: []string{npccodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind},
MaxBytes: ReferenceMaxBytes,
},
contracts.ReferenceSlot{

View File

@@ -33,7 +33,7 @@ func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
name string
kind contracts.ArtifactKind
}{
{NPCRegistryReferenceSlot, dnd.NPCListKind},
{NPCRegistryReferenceSlot, dnd.NPCRegistryKind},
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind},
@@ -195,7 +195,7 @@ func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) {
func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet {
t.Helper()
npcContent, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{{
npcContent, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{{
ID: identity.DeriveID(enemy),
Name: enemy,
SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}},

View File

@@ -45,7 +45,7 @@ func referenceSlots() []contracts.ReferenceSlot {
Description: "Required normalized NPC registry used only for interaction identity grounding, never as interaction evidence.",
Required: true,
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind},
MaxBytes: NPCRegistryMaxBytes,
})
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })

View File

@@ -181,7 +181,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
}
func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{}})
content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{}})
if err != nil {
t.Fatal(err)
}
@@ -239,7 +239,7 @@ func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
registrySlot = slot
}
}
if !registrySlot.Required || !reflect.DeepEqual(registrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || registrySlot.MaxBytes != NPCRegistryMaxBytes {
if !registrySlot.Required || !reflect.DeepEqual(registrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCRegistryKind}) || registrySlot.MaxBytes != NPCRegistryMaxBytes {
t.Fatalf("NPC registry slot = %#v", registrySlot)
}
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
@@ -326,7 +326,7 @@ func requiredRegistryReferences(t *testing.T, names ...string) contracts.Referen
SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}},
}
}
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs})
content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: npcs})
if err != nil {
t.Fatal(err)
}

View File

@@ -51,9 +51,9 @@ func canonicalizeNPC(npc *npcResponse, order shared.SourceRefOrder, sourceID str
return order.EarliestValid(refs)
}
func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList {
func canonicalNPCRegistry(response extractionResponse, sourceID string) dnd.NPCRegistry {
if response.NPCs == nil {
return dnd.NPCList{NPCs: nil}
return dnd.NPCRegistry{NPCs: nil}
}
npcs := make([]dnd.NPC, len(response.NPCs))
for index, npc := range response.NPCs {
@@ -63,7 +63,7 @@ func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList
SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
}
}
return dnd.NPCList{NPCs: npcs}
return dnd.NPCRegistry{NPCs: npcs}
}
func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []source.SourceRef {

View File

@@ -35,7 +35,7 @@ func referenceSlots() []contracts.ReferenceSlot {
return shared.ReferenceSlots(referenceSlotDescriptions)
}
var _ contracts.Extractor[dnd.NPCList] = (*Extractor)(nil)
var _ contracts.Extractor[dnd.NPCRegistry] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
@@ -103,16 +103,16 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
}
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCList], error) {
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCRegistry], error) {
if e == nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("extractor must not be nil")
return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("extractor must not be nil")
}
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("LLM client must not be nil")
return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("LLM client must not be nil")
}
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("%w", err)
}
order := shared.NewSourceRefOrder(req.Source)
@@ -125,10 +125,10 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
SessionID: req.SessionID,
Inputs: shared.PromptInputs(sourceInput, req.References),
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("complete structured output: %w", err)
return contracts.TypedExtractionResult[dnd.NPCRegistry]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response, order, req.Source.ID)
return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil
return contracts.TypedExtractionResult[dnd.NPCRegistry]{Value: canonicalNPCRegistry(response, req.Source.ID)}, nil
}
func ModuleSpec() pipeline.ModuleSpec {
@@ -138,13 +138,13 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCListKind,
ArtifactKind: dnd.NPCRegistryKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.ExtractorRegistry) error {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCList], error) {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCRegistry], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err

View File

@@ -13,7 +13,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
func TestExtractReturnsCanonicalNPCRegistryFromPrivateResponse(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{
Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3),
@@ -32,7 +32,7 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
want := dnd.NPCList{NPCs: []dnd.NPC{
want := dnd.NPCRegistry{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}},
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
}}

View File

@@ -30,7 +30,7 @@ func TestModuleSpecAndReferenceSlots(t *testing.T) {
ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: []string{"chunks", "source.transcript"},
Provides: []string{"dnd.npcs"},
ArtifactKind: dnd.NPCListKind,
ArtifactKind: dnd.NPCRegistryKind,
ReferenceSlots: []contracts.ReferenceSlot{
{Name: "glossary", Description: "Optional campaign glossary reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
{Name: "party", Description: "Optional party roster reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
@@ -77,7 +77,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) {
"prompt_id": PromptID, "prompt_version": SchemaVersion,
"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
"identity_policy": "dnd.npcs.identity.v1",
"identity_policy": "dnd.npc_registry.identity.v1",
"mapping_policy": mappingPolicy,
} {
if metadata[key] != want {
@@ -90,7 +90,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) {
}
}
fingerprints := extractor.CheckpointFingerprints()
want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npcs.identity.v1", "mapping_policy": mappingPolicy}
want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npc_registry.identity.v1", "mapping_policy": mappingPolicy}
if len(fingerprints) != len(want) {
t.Fatalf("CheckpointFingerprints() = %#v, want %d entries", fingerprints, len(want))
}

View File

@@ -50,7 +50,7 @@ func referenceSlots() []contracts.ReferenceSlot {
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical caster-name grounding.",
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind},
MaxBytes: NPCRegistryMaxBytes,
})
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })

View File

@@ -107,8 +107,8 @@ func TestSpellExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *t
}
}
func registryFixture() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{
func registryFixture() dnd.NPCRegistry {
return dnd.NPCRegistry{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn",
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},

View File

@@ -44,7 +44,7 @@ func TestModuleSpec(t *testing.T) {
Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical caster-name grounding.",
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind},
MaxBytes: NPCRegistryMaxBytes,
},
{