diff --git a/internal/modules/sharedassets/dnd/prompt_inputs.go b/internal/modules/sharedassets/dnd/prompt_inputs.go new file mode 100644 index 0000000..c5cd195 --- /dev/null +++ b/internal/modules/sharedassets/dnd/prompt_inputs.go @@ -0,0 +1,84 @@ +package dnd + +import ( + "bytes" + "fmt" + "sort" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func PromptInputs(sourceInput contracts.LLMInputMaterial, references contracts.ReferenceSet) contracts.LLMInputSet { + partySlot := references.Slots["party"] + if len(partySlot.Items) == 0 { + partySlot = references.Slots["roster"] + } + return contracts.LLMInputSet{ + "transcript": TranscriptPromptMaterial(sourceInput), + "players": ReferencePromptMaterial("players", references.Slots["players"]), + "party": ReferencePromptMaterial("party", partySlot), + "glossary": ReferencePromptMaterial("glossary", references.Slots["glossary"]), + } +} + +func TranscriptPromptMaterial(material contracts.LLMInputMaterial) contracts.LLMInputMaterial { + out := material.Clone() + out.Name = "transcript" + return out +} + +func ReferencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) contracts.LLMInputMaterial { + body := ReferencePromptInput(slot) + digest := "" + originURI := "" + if len(slot.Items) == 1 { + digest = slot.Items[0].Digest + originURI = slot.Items[0].Origin.URI + } + return contracts.NewLLMInputMaterial(name, "text/plain", body, digest, originURI) +} + +func ReferencePromptInput(slot contracts.ResolvedReferenceSlot) []byte { + if len(slot.Items) == 0 { + return []byte(" ") + } + items := append([]contracts.ReferenceItem(nil), slot.Items...) + sort.SliceStable(items, func(i, j int) bool { + if items[i].Origin.URI != items[j].Origin.URI { + return items[i].Origin.URI < items[j].Origin.URI + } + if items[i].Digest != items[j].Digest { + return items[i].Digest < items[j].Digest + } + return string(items[i].Content) < string(items[j].Content) + }) + if len(items) == 1 { + return append([]byte(nil), items[0].Content...) + } + + var b bytes.Buffer + for i, item := range items { + if i > 0 { + b.WriteString("\n\n") + } + fmt.Fprintf(&b, "Reference %d\n", i+1) + if item.Origin.Type != "" { + fmt.Fprintf(&b, "Origin-Type: %s\n", item.Origin.Type) + } + if item.Origin.URI != "" { + fmt.Fprintf(&b, "Origin-URI: %s\n", item.Origin.URI) + } + if item.Digest != "" { + fmt.Fprintf(&b, "Digest: %s\n", item.Digest) + } + if item.MediaType != "" { + fmt.Fprintf(&b, "Media-Type: %s\n", item.MediaType) + } + if item.SizeBytes > 0 { + fmt.Fprintf(&b, "Size-Bytes: %d\n", item.SizeBytes) + } + b.WriteString("\n") + b.Write(item.Content) + } + return b.Bytes() +} diff --git a/internal/modules/sharedassets/dnd/prompt_inputs_test.go b/internal/modules/sharedassets/dnd/prompt_inputs_test.go new file mode 100644 index 0000000..6e9b4a9 --- /dev/null +++ b/internal/modules/sharedassets/dnd/prompt_inputs_test.go @@ -0,0 +1,151 @@ +package dnd + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestPromptInputsBuildExpectedInputs(t *testing.T) { + source := contracts.NewLLMInputMaterial("source", "application/json", []byte("source text"), "sha256:source", "file:///source.json") + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "players": slotWithContent("players", "Alice: Aria"), + "party": slotWithContent("party", "Aria: cleric"), + "glossary": slotWithContent("glossary", "Brightmantle: temple"), + }} + + inputs := PromptInputs(source, references) + for _, name := range []string{"transcript", "players", "party", "glossary"} { + if _, ok := inputs[name]; !ok { + t.Fatalf("PromptInputs() missing %q: %#v", name, inputs) + } + } + if _, ok := inputs["roster"]; ok { + t.Fatalf("PromptInputs() included roster input: %#v", inputs) + } + if got := inputs["transcript"].Name; got != "transcript" { + t.Fatalf("transcript name = %q, want transcript", got) + } + if got := string(inputs["transcript"].Content); got != "source text" { + t.Fatalf("transcript content = %q, want source text", got) + } + if got := string(inputs["players"].Content); got != "Alice: Aria" { + t.Fatalf("players content = %q, want player reference", got) + } + if got := string(inputs["party"].Content); got != "Aria: cleric" { + t.Fatalf("party content = %q, want party reference", got) + } + if got := string(inputs["glossary"].Content); got != "Brightmantle: temple" { + t.Fatalf("glossary content = %q, want glossary reference", got) + } +} + +func TestPromptInputsUseRosterWhenPartyIsEmpty(t *testing.T) { + inputs := PromptInputs(contracts.LLMInputMaterial{}, contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "party": {}, + "roster": slotWithContent("roster", "Legacy roster text"), + }}) + + if got := string(inputs["party"].Content); got != "Legacy roster text" { + t.Fatalf("party content = %q, want roster fallback content", got) + } + if _, ok := inputs["roster"]; ok { + t.Fatalf("PromptInputs() included roster input: %#v", inputs) + } +} + +func TestTranscriptPromptMaterialClonesSource(t *testing.T) { + source := contracts.NewLLMInputMaterial("source", "text/plain", []byte("source text"), "sha256:source", "file:///source.txt") + got := TranscriptPromptMaterial(source) + + if got.Name != "transcript" { + t.Fatalf("Name = %q, want transcript", got.Name) + } + if got.MediaType != source.MediaType || got.Digest != source.Digest || got.OriginURI != source.OriginURI || got.SizeBytes != source.SizeBytes { + t.Fatalf("TranscriptPromptMaterial() = %#v, want cloned metadata from %#v", got, source) + } + source.Content[0] = 'X' + if string(got.Content) != "source text" { + t.Fatalf("TranscriptPromptMaterial() reused content slice: %q", got.Content) + } +} + +func TestReferencePromptMaterialUsesTextPlainAndSingleReferenceMetadata(t *testing.T) { + slot := contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{{ + Content: []byte("single reference"), + Digest: "sha256:reference", + Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///reference.md"}, + }}} + + got := ReferencePromptMaterial("party", slot) + if got.Name != "party" || got.MediaType != "text/plain" { + t.Fatalf("ReferencePromptMaterial() name/media = %q/%q, want party/text/plain", got.Name, got.MediaType) + } + if got.Digest != "sha256:reference" || got.OriginURI != "file:///reference.md" { + t.Fatalf("ReferencePromptMaterial() digest/origin = %q/%q, want single reference metadata", got.Digest, got.OriginURI) + } + if string(got.Content) != "single reference" { + t.Fatalf("ReferencePromptMaterial() content = %q, want raw single reference", got.Content) + } +} + +func TestReferencePromptMaterialOmitsAggregateMetadata(t *testing.T) { + got := ReferencePromptMaterial("party", contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{ + {Content: []byte("one"), Digest: "sha256:one", Origin: contracts.ReferenceOrigin{URI: "file:///one.md"}}, + {Content: []byte("two"), Digest: "sha256:two", Origin: contracts.ReferenceOrigin{URI: "file:///two.md"}}, + }}) + + if got.Digest != "" || got.OriginURI != "" { + t.Fatalf("ReferencePromptMaterial() digest/origin = %q/%q, want empty aggregate metadata", got.Digest, got.OriginURI) + } +} + +func TestReferencePromptInputRendering(t *testing.T) { + if got := string(ReferencePromptInput(contracts.ResolvedReferenceSlot{})); got != " " { + t.Fatalf("empty rendering = %q, want single space", got) + } + if got := string(ReferencePromptInput(slotWithContent("party", "single reference"))); got != "single reference" { + t.Fatalf("single rendering = %q, want raw content", got) + } + + slot := contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{ + { + SlotName: "party", + MediaType: "text/plain", + Content: []byte("second"), + Digest: "sha256:bbb", + Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///b.txt"}, + SizeBytes: 6, + }, + { + SlotName: "party", + MediaType: "text/plain", + Content: []byte("first"), + Digest: "sha256:aaa", + Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///a.txt"}, + SizeBytes: 5, + }, + }} + first := string(ReferencePromptInput(slot)) + second := string(ReferencePromptInput(slot)) + if first != second { + t.Fatalf("ReferencePromptInput() was not deterministic:\nfirst=%q\nsecond=%q", first, second) + } + if !strings.Contains(first, "Reference 1\nOrigin-Type: file\nOrigin-URI: file:///a.txt\nDigest: sha256:aaa\nMedia-Type: text/plain\nSize-Bytes: 5\n\nfirst") { + t.Fatalf("first reference block = %q, want sorted first reference metadata", first) + } + if strings.Index(first, "first") > strings.Index(first, "second") { + t.Fatalf("references were not sorted deterministically: %q", first) + } +} + +func slotWithContent(name string, content string) contracts.ResolvedReferenceSlot { + return contracts.ResolvedReferenceSlot{ + Slot: contracts.ReferenceSlot{Name: name}, + Items: []contracts.ReferenceItem{{ + SlotName: name, + Content: []byte(content), + }}, + } +} diff --git a/internal/modules/sharedassets/dnd/references.go b/internal/modules/sharedassets/dnd/references.go new file mode 100644 index 0000000..6a50a2d --- /dev/null +++ b/internal/modules/sharedassets/dnd/references.go @@ -0,0 +1,47 @@ +package dnd + +import "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + +type ReferenceSlotDescriptions struct { + Glossary string + Party string + Players string + Roster string +} + +var referenceMediaTypes = []string{ + "application/json", + "application/x-yaml", + "application/yaml", + "text/markdown", + "text/plain", +} + +func ReferenceMediaTypes() []string { + return append([]string(nil), referenceMediaTypes...) +} + +func ReferenceSlots(descriptions ReferenceSlotDescriptions) []contracts.ReferenceSlot { + return contracts.CloneReferenceSlots([]contracts.ReferenceSlot{ + { + Name: "glossary", + Description: descriptions.Glossary, + AcceptedMediaTypes: ReferenceMediaTypes(), + }, + { + Name: "party", + Description: descriptions.Party, + AcceptedMediaTypes: ReferenceMediaTypes(), + }, + { + Name: "players", + Description: descriptions.Players, + AcceptedMediaTypes: ReferenceMediaTypes(), + }, + { + Name: "roster", + Description: descriptions.Roster, + AcceptedMediaTypes: ReferenceMediaTypes(), + }, + }) +} diff --git a/internal/modules/sharedassets/dnd/references_test.go b/internal/modules/sharedassets/dnd/references_test.go new file mode 100644 index 0000000..13f96fb --- /dev/null +++ b/internal/modules/sharedassets/dnd/references_test.go @@ -0,0 +1,60 @@ +package dnd + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestReferenceMediaTypesReturnsDefensiveCopy(t *testing.T) { + want := []string{ + "application/json", + "application/x-yaml", + "application/yaml", + "text/markdown", + "text/plain", + } + got := ReferenceMediaTypes() + if !reflect.DeepEqual(got, want) { + t.Fatalf("ReferenceMediaTypes() = %#v, want %#v", got, want) + } + got[0] = "changed" + if again := ReferenceMediaTypes(); again[0] != "application/json" { + t.Fatalf("ReferenceMediaTypes() reused backing storage: %#v", again) + } +} + +func TestReferenceSlotsUseDescriptionsAndExpectedOrder(t *testing.T) { + descriptions := ReferenceSlotDescriptions{ + Glossary: "Glossary reference", + Party: "Party reference", + Players: "Players reference", + Roster: "Roster reference", + } + got := ReferenceSlots(descriptions) + want := []contracts.ReferenceSlot{ + {Name: "glossary", Description: descriptions.Glossary, AcceptedMediaTypes: ReferenceMediaTypes()}, + {Name: "party", Description: descriptions.Party, AcceptedMediaTypes: ReferenceMediaTypes()}, + {Name: "players", Description: descriptions.Players, AcceptedMediaTypes: ReferenceMediaTypes()}, + {Name: "roster", Description: descriptions.Roster, AcceptedMediaTypes: ReferenceMediaTypes()}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ReferenceSlots() = %#v, want %#v", got, want) + } +} + +func TestReferenceSlotsReturnDefensiveCopies(t *testing.T) { + first := ReferenceSlots(ReferenceSlotDescriptions{}) + second := ReferenceSlots(ReferenceSlotDescriptions{}) + + first[0].Name = "changed" + first[0].AcceptedMediaTypes[0] = "changed" + + if second[0].Name != "glossary" { + t.Fatalf("ReferenceSlots() reused slot slice: %#v", second) + } + if second[0].AcceptedMediaTypes[0] != "application/json" { + t.Fatalf("ReferenceSlots() reused media type slice: %#v", second) + } +}