Add DnD reference prompt helpers
This commit is contained in:
84
internal/modules/sharedassets/dnd/prompt_inputs.go
Normal file
84
internal/modules/sharedassets/dnd/prompt_inputs.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
151
internal/modules/sharedassets/dnd/prompt_inputs_test.go
Normal file
151
internal/modules/sharedassets/dnd/prompt_inputs_test.go
Normal file
@@ -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),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
47
internal/modules/sharedassets/dnd/references.go
Normal file
47
internal/modules/sharedassets/dnd/references.go
Normal file
@@ -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(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
60
internal/modules/sharedassets/dnd/references_test.go
Normal file
60
internal/modules/sharedassets/dnd/references_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user