Adopt registry-backed NPC occurrence artifacts
This commit is contained in:
@@ -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}},
|
||||
}}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user