Organize D&D extensions by domain

This commit is contained in:
2026-07-17 05:05:48 +00:00
parent a81b9f1e1f
commit 15c369c509
64 changed files with 469 additions and 194 deletions

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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"},

View 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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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),
}
}

View File

@@ -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 {

View 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"`
}

View File

@@ -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

View File

@@ -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),
},
}
}

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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()...)
}

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
package dnd
package shared
import (
"bytes"

View File

@@ -1,4 +1,4 @@
package dnd
package shared
import (
"strings"

View File

@@ -1,4 +1,4 @@
package dnd
package shared
import "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"

View File

@@ -1,4 +1,4 @@
package dnd
package shared
import (
"reflect"

View File

@@ -1,4 +1,4 @@
package dnd
package shared
import (
"bytes"

View File

@@ -1,4 +1,4 @@
package dnd
package shared
import (
"encoding/json"

View 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,
}
}

View 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",
},
}
}

View File

@@ -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,
}
}

View File

@@ -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."},
},
}
}

View File

@@ -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
}

View File

@@ -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."},
},
}
}

View 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)
}

View File

@@ -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"`
}

View File

@@ -0,0 +1,3 @@
package importboundaries
import _ "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"

View 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), "..", ".."))
}

View File

@@ -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)
}

View 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
}

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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)
}
}