Use shared DnD prompt helpers in modules

This commit is contained in:
2026-07-06 17:22:21 +00:00
parent 35fe405448
commit f91e643932
7 changed files with 36 additions and 265 deletions

View File

@@ -8,6 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
)
const Key = "dnd/scenes"
@@ -21,35 +22,11 @@ var providedCapabilities = []string{
"chunks.scenes",
}
var acceptedReferenceMediaTypes = []string{
"application/json",
"application/x-yaml",
"application/yaml",
"text/markdown",
"text/plain",
}
var referenceSlots = []contracts.ReferenceSlot{
{
Name: "glossary",
Description: "Optional campaign glossary reference material used only for scene disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
{
Name: "party",
Description: "Optional party roster reference material used only for scene disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
{
Name: "players",
Description: "Optional player list reference material used only for scene disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
{
Name: "roster",
Description: "Deprecated alias for party roster reference material used only for scene disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
var referenceSlotDescriptions = dnd.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
Party: "Optional party roster reference material used only for scene disambiguation.",
Players: "Optional player list reference material used only for scene disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for scene disambiguation.",
}
var _ contracts.Chunker = (*Chunker)(nil)
@@ -66,7 +43,7 @@ func (c *Chunker) Key() string {
}
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
return cloneReferenceSlots(referenceSlots)
return dnd.ReferenceSlots(referenceSlotDescriptions)
}
func (c *Chunker) ManifestMetadata() map[string]any {
@@ -122,7 +99,7 @@ func (c *Chunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contra
PromptVersion: ResponseSchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: promptInputs(req),
Inputs: dnd.PromptInputs(req.SourceInput, req.References),
}, &response); err != nil {
return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err)
}
@@ -147,7 +124,7 @@ func ModuleSpec() pipeline.ModuleSpec {
Stage: pipeline.StageChunk,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ReferenceSlots: cloneReferenceSlots(referenceSlots),
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions),
}
}
@@ -338,15 +315,3 @@ func cloneMetadata(metadata map[string]any) map[string]any {
func chunkerErrorf(format string, args ...any) error {
return fmt.Errorf("dnd scenes chunker: "+format, args...)
}
func cloneReferenceSlots(slots []contracts.ReferenceSlot) []contracts.ReferenceSlot {
if len(slots) == 0 {
return nil
}
out := make([]contracts.ReferenceSlot, len(slots))
for i, slot := range slots {
slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...)
out[i] = slot
}
return out
}

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
)
func TestNewModuleSpecAndRegister(t *testing.T) {
@@ -257,15 +258,12 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
}
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
inputs := promptInputs(contracts.ChunkRequest{
SourceInput: sceneSourceInput(),
References: contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": {
Slot: contracts.ReferenceSlot{Name: "roster"},
Items: []contracts.ReferenceItem{
{SlotName: "roster", Content: []byte("Legacy roster text")},
},
inputs := dnd.PromptInputs(sceneSourceInput(), contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": {
Slot: contracts.ReferenceSlot{Name: "roster"},
Items: []contracts.ReferenceItem{
{SlotName: "roster", Content: []byte("Legacy roster text")},
},
},
},

View File

@@ -1,12 +1,9 @@
package scenes
import (
"bytes"
"fmt"
"sort"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
@@ -29,36 +26,6 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet {
partySlot := req.References.Slots["party"]
if len(partySlot.Items) == 0 {
partySlot = req.References.Slots["roster"]
}
return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput),
"players": referencePromptMaterial("players", req.References.Slots["players"]),
"party": referencePromptMaterial("party", partySlot),
"glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]),
}
}
func transcriptPromptInput(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 scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{
@@ -71,51 +38,6 @@ func scriptoriumPromptMetadata() (string, error) {
return scriptoriumPromptHash, scriptoriumPromptHashErr
}
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()
}
var (
scriptoriumPromptHashOnce sync.Once
scriptoriumPromptHash string

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
)
const Key = "dnd/spells"
@@ -25,35 +26,11 @@ var providedCapabilities = []string{
"dnd.spell_casts",
}
var acceptedReferenceMediaTypes = []string{
"application/json",
"application/x-yaml",
"application/yaml",
"text/markdown",
"text/plain",
}
var referenceSlots = []contracts.ReferenceSlot{
{
Name: "glossary",
Description: "Optional campaign glossary reference material used only for disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
{
Name: "party",
Description: "Optional party roster reference material used only for disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
{
Name: "players",
Description: "Optional player list reference material used only for disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
{
Name: "roster",
Description: "Deprecated alias for party roster reference material used only for disambiguation.",
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
},
var referenceSlotDescriptions = dnd.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for disambiguation.",
Party: "Optional party roster reference material used only for disambiguation.",
Players: "Optional player list reference material used only for disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
}
var _ contracts.Extractor = (*Extractor)(nil)
@@ -77,7 +54,7 @@ func (e *Extractor) SchemaVersion() string {
}
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
return cloneReferenceSlots(referenceSlots)
return dnd.ReferenceSlots(referenceSlotDescriptions)
}
func (e *Extractor) ManifestMetadata() map[string]any {
@@ -137,7 +114,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: promptInputs(req),
Inputs: dnd.PromptInputs(req.SourceInput, req.References),
}, &response); err != nil {
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
}
@@ -168,7 +145,7 @@ func ModuleSpec() pipeline.ModuleSpec {
Stage: pipeline.StageExtract,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ReferenceSlots: cloneReferenceSlots(referenceSlots),
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions),
}
}
@@ -190,15 +167,3 @@ func spellCastPayload(spellCast spellCastResponse) (json.RawMessage, error) {
func extractorErrorf(format string, args ...any) error {
return fmt.Errorf("dnd spells extractor: "+format, args...)
}
func cloneReferenceSlots(slots []contracts.ReferenceSlot) []contracts.ReferenceSlot {
if len(slots) == 0 {
return nil
}
out := make([]contracts.ReferenceSlot, len(slots))
for i, slot := range slots {
slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...)
out[i] = slot
}
return out
}

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
)
func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) {
@@ -156,15 +157,12 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
}
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
inputs := promptInputs(contracts.ExtractionRequest{
SourceInput: spellSourceInput(),
References: contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": {
Slot: contracts.ReferenceSlot{Name: "roster"},
Items: []contracts.ReferenceItem{
{SlotName: "roster", Content: []byte("Legacy roster text")},
},
inputs := dnd.PromptInputs(spellSourceInput(), contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": {
Slot: contracts.ReferenceSlot{Name: "roster"},
Items: []contracts.ReferenceItem{
{SlotName: "roster", Content: []byte("Legacy roster text")},
},
},
},

View File

@@ -1,12 +1,9 @@
package spells
import (
"bytes"
"fmt"
"sort"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
@@ -29,36 +26,6 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func promptInputs(req contracts.ExtractionRequest) contracts.LLMInputSet {
partySlot := req.References.Slots["party"]
if len(partySlot.Items) == 0 {
partySlot = req.References.Slots["roster"]
}
return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput),
"players": referencePromptMaterial("players", req.References.Slots["players"]),
"party": referencePromptMaterial("party", partySlot),
"glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]),
}
}
func transcriptPromptInput(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 scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{
@@ -71,51 +38,6 @@ func scriptoriumPromptMetadata() (string, error) {
return scriptoriumPromptHash, scriptoriumPromptHashErr
}
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()
}
var (
scriptoriumPromptHashOnce sync.Once
scriptoriumPromptHash string

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
"gitea.maximumdirect.net/eric/scriptorium"
)
@@ -72,8 +73,8 @@ func TestReferencePromptInputRenderingIsDeterministic(t *testing.T) {
},
},
}
first := string(referencePromptInput(slot))
second := string(referencePromptInput(slot))
first := string(dnd.ReferencePromptInput(slot))
second := string(dnd.ReferencePromptInput(slot))
if first != second {
t.Fatalf("reference rendering was not deterministic:\nfirst=%q\nsecond=%q", first, second)
}
@@ -86,7 +87,7 @@ func TestReferencePromptInputRenderingIsDeterministic(t *testing.T) {
}
func TestSingleReferencePromptInputKeepsContentOnly(t *testing.T) {
got := string(referencePromptInput(contracts.ResolvedReferenceSlot{
got := string(dnd.ReferencePromptInput(contracts.ResolvedReferenceSlot{
Items: []contracts.ReferenceItem{{Content: []byte("single reference")}},
}))
if got != "single reference" {