Organize D&D extensions by domain
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
package scenes
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
|
||||
type chunkResponse struct {
|
||||
Scenes []sceneResponse `json:"scenes"`
|
||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
||||
}
|
||||
|
||||
type sceneResponse struct {
|
||||
StartUnitID dnd.UnitRef `json:"start_unit_id"`
|
||||
EndUnitID dnd.UnitRef `json:"end_unit_id"`
|
||||
ShortTitle string `json:"short_title"`
|
||||
PrimaryMode string `json:"primary_mode"`
|
||||
MainParticipants []string `json:"main_participants"`
|
||||
Summary string `json:"summary"`
|
||||
BoundaryNote string `json:"boundary_note"`
|
||||
BoundaryConfidence string `json:"boundary_confidence"`
|
||||
}
|
||||
|
||||
type normalizedScene struct {
|
||||
StartUnitID int
|
||||
EndUnitID int
|
||||
ShortTitle string
|
||||
PrimaryMode string
|
||||
MainParticipants []string
|
||||
Summary string
|
||||
BoundaryNote string
|
||||
BoundaryConfidence string
|
||||
}
|
||||
@@ -9,7 +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/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const Key = "dnd/scenes"
|
||||
@@ -23,7 +23,7 @@ var providedCapabilities = []string{
|
||||
"chunks.scenes",
|
||||
}
|
||||
|
||||
var referenceSlotDescriptions = dnd.ReferenceSlotDescriptions{
|
||||
var referenceSlotDescriptions = shared.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.",
|
||||
@@ -44,7 +44,7 @@ func (c *Chunker) Key() string {
|
||||
}
|
||||
|
||||
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return dnd.ReferenceSlots(referenceSlotDescriptions)
|
||||
return shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
}
|
||||
|
||||
func (c *Chunker) ManifestMetadata() map[string]any {
|
||||
@@ -100,7 +100,7 @@ func (c *Chunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contra
|
||||
PromptVersion: ResponseSchemaVersion,
|
||||
ProfileID: req.LLMProfile,
|
||||
SessionID: req.SessionID,
|
||||
Inputs: dnd.PromptInputs(req.SourceInput, req.References),
|
||||
Inputs: shared.PromptInputs(req.SourceInput, req.References),
|
||||
}, &response); err != nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions),
|
||||
ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,11 +228,11 @@ func chunkContent(units []source.SourceUnit) ([]byte, error) {
|
||||
}
|
||||
|
||||
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
|
||||
startUnitID, err := dnd.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
|
||||
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
|
||||
if err != nil {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
|
||||
}
|
||||
endUnitID, err := dnd.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
|
||||
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
|
||||
if err != nil {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
|
||||
}
|
||||
@@ -11,7 +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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func TestNewModuleSpecAndRegister(t *testing.T) {
|
||||
@@ -105,8 +105,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
||||
response: chunkResponse{
|
||||
Scenes: []sceneResponse{
|
||||
{
|
||||
StartUnitID: dnd.UnitRefFromInt(1),
|
||||
EndUnitID: dnd.UnitRefFromInt(2),
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(2),
|
||||
ShortTitle: " Goblin parley ",
|
||||
PrimaryMode: "Discussion",
|
||||
MainParticipants: []string{" Aria ", "Goblin scout"},
|
||||
@@ -115,8 +115,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
{
|
||||
StartUnitID: dnd.UnitRefFromInt(3),
|
||||
EndUnitID: dnd.UnitRefFromInt(4),
|
||||
StartUnitID: shared.UnitRefFromInt(3),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Ambush at the gate",
|
||||
PrimaryMode: "Combat",
|
||||
MainParticipants: []string{"Aria", "Goblin ambushers"},
|
||||
@@ -210,8 +210,8 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{response: chunkResponse{
|
||||
Scenes: []sceneResponse{
|
||||
{
|
||||
StartUnitID: dnd.UnitRefFromInt(1),
|
||||
EndUnitID: dnd.UnitRefFromInt(4),
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Ambush",
|
||||
PrimaryMode: "Combat",
|
||||
MainParticipants: []string{"Aria"},
|
||||
@@ -264,7 +264,7 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
inputs := dnd.PromptInputs(sceneSourceInput(), contracts.ReferenceSet{
|
||||
inputs := shared.PromptInputs(sceneSourceInput(), contracts.ReferenceSet{
|
||||
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"roster": {
|
||||
Slot: contracts.ReferenceSlot{Name: "roster"},
|
||||
@@ -451,8 +451,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
|
||||
name: "empty metadata field",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
{
|
||||
StartUnitID: dnd.UnitRefFromInt(1),
|
||||
EndUnitID: dnd.UnitRefFromInt(4),
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: " ",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria"},
|
||||
@@ -467,8 +467,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
|
||||
name: "empty participant",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
{
|
||||
StartUnitID: dnd.UnitRefFromInt(1),
|
||||
EndUnitID: dnd.UnitRefFromInt(4),
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Title",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria", " "},
|
||||
@@ -559,8 +559,8 @@ func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse
|
||||
|
||||
func scene(startUnitID int, endUnitID int) sceneResponse {
|
||||
return sceneResponse{
|
||||
StartUnitID: dnd.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: dnd.UnitRefFromInt(endUnitID),
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
ShortTitle: "Scene title",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria"},
|
||||
30
internal/modules/dnd/chunk/scenes/model.go
Normal file
30
internal/modules/dnd/chunk/scenes/model.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package scenes
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
|
||||
type chunkResponse struct {
|
||||
Scenes []sceneResponse `json:"scenes"`
|
||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
||||
}
|
||||
|
||||
type sceneResponse struct {
|
||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
||||
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
||||
ShortTitle string `json:"short_title"`
|
||||
PrimaryMode string `json:"primary_mode"`
|
||||
MainParticipants []string `json:"main_participants"`
|
||||
Summary string `json:"summary"`
|
||||
BoundaryNote string `json:"boundary_note"`
|
||||
BoundaryConfidence string `json:"boundary_confidence"`
|
||||
}
|
||||
|
||||
type normalizedScene struct {
|
||||
StartUnitID int
|
||||
EndUnitID int
|
||||
ShortTitle string
|
||||
PrimaryMode string
|
||||
MainParticipants []string
|
||||
Summary string
|
||||
BoundaryNote string
|
||||
BoundaryConfidence string
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"sync"
|
||||
|
||||
"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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := dnd.ModulePromptFS("dnd.scenes", embeddedAssets, []sharedassets.ModulePromptFile{
|
||||
promptFS, err := shared.ModulePromptFS("dnd.scenes", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
@@ -32,7 +32,7 @@ func scriptoriumPromptMetadata() (string, error) {
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(dnd.CommonHashParts(), dnd.ReferenceHashParts()...)...)
|
||||
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
@@ -3,7 +3,7 @@ package spells
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func canonicalizeResponse(response *extractionResponse, sourceID string) {
|
||||
@@ -43,20 +43,20 @@ func canonicalizeSpellCast(spell *spellCastResponse, sourceID string) {
|
||||
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
|
||||
}
|
||||
|
||||
func canonicalUnitRef(ref dnd.UnitRef) dnd.UnitRef {
|
||||
func canonicalUnitRef(ref shared.UnitRef) shared.UnitRef {
|
||||
value := ref.Int()
|
||||
if value <= 0 {
|
||||
return ref
|
||||
}
|
||||
return dnd.UnitRefFromInt(value)
|
||||
return shared.UnitRefFromInt(value)
|
||||
}
|
||||
|
||||
func dedupeSourceRefs(refs []dnd.SourceRefResponse) []dnd.SourceRefResponse {
|
||||
func dedupeSourceRefs(refs []shared.SourceRefResponse) []shared.SourceRefResponse {
|
||||
if len(refs) < 2 {
|
||||
return refs
|
||||
}
|
||||
out := refs[:0]
|
||||
var previous dnd.SourceRefResponse
|
||||
var previous shared.SourceRefResponse
|
||||
for index, ref := range refs {
|
||||
if index > 0 && sameSourceRef(previous, ref) {
|
||||
continue
|
||||
@@ -67,7 +67,7 @@ func dedupeSourceRefs(refs []dnd.SourceRefResponse) []dnd.SourceRefResponse {
|
||||
return out
|
||||
}
|
||||
|
||||
func sameSourceRef(left dnd.SourceRefResponse, right dnd.SourceRefResponse) bool {
|
||||
func sameSourceRef(left shared.SourceRefResponse, right shared.SourceRefResponse) bool {
|
||||
return left.SourceID == right.SourceID &&
|
||||
left.StartUnitID.Int() == right.StartUnitID.Int() &&
|
||||
left.EndUnitID.Int() == right.EndUnitID.Int()
|
||||
@@ -83,7 +83,7 @@ func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func unitSortValue(ref dnd.UnitRef) int {
|
||||
func unitSortValue(ref shared.UnitRef) int {
|
||||
value := ref.Int()
|
||||
if value <= 0 {
|
||||
return int(^uint(0) >> 1)
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const Key = "dnd/spells"
|
||||
@@ -24,7 +24,7 @@ var providedCapabilities = []string{
|
||||
"dnd.spell_casts",
|
||||
}
|
||||
|
||||
var referenceSlotDescriptions = dnd.ReferenceSlotDescriptions{
|
||||
var referenceSlotDescriptions = shared.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.",
|
||||
@@ -44,7 +44,7 @@ func (e *Extractor) Key() string {
|
||||
}
|
||||
|
||||
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return dnd.ReferenceSlots(referenceSlotDescriptions)
|
||||
return shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
}
|
||||
|
||||
func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
@@ -101,7 +101,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
|
||||
PromptVersion: SchemaVersion,
|
||||
ProfileID: req.LLMProfile,
|
||||
SessionID: req.SessionID,
|
||||
Inputs: dnd.PromptInputs(sourceInput, req.References),
|
||||
Inputs: shared.PromptInputs(sourceInput, req.References),
|
||||
}, &response); err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions),
|
||||
ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func TestExtractReturnsCanonicalOutputFromStructuredResponse(t *testing.T) {
|
||||
@@ -155,7 +155,7 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
inputs := dnd.PromptInputs(spellSourceInput(), contracts.ReferenceSet{
|
||||
inputs := shared.PromptInputs(spellSourceInput(), contracts.ReferenceSet{
|
||||
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"roster": {
|
||||
Slot: contracts.ReferenceSlot{Name: "roster"},
|
||||
@@ -303,10 +303,10 @@ func TestExtractCanonicalizesSourceRefs(t *testing.T) {
|
||||
Spell: "Cure Wounds",
|
||||
Effect: "Heals.",
|
||||
NarrativeDescription: "Aria heals.",
|
||||
SourceRefs: []dnd.SourceRefResponse{
|
||||
{SourceID: "gameplay_transcript", StartUnitID: dnd.UnitRefFromInt(2), EndUnitID: dnd.UnitRefFromInt(2)},
|
||||
{SourceID: "", StartUnitID: dnd.UnitRefFromInt(1), EndUnitID: dnd.UnitRefFromInt(2)},
|
||||
{SourceID: "transcript", StartUnitID: dnd.UnitRefFromInt(1), EndUnitID: dnd.UnitRefFromInt(2)},
|
||||
SourceRefs: []shared.SourceRefResponse{
|
||||
{SourceID: "gameplay_transcript", StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)},
|
||||
{SourceID: "", StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)},
|
||||
{SourceID: "transcript", StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -345,8 +345,8 @@ func TestExtractPreservesInvalidSourceRefsForValidators(t *testing.T) {
|
||||
Spell: "Cure Wounds",
|
||||
Effect: "Heals.",
|
||||
NarrativeDescription: "Aria heals.",
|
||||
SourceRefs: []dnd.SourceRefResponse{
|
||||
{SourceID: "transcript", StartUnitID: dnd.UnitRefFromInt(99), EndUnitID: dnd.UnitRefFromString("missing")},
|
||||
SourceRefs: []shared.SourceRefResponse{
|
||||
{SourceID: "transcript", StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("missing")},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -386,7 +386,7 @@ func TestExtractDefensivelyCopiesRawContent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
client.response.SpellCasts[0].SourceRefs[0].StartUnitID = dnd.UnitRefFromInt(99)
|
||||
client.response.SpellCasts[0].SourceRefs[0].StartUnitID = shared.UnitRefFromInt(99)
|
||||
|
||||
var payload extractionResponse
|
||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||
22
internal/modules/dnd/extract/spells/model.go
Normal file
22
internal/modules/dnd/extract/spells/model.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package spells
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
|
||||
type SpellCast struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
}
|
||||
|
||||
type extractionResponse struct {
|
||||
SpellCasts []spellCastResponse `json:"spell_casts"`
|
||||
}
|
||||
|
||||
type spellCastResponse struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"sync"
|
||||
|
||||
"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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := dnd.ModulePromptFS("dnd.spells", embeddedAssets, []sharedassets.ModulePromptFile{
|
||||
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
@@ -32,7 +32,7 @@ func scriptoriumPromptMetadata() (string, error) {
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(dnd.CommonHashParts(), dnd.ReferenceHashParts()...)...)
|
||||
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
@@ -6,7 +6,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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func promptExtractionRequest() contracts.ExtractionRequest {
|
||||
@@ -65,22 +65,22 @@ func mustJSON(t *testing.T, value any) string {
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []dnd.SourceRefResponse {
|
||||
return []dnd.SourceRefResponse{
|
||||
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
|
||||
return []shared.SourceRefResponse{
|
||||
{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: dnd.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: dnd.UnitRefFromInt(endUnitID),
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func responseSourceRefsInt(sourceID string, startUnitID int, endUnitID int) []dnd.SourceRefResponse {
|
||||
return []dnd.SourceRefResponse{
|
||||
func responseSourceRefsInt(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
|
||||
return []shared.SourceRefResponse{
|
||||
{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: dnd.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: dnd.UnitRefFromInt(endUnitID),
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
|
||||
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
|
||||
validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json"
|
||||
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/shape"
|
||||
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_refs"
|
||||
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_relatedness"
|
||||
)
|
||||
|
||||
// Register adds all production D&D modules, validators, policy, and assets.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
)
|
||||
|
||||
func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||
)
|
||||
|
||||
//go:embed assets/prompts/*.md
|
||||
@@ -17,10 +17,10 @@ var sharedPromptFiles = []string{
|
||||
"common-dnd-references.md",
|
||||
}
|
||||
|
||||
func SharedPromptFiles() []sharedassets.SharedPromptFile {
|
||||
files := make([]sharedassets.SharedPromptFile, 0, len(sharedPromptFiles))
|
||||
func SharedPromptFiles() []promptfs.SharedPromptFile {
|
||||
files := make([]promptfs.SharedPromptFile, 0, len(sharedPromptFiles))
|
||||
for _, name := range sharedPromptFiles {
|
||||
files = append(files, sharedassets.SharedPromptFile{
|
||||
files = append(files, promptfs.SharedPromptFile{
|
||||
Name: name,
|
||||
FS: embeddedAssets,
|
||||
Path: "assets/prompts/" + name,
|
||||
@@ -42,6 +42,6 @@ func ReferenceHashParts() []llm.AssetHashPart {
|
||||
}
|
||||
}
|
||||
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []sharedassets.ModulePromptFile) (fs.FS, error) {
|
||||
return sharedassets.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...)
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []promptfs.ModulePromptFile) (fs.FS, error) {
|
||||
return promptfs.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||
)
|
||||
|
||||
func TestSharedPromptFilesReturnsNewSlice(t *testing.T) {
|
||||
@@ -67,7 +67,7 @@ func assertHashParts(t *testing.T, name string, parts []llm.AssetHashPart, want
|
||||
func TestModulePromptFSMountsDNDSharedPrompts(t *testing.T) {
|
||||
fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{
|
||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||
}, []sharedassets.ModulePromptFile{
|
||||
}, []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1,4 +1,4 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@@ -1,4 +1,4 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package dnd
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
60
internal/modules/dnd/validate/spells/shape/validator.go
Normal file
60
internal/modules/dnd/validate/spells/shape/validator.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package shape
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/spellpayload"
|
||||
)
|
||||
|
||||
const Key = "extract/dnd/spells/shape"
|
||||
const ReasonCode = "invalid_spell_shape"
|
||||
|
||||
var _ contracts.Validator = (*Validator)(nil)
|
||||
|
||||
type Validator struct{}
|
||||
|
||||
func New() *Validator {
|
||||
return &Validator{}
|
||||
}
|
||||
|
||||
func (v *Validator) Name() string {
|
||||
return Key
|
||||
}
|
||||
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
||||
payload, err := spellpayload.ValidationRequestPayload(req)
|
||||
if err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
}
|
||||
if err := spellpayload.ValidateShape(payload); err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{
|
||||
Key: Key,
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return registry.RegisterWithSpec(Spec(), func() (contracts.Validator, error) {
|
||||
return New(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
68
internal/modules/dnd/validate/spells/shape/validator_test.go
Normal file
68
internal/modules/dnd/validate/spells/shape/validator_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package shape
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidatorApprovesWellFormedSpellPayload(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria heals Borin.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, want approved", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsMalformedPayload(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(`{"spell_casts":`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if result.Approved {
|
||||
t.Fatalf("Approved = true, want false")
|
||||
}
|
||||
if result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(`{"spell_casts":[{"caster":"Aria","effect":"heals","narrative_description":"Aria heals Borin.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if result.Approved {
|
||||
t.Fatalf("Approved = true, want false")
|
||||
}
|
||||
if result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAndRegister(t *testing.T) {
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
validator, err := registry.Build(Key)
|
||||
if err != nil {
|
||||
t.Fatalf("Build(%q) error = %v, want nil", Key, err)
|
||||
}
|
||||
if validator.Name() != Key || validator.ExecutionClass() != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("validator = %q/%q, want key and deterministic execution", validator.Name(), validator.ExecutionClass())
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithPayload(payload string) contracts.ValidationRequest {
|
||||
return contracts.ValidationRequest{
|
||||
Payload: contracts.RawPayload{
|
||||
Content: []byte(payload),
|
||||
MediaType: "application/json",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sourcerefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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/dnd/validate/spells/spellpayload"
|
||||
)
|
||||
|
||||
const Key = "extract/dnd/spells/source_refs"
|
||||
const ReasonCode = "invalid_source_refs"
|
||||
|
||||
var _ contracts.Validator = (*Validator)(nil)
|
||||
|
||||
type Validator struct{}
|
||||
|
||||
func New() *Validator {
|
||||
return &Validator{}
|
||||
}
|
||||
|
||||
func (v *Validator) Name() string {
|
||||
return Key
|
||||
}
|
||||
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
||||
payload, err := spellpayload.ValidationRequestPayload(req)
|
||||
if err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
}
|
||||
if err := spellpayload.ValidateShape(payload); err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
}
|
||||
for spellIndex, spell := range payload.SpellCasts {
|
||||
for refIndex, ref := range spellpayload.SourceRefCandidates(req.Source, spell) {
|
||||
if err := source.ValidateRef(req.Source, ref); err != nil {
|
||||
return rejection(fmt.Sprintf("spell_casts[%d].source_refs[%d]: %v", spellIndex, refIndex, err)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{
|
||||
Key: Key,
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return registry.RegisterWithSpec(Spec(), func() (contracts.Validator, error) {
|
||||
return New(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package sourcerefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidatorApprovesValidSourceRefs(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":2}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, want approved", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsInvalidSourceRefs(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":99,"end_unit_id":99}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if result.Approved {
|
||||
t.Fatalf("Approved = true, want false")
|
||||
}
|
||||
if result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsMissingSourceDocument(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(nil, `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if result.Approved {
|
||||
t.Fatalf("Approved = true, want false")
|
||||
}
|
||||
if result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAndRegister(t *testing.T) {
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
validator, err := registry.Build(Key)
|
||||
if err != nil {
|
||||
t.Fatalf("Build(%q) error = %v, want nil", Key, err)
|
||||
}
|
||||
if validator.Name() != Key || validator.ExecutionClass() != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("validator = %q/%q, want key and deterministic execution", validator.Name(), validator.ExecutionClass())
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithPayload(doc *source.SourceDocument, payload string) contracts.ValidationRequest {
|
||||
return contracts.ValidationRequest{
|
||||
Source: doc,
|
||||
Payload: contracts.RawPayload{
|
||||
Content: []byte(payload),
|
||||
MediaType: "application/json",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{
|
||||
ID: "session",
|
||||
Kind: "transcript",
|
||||
Format: "application/json",
|
||||
Digest: "sha256:session",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 1, Kind: "message", Text: "Aria raises her holy symbol."},
|
||||
{ID: 2, Kind: "message", Text: "Aria casts Cure Wounds on Borin."},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package sourcerelatedness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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/dnd/validate/spells/spellpayload"
|
||||
)
|
||||
|
||||
const Key = "extract/dnd/spells/source_relatedness"
|
||||
const WarningReasonCode = "spell_not_near_source"
|
||||
|
||||
var _ contracts.Validator = (*Validator)(nil)
|
||||
|
||||
type Validator struct{}
|
||||
|
||||
func New() *Validator {
|
||||
return &Validator{}
|
||||
}
|
||||
|
||||
func (v *Validator) Name() string {
|
||||
return Key
|
||||
}
|
||||
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
||||
payload, err := spellpayload.ValidationRequestPayload(req)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
if err := spellpayload.ValidateShape(payload); err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
var warnings []contracts.Warning
|
||||
for spellIndex, spell := range payload.SpellCasts {
|
||||
if !spellAppearsInCitedText(req.Source, spell) {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: fmt.Sprintf("spell_casts[%d]", spellIndex),
|
||||
ReasonCode: WarningReasonCode,
|
||||
Message: fmt.Sprintf("spell %q was not found in cited source text", strings.TrimSpace(spell.Spell)),
|
||||
})
|
||||
}
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{
|
||||
Key: Key,
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return registry.RegisterWithSpec(Spec(), func() (contracts.Validator, error) {
|
||||
return New(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func spellAppearsInCitedText(doc *source.SourceDocument, spell spellpayload.SpellCast) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(spell.Spell))
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
for _, ref := range spellpayload.SourceRefCandidates(doc, spell) {
|
||||
text, ok := spellpayload.CitedText(doc, ref)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(text), name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package sourcerelatedness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidatorApprovesWithoutWarningWhenSpellAppearsInCitedText(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":2,"end_unit_id":2}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved {
|
||||
t.Fatalf("Approved = false, want true")
|
||||
}
|
||||
if len(result.Warnings) != 0 {
|
||||
t.Fatalf("Warnings = %#v, want none", result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Borin","spell":"Fire Bolt","effect":"scorches","narrative_description":"Borin casts Fire Bolt.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved {
|
||||
t.Fatalf("Approved = false, want true")
|
||||
}
|
||||
if len(result.Warnings) != 1 {
|
||||
t.Fatalf("Warnings = %#v, want one warning", result.Warnings)
|
||||
}
|
||||
if result.Warnings[0].ReasonCode != WarningReasonCode {
|
||||
t.Fatalf("ReasonCode = %q, want %q", result.Warnings[0].ReasonCode, WarningReasonCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorApprovesMalformedPayloadWithoutWarning(t *testing.T) {
|
||||
result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":`))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if !result.Approved {
|
||||
t.Fatalf("Approved = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAndRegister(t *testing.T) {
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
validator, err := registry.Build(Key)
|
||||
if err != nil {
|
||||
t.Fatalf("Build(%q) error = %v, want nil", Key, err)
|
||||
}
|
||||
if validator.Name() != Key || validator.ExecutionClass() != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("validator = %q/%q, want key and deterministic execution", validator.Name(), validator.ExecutionClass())
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithPayload(doc *source.SourceDocument, payload string) contracts.ValidationRequest {
|
||||
return contracts.ValidationRequest{
|
||||
Source: doc,
|
||||
Payload: contracts.RawPayload{
|
||||
Content: []byte(payload),
|
||||
MediaType: "application/json",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{
|
||||
ID: "session",
|
||||
Kind: "transcript",
|
||||
Format: "application/json",
|
||||
Digest: "sha256:session",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 1, Kind: "message", Text: "Borin draws his dagger."},
|
||||
{ID: 2, Kind: "message", Text: "Aria casts Cure Wounds on Borin."},
|
||||
},
|
||||
}
|
||||
}
|
||||
98
internal/modules/dnd/validate/spells/spellpayload/payload.go
Normal file
98
internal/modules/dnd/validate/spells/spellpayload/payload.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package spellpayload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
type Payload struct {
|
||||
SpellCasts []SpellCast `json:"spell_casts"`
|
||||
}
|
||||
|
||||
type SpellCast struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
func Parse(raw []byte) (Payload, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var payload Payload
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return Payload{}, fmt.Errorf("parse spell payload: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return Payload{}, fmt.Errorf("parse spell payload: multiple JSON values")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func ValidateShape(payload Payload) error {
|
||||
if payload.SpellCasts == nil {
|
||||
return fmt.Errorf("spell_casts must be present")
|
||||
}
|
||||
for index, spell := range payload.SpellCasts {
|
||||
if strings.TrimSpace(spell.Caster) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].caster must not be empty", index)
|
||||
}
|
||||
if strings.TrimSpace(spell.Spell) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].spell must not be empty", index)
|
||||
}
|
||||
if strings.TrimSpace(spell.Effect) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].effect must not be empty", index)
|
||||
}
|
||||
if strings.TrimSpace(spell.NarrativeDescription) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].narrative_description must not be empty", index)
|
||||
}
|
||||
if len(spell.SourceRefs) == 0 {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SourceRefCandidates(doc *source.SourceDocument, spell SpellCast) []source.SourceRef {
|
||||
refs := make([]source.SourceRef, 0, len(spell.SourceRefs))
|
||||
for _, ref := range spell.SourceRefs {
|
||||
refs = append(refs, shared.SourceRefCandidate(doc, ref))
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func CitedText(doc *source.SourceDocument, ref source.SourceRef) (string, bool) {
|
||||
if doc == nil {
|
||||
return "", false
|
||||
}
|
||||
startIndex, ok := source.UnitIndex(doc, ref.StartUnitID)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
endIndex, ok := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !ok || startIndex > endIndex {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := startIndex; i <= endIndex; i++ {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(doc.Units[i].Text)
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
func ValidationRequestPayload(req contracts.ValidationRequest) (Payload, error) {
|
||||
return Parse(req.Payload.Content)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package spells
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
|
||||
type SpellCast struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
}
|
||||
|
||||
type extractionResponse struct {
|
||||
SpellCasts []spellCastResponse `json:"spell_casts"`
|
||||
}
|
||||
|
||||
type spellCastResponse struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []dnd.SourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
3
internal/modules/generic/testdata/importboundaries/imports_dnd.go
vendored
Normal file
3
internal/modules/generic/testdata/importboundaries/imports_dnd.go
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
package importboundaries
|
||||
|
||||
import _ "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
203
internal/modules/import_boundaries_test.go
Normal file
203
internal/modules/import_boundaries_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const moduleImportPrefix = "gitea.maximumdirect.net/eric/notarius/internal/modules/"
|
||||
|
||||
func TestProductionImportBoundaries(t *testing.T) {
|
||||
repositoryRoot := testRepositoryRoot(t)
|
||||
err := filepath.WalkDir(repositoryRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if entry.Name() == ".git" || entry.Name() == "testdata" || entry.Name() == "vendor" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".go" {
|
||||
return nil
|
||||
}
|
||||
return checkImportBoundaries(repositoryRoot, path)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportBoundaryFixtureIsRejected(t *testing.T) {
|
||||
repositoryRoot := testRepositoryRoot(t)
|
||||
fixture := filepath.Join(repositoryRoot, "internal", "modules", "generic", "testdata", "importboundaries", "imports_dnd.go")
|
||||
err := checkImportBoundaries(repositoryRoot, fixture)
|
||||
if err == nil {
|
||||
t.Fatal("fixture import was accepted, want generic-to-D&D violation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "generic packages must not import D&D packages") {
|
||||
t.Fatalf("fixture error = %q, want generic-to-D&D violation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportBoundaryRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
importPath string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "D&D implementation cannot import Seriatim",
|
||||
filename: "internal/modules/dnd/extract/example/extractor.go",
|
||||
importPath: moduleImportPrefix + "seriatim/input/transcript",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "Seriatim implementation cannot import D&D",
|
||||
filename: "internal/modules/seriatim/input/example/adapter.go",
|
||||
importPath: moduleImportPrefix + "dnd/shared",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "generic implementation cannot import D&D",
|
||||
filename: "internal/modules/generic/merge/example/merger.go",
|
||||
importPath: moduleImportPrefix + "dnd/shared",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "domain root cannot import child",
|
||||
filename: "internal/modules/dnd/types.go",
|
||||
importPath: moduleImportPrefix + "dnd/extract/spells",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "D&D implementation may import generic implementation",
|
||||
filename: "internal/modules/dnd/extract/example/extractor.go",
|
||||
importPath: moduleImportPrefix + "generic/normalize/noop",
|
||||
},
|
||||
{
|
||||
name: "domain registrar may compose child packages",
|
||||
filename: "internal/modules/dnd/register/register.go",
|
||||
importPath: moduleImportPrefix + "dnd/extract/spells",
|
||||
},
|
||||
{
|
||||
name: "CLI may compose registrars",
|
||||
filename: "internal/cli/catalog.go",
|
||||
importPath: moduleImportPrefix + "dnd/register",
|
||||
},
|
||||
{
|
||||
name: "external integration test may compose domains",
|
||||
filename: "internal/modules/integration/example_test.go",
|
||||
importPath: moduleImportPrefix + "dnd/extract/spells",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateImport(tt.filename, tt.importPath)
|
||||
if tt.wantError && err == nil {
|
||||
t.Fatal("validateImport() error = nil, want boundary violation")
|
||||
}
|
||||
if !tt.wantError && err != nil {
|
||||
t.Fatalf("validateImport() error = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkImportBoundaries(repositoryRoot string, filename string) error {
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
relative, err := filepath.Rel(repositoryRoot, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve relative path for %s: %w", filename, err)
|
||||
}
|
||||
relative = filepath.ToSlash(relative)
|
||||
for _, imported := range parsed.Imports {
|
||||
importPath, err := strconv.Unquote(imported.Path.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse import in %s: %w", relative, err)
|
||||
}
|
||||
if err := validateImport(relative, importPath); err != nil {
|
||||
return fmt.Errorf("%s imports %s: %w", relative, importPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateImport(filename string, importPath string) error {
|
||||
if isExternalIntegrationTest(filename) {
|
||||
return nil
|
||||
}
|
||||
sourceDomain, sourceRoot := domainForFile(filename)
|
||||
targetDomain, targetChild := domainForImport(importPath)
|
||||
if sourceDomain == "" || targetDomain == "" {
|
||||
return nil
|
||||
}
|
||||
if sourceRoot && sourceDomain == targetDomain && targetChild {
|
||||
return fmt.Errorf("domain root packages must not import child implementations")
|
||||
}
|
||||
if sourceDomain == "generic" && targetDomain == "dnd" {
|
||||
return fmt.Errorf("generic packages must not import D&D packages")
|
||||
}
|
||||
if sourceDomain == "dnd" && targetDomain == "seriatim" {
|
||||
return fmt.Errorf("D&D packages must not import Seriatim packages")
|
||||
}
|
||||
if sourceDomain == "seriatim" && targetDomain == "dnd" {
|
||||
return fmt.Errorf("Seriatim packages must not import D&D packages")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func domainForFile(filename string) (domain string, root bool) {
|
||||
const prefix = "internal/modules/"
|
||||
if !strings.HasPrefix(filename, prefix) {
|
||||
return "", false
|
||||
}
|
||||
remainder := strings.TrimPrefix(filename, prefix)
|
||||
parts := strings.Split(remainder, "/")
|
||||
if len(parts) < 2 || !isDomain(parts[0]) {
|
||||
return "", false
|
||||
}
|
||||
return parts[0], len(parts) == 2
|
||||
}
|
||||
|
||||
func domainForImport(importPath string) (domain string, child bool) {
|
||||
if !strings.HasPrefix(importPath, moduleImportPrefix) {
|
||||
return "", false
|
||||
}
|
||||
remainder := strings.TrimPrefix(importPath, moduleImportPrefix)
|
||||
parts := strings.Split(remainder, "/")
|
||||
if len(parts) == 0 || !isDomain(parts[0]) {
|
||||
return "", false
|
||||
}
|
||||
return parts[0], len(parts) > 1
|
||||
}
|
||||
|
||||
func isDomain(name string) bool {
|
||||
return name == "dnd" || name == "generic" || name == "seriatim"
|
||||
}
|
||||
|
||||
func isExternalIntegrationTest(filename string) bool {
|
||||
return strings.HasPrefix(filename, "internal/modules/integration/") && strings.HasSuffix(filename, "_test.go")
|
||||
}
|
||||
|
||||
func testRepositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve import-boundary test location")
|
||||
}
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package spells
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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/dnd/extract/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
||||
@@ -45,8 +46,8 @@ func TestPipelineConfigLoadsAndResolvesWithDNDSpellsExtractor(t *testing.T) {
|
||||
if lane.ID != "spells" {
|
||||
t.Fatalf("lane ID = %q, want spells", lane.ID)
|
||||
}
|
||||
if lane.Extract.Module != Key {
|
||||
t.Fatalf("extract module = %q, want %q", lane.Extract.Module, Key)
|
||||
if lane.Extract.Module != spells.Key {
|
||||
t.Fatalf("extract module = %q, want %q", lane.Extract.Module, spells.Key)
|
||||
}
|
||||
if resolved.ResolvedPipeline.Digest == "" {
|
||||
t.Fatal("resolved digest is empty")
|
||||
@@ -82,13 +83,13 @@ func TestPipelineConfigRejectsMissingTranscriptCapabilityForDNDSpells(t *testing
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing capability") ||
|
||||
!strings.Contains(err.Error(), "source.transcript") ||
|
||||
!strings.Contains(err.Error(), Key) {
|
||||
!strings.Contains(err.Error(), spells.Key) {
|
||||
t.Fatalf("Resolve() error = %q, want dnd/spells missing source.transcript capability", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineConfigRejectsMissingSpellCastsCapabilityForAppendOrder(t *testing.T) {
|
||||
extractorSpec := ModuleSpec()
|
||||
extractorSpec := spells.ModuleSpec()
|
||||
extractorSpec.Provides = withoutCapability(extractorSpec.Provides, "dnd.spell_casts")
|
||||
|
||||
_, err := loadDNDSpellsPipelineConfig(t).Resolve(config.ResolveInput{
|
||||
@@ -176,11 +177,11 @@ func dndSpellsTestCatalog(t *testing.T, specs dndSpellsCatalogSpecs) pipeline.Mo
|
||||
}
|
||||
|
||||
if specs.extractor.Key == "" {
|
||||
if err := Register(extractors); err != nil {
|
||||
if err := spells.Register(extractors); err != nil {
|
||||
t.Fatalf("register dnd spells extractor: %v", err)
|
||||
}
|
||||
} else if err := extractors.RegisterWithSpec(specs.extractor, func() (contracts.Extractor, error) {
|
||||
return New(), nil
|
||||
return spells.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("register dnd spells extractor override: %v", err)
|
||||
}
|
||||
63
internal/modules/integration/dnd_spells_helpers_test.go
Normal file
63
internal/modules/integration/dnd_spells_helpers_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
type extractionResponse struct {
|
||||
SpellCasts []spellCastResponse `json:"spell_casts"`
|
||||
}
|
||||
|
||||
type spellCastResponse struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type fakeSpellsLLMClient struct {
|
||||
response extractionResponse
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
||||
content, err := json.Marshal(client.response)
|
||||
if err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
if err := json.Unmarshal(content, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured target: %w", err)
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||
}
|
||||
|
||||
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
|
||||
return []shared.SourceRefResponse{
|
||||
{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
||||
req.Inputs = req.Inputs.Clone()
|
||||
if len(req.Vars) == 0 {
|
||||
req.Vars = nil
|
||||
return req
|
||||
}
|
||||
vars := make(map[string]any, len(req.Vars))
|
||||
for key, value := range req.Vars {
|
||||
vars[key] = value
|
||||
}
|
||||
req.Vars = vars
|
||||
return req
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package spells
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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/dnd/extract/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
||||
)
|
||||
|
||||
@@ -52,7 +53,7 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
|
||||
t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs))
|
||||
}
|
||||
rawOutput := output.NormalizeOutputs[0]
|
||||
if rawOutput.LaneID != "spells" || rawOutput.Schema.ID != ResponseSchemaID || rawOutput.Schema.Version != SchemaVersion {
|
||||
if rawOutput.LaneID != "spells" || rawOutput.Schema.ID != spells.ResponseSchemaID || rawOutput.Schema.Version != spells.SchemaVersion {
|
||||
t.Fatalf("raw output envelope = %#v, want dnd spells schema on spells lane", rawOutput)
|
||||
}
|
||||
response := decodeRunnerSpellResponse(t, rawOutput.Payload.Content)
|
||||
@@ -85,16 +86,16 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
|
||||
t.Fatalf("len(ArtifactLanes) = %d, want 1", len(output.Manifest.ArtifactLanes))
|
||||
}
|
||||
lane := output.Manifest.ArtifactLanes[0]
|
||||
if lane.ID != "spells" || lane.Extractor != Key {
|
||||
if lane.ID != "spells" || lane.Extractor != spells.Key {
|
||||
t.Fatalf("manifest lane = %#v, want spells lane with dnd/spells extractor", lane)
|
||||
}
|
||||
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("manifest lane metadata = %#v, want extractor metadata", lane.Metadata)
|
||||
}
|
||||
if extractorMetadata["prompt_id"] != PromptID ||
|
||||
extractorMetadata["response_schema_key"] != string(ResponseSchemaKey) ||
|
||||
extractorMetadata["response_schema_name"] != ResponseSchemaName {
|
||||
if extractorMetadata["prompt_id"] != spells.PromptID ||
|
||||
extractorMetadata["response_schema_key"] != string(spells.ResponseSchemaKey) ||
|
||||
extractorMetadata["response_schema_name"] != spells.ResponseSchemaName {
|
||||
t.Fatalf("extractor metadata = %#v, want prompt/schema identifiers", extractorMetadata)
|
||||
}
|
||||
if len(output.OutputFiles) != 1 {
|
||||
@@ -146,8 +147,8 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
|
||||
}
|
||||
request := llmClient.requests[0]
|
||||
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
|
||||
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion)
|
||||
if request.PromptID != spells.PromptID || request.PromptVersion != spells.SchemaVersion {
|
||||
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, spells.PromptID, spells.SchemaVersion)
|
||||
}
|
||||
if got := string(request.Inputs["party"].Content); got != "Aria: party cleric\nBorin: fighter" {
|
||||
t.Fatalf("party input = %q, want reference text", got)
|
||||
@@ -188,8 +189,8 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference(t *testing.T) {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
|
||||
}
|
||||
request := llmClient.requests[0]
|
||||
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
|
||||
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion)
|
||||
if request.PromptID != spells.PromptID || request.PromptVersion != spells.SchemaVersion {
|
||||
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, spells.PromptID, spells.SchemaVersion)
|
||||
}
|
||||
if got := string(request.Inputs["party"].Content); !strings.Contains(got, "Lightning Bolt") {
|
||||
t.Fatalf("party input = %q, want party-reference-only spell in reference input", got)
|
||||
@@ -1,240 +0,0 @@
|
||||
package sharedassets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ModulePromptFile maps a module-owned embedded prompt file into the
|
||||
// Scriptorium-visible module prompt directory.
|
||||
type ModulePromptFile struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
// SharedPromptFile maps a caller-owned shared prompt file into a module's
|
||||
// Scriptorium-visible sharedassets prompt subdirectory.
|
||||
type SharedPromptFile struct {
|
||||
Name string
|
||||
FS fs.FS
|
||||
Path string
|
||||
}
|
||||
|
||||
// ModulePromptFS builds a prompt filesystem for a module directory from
|
||||
// module-owned prompt files plus caller-provided shared prompt files under the
|
||||
// module's sharedassets subdirectory.
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, sharedFiles ...SharedPromptFile) (fs.FS, error) {
|
||||
cleanModuleDir, err := cleanPromptPath(moduleDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module prompt directory: %w", err)
|
||||
}
|
||||
if moduleFS == nil {
|
||||
return nil, fmt.Errorf("module prompt filesystem must not be nil")
|
||||
}
|
||||
assets := make(promptMapFS, len(files)+len(sharedFiles))
|
||||
for _, file := range files {
|
||||
name, err := cleanPromptPath(file.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module prompt file name %q: %w", file.Name, err)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.Name)
|
||||
}
|
||||
filePath, err := cleanPromptPath(file.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err)
|
||||
}
|
||||
data, err := fs.ReadFile(moduleFS, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err)
|
||||
}
|
||||
assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...)
|
||||
}
|
||||
|
||||
for _, file := range sharedFiles {
|
||||
name, err := cleanPromptPath(file.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shared prompt file name %q: %w", file.Name, err)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.Name)
|
||||
}
|
||||
if file.FS == nil {
|
||||
return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name)
|
||||
}
|
||||
filePath, err := cleanPromptPath(file.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shared prompt file path %q: %w", file.Path, err)
|
||||
}
|
||||
data, err := fs.ReadFile(file.FS, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err)
|
||||
}
|
||||
assets["assets/prompts/"+cleanModuleDir+"/sharedassets/"+name] = append([]byte(nil), data...)
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
type promptMapFS map[string][]byte
|
||||
|
||||
func (m promptMapFS) Open(name string) (fs.File, error) {
|
||||
cleaned, err := cleanPromptFSPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
|
||||
}
|
||||
if data, ok := m[cleaned]; ok {
|
||||
return &promptFile{
|
||||
reader: bytes.NewReader(data),
|
||||
info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))},
|
||||
}, nil
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries != nil {
|
||||
return &promptDir{name: path.Base(cleaned), entries: entries}, nil
|
||||
}
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
cleaned, err := cleanPromptFSPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: err}
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries == nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (m promptMapFS) dirEntries(dir string) []fs.DirEntry {
|
||||
children := map[string]promptDirEntry{}
|
||||
prefix := ""
|
||||
if dir != "." {
|
||||
prefix = dir + "/"
|
||||
}
|
||||
for name, data := range m {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(name, prefix)
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
childName, _, hasSlash := strings.Cut(rest, "/")
|
||||
entry := promptDirEntry{name: childName, dir: hasSlash}
|
||||
if !hasSlash {
|
||||
entry.size = int64(len(data))
|
||||
}
|
||||
children[childName] = entry
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(children))
|
||||
for name := range children {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
entries := make([]fs.DirEntry, 0, len(names))
|
||||
for _, name := range names {
|
||||
entries = append(entries, children[name])
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func cleanPromptPath(name string) (string, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("path must not be empty")
|
||||
}
|
||||
cleaned := path.Clean(strings.TrimPrefix(trimmed, "/"))
|
||||
if cleaned == "." || !fs.ValidPath(cleaned) {
|
||||
return "", fmt.Errorf("invalid path %q", name)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func cleanPromptFSPath(name string) (string, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("path must not be empty")
|
||||
}
|
||||
cleaned := path.Clean(strings.TrimPrefix(trimmed, "/"))
|
||||
if cleaned == "." {
|
||||
return cleaned, nil
|
||||
}
|
||||
if !fs.ValidPath(cleaned) {
|
||||
return "", fmt.Errorf("invalid path %q", name)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
type promptFile struct {
|
||||
reader *bytes.Reader
|
||||
info promptFileInfo
|
||||
}
|
||||
|
||||
func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||
func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
|
||||
func (f *promptFile) Close() error { return nil }
|
||||
|
||||
type promptDir struct {
|
||||
name string
|
||||
offset int
|
||||
entries []fs.DirEntry
|
||||
}
|
||||
|
||||
func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil }
|
||||
func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (d *promptDir) Close() error { return nil }
|
||||
func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if d.offset >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := len(d.entries)
|
||||
if n > 0 && d.offset+n < end {
|
||||
end = d.offset + n
|
||||
}
|
||||
out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
|
||||
d.offset = end
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type promptDirEntry struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (e promptDirEntry) Name() string { return e.name }
|
||||
func (e promptDirEntry) IsDir() bool { return e.dir }
|
||||
func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() }
|
||||
func (e promptDirEntry) Info() (fs.FileInfo, error) {
|
||||
return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil
|
||||
}
|
||||
func (e promptDirEntry) fileInfoMode() fs.FileMode {
|
||||
if e.dir {
|
||||
return fs.ModeDir | 0o555
|
||||
}
|
||||
return 0o444
|
||||
}
|
||||
|
||||
type promptFileInfo struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i promptFileInfo) Name() string { return i.name }
|
||||
func (i promptFileInfo) Size() int64 { return i.size }
|
||||
func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() }
|
||||
func (i promptFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i promptFileInfo) IsDir() bool { return i.dir }
|
||||
func (i promptFileInfo) Sys() any { return nil }
|
||||
@@ -1,123 +0,0 @@
|
||||
package sharedassets
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) {
|
||||
sharedFS := fstest.MapFS{
|
||||
"shared/system.md": {Data: []byte("system")},
|
||||
"shared/reference.md": {Data: []byte("reference")},
|
||||
"nested/transcript.md": {Data: []byte("transcript")},
|
||||
}
|
||||
fsys, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/module.test.yaml": {Data: []byte("id: module.test")},
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "module.test.yaml", Path: "assets/prompts/module.test.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
},
|
||||
SharedPromptFile{Name: "system.md", FS: sharedFS, Path: "shared/system.md"},
|
||||
SharedPromptFile{Name: "reference.md", FS: sharedFS, Path: "shared/reference.md"},
|
||||
SharedPromptFile{Name: "transcript.md", FS: sharedFS, Path: "nested/transcript.md"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
tests := map[string]string{
|
||||
"assets/prompts/module.test/module.test.yaml": "id: module.test",
|
||||
"assets/prompts/module.test/task.md": "task",
|
||||
"assets/prompts/module.test/sharedassets/system.md": "system",
|
||||
"assets/prompts/module.test/sharedassets/reference.md": "reference",
|
||||
"assets/prompts/module.test/sharedassets/transcript.md": "transcript",
|
||||
}
|
||||
for path, want := range tests {
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("ReadFile(%q) = %q, want %q", path, data, want)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(.) error = %v, want nil", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != "assets" || !entries[0].IsDir() {
|
||||
t.Fatalf("ReadDir(.) = %#v, want assets directory", entries)
|
||||
}
|
||||
entries, err = fs.ReadDir(fsys, "assets/prompts/module.test/sharedassets")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(sharedassets) error = %v, want nil", err)
|
||||
}
|
||||
if len(entries) != 3 || entries[0].Name() != "reference.md" || entries[1].Name() != "system.md" || entries[2].Name() != "transcript.md" {
|
||||
t.Fatalf("ReadDir(sharedassets) = %#v, want sorted shared prompt entries", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsMissingModuleFile(t *testing.T) {
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{}, []ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/task.md") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want missing module asset context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsMissingSharedFile(t *testing.T) {
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
}, SharedPromptFile{Name: "system.md", FS: fstest.MapFS{}, Path: "shared/system.md"})
|
||||
if err == nil || !strings.Contains(err.Error(), "read shared prompt asset shared/system.md") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want missing shared asset context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsNestedVirtualFileName(t *testing.T) {
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "nested/task.md", Path: "assets/prompts/task.md"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "must not contain path separators") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nested file name error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsNestedSharedFileName(t *testing.T) {
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
}, SharedPromptFile{Name: "nested/system.md", FS: fstest.MapFS{}, Path: "shared/system.md"})
|
||||
if err == nil || !strings.Contains(err.Error(), "shared prompt file name") || !strings.Contains(err.Error(), "must not contain path separators") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nested shared file name error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsInvalidModuleDir(t *testing.T) {
|
||||
_, err := ModulePromptFS(".", fstest.MapFS{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "module prompt directory") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want module directory context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsNilFilesystems(t *testing.T) {
|
||||
_, err := ModulePromptFS("module.test", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "module prompt filesystem must not be nil") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil module filesystem error", err)
|
||||
}
|
||||
|
||||
_, err = ModulePromptFS("module.test", fstest.MapFS{}, nil, SharedPromptFile{Name: "system.md", Path: "shared/system.md"})
|
||||
if err == nil || !strings.Contains(err.Error(), "shared prompt file \"system.md\" filesystem must not be nil") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil shared filesystem error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user