diff --git a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go index ec03ef5..284cde9 100644 --- a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go +++ b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go @@ -18,7 +18,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { {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"}, - }) + }, sharedassets.SharedPromptFiles()...) if err != nil { return fmt.Errorf("prepare scene prompt assets: %w", err) } diff --git a/internal/modules/extract/dnd/spells/scriptorium_assets.go b/internal/modules/extract/dnd/spells/scriptorium_assets.go index 47949d6..f661f89 100644 --- a/internal/modules/extract/dnd/spells/scriptorium_assets.go +++ b/internal/modules/extract/dnd/spells/scriptorium_assets.go @@ -18,7 +18,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error { {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"}, - }) + }, sharedassets.SharedPromptFiles()...) if err != nil { return fmt.Errorf("prepare spell prompt assets: %w", err) } diff --git a/internal/modules/sharedassets/assets.go b/internal/modules/sharedassets/assets.go index a5c818b..be3a323 100644 --- a/internal/modules/sharedassets/assets.go +++ b/internal/modules/sharedassets/assets.go @@ -9,10 +9,28 @@ import ( //go:embed assets/prompts/*.md var embeddedAssets embed.FS +var commonDNDPromptFiles = []string{ + "common-dnd-system.md", + "common-dnd-transcript.md", + "common-dnd-references.md", +} + func Register(registry *llm.AssetRegistry) error { return registry.RegisterPromptFS(embeddedAssets, "assets/prompts") } +func SharedPromptFiles() []SharedPromptFile { + files := make([]SharedPromptFile, 0, len(commonDNDPromptFiles)) + for _, name := range commonDNDPromptFiles { + files = append(files, SharedPromptFile{ + Name: name, + FS: embeddedAssets, + Path: "assets/prompts/" + name, + }) + } + return files +} + func CommonHashParts() []llm.AssetHashPart { return []llm.AssetHashPart{ {FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"}, diff --git a/internal/modules/sharedassets/prompt_fs.go b/internal/modules/sharedassets/prompt_fs.go index a998539..5ba030f 100644 --- a/internal/modules/sharedassets/prompt_fs.go +++ b/internal/modules/sharedassets/prompt_fs.go @@ -18,14 +18,18 @@ type ModulePromptFile struct { Path string } -// ModulePromptFS builds a prompt filesystem for a module directory from -// module-owned prompt files plus common D&D shared prompt files under the -// module's sharedassets subdirectory. -func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile) (fs.FS, error) { - return modulePromptFS(moduleDir, moduleFS, files, embeddedAssets) +// 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 } -func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, sharedFS fs.FS) (fs.FS, error) { +// 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) @@ -33,10 +37,7 @@ func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, if moduleFS == nil { return nil, fmt.Errorf("module prompt filesystem must not be nil") } - if sharedFS == nil { - return nil, fmt.Errorf("shared prompt filesystem must not be nil") - } - assets := make(promptMapFS, len(files)+len(commonDNDPromptFiles)) + assets := make(promptMapFS, len(files)+len(sharedFiles)) for _, file := range files { name, err := cleanPromptPath(file.Name) if err != nil { @@ -56,22 +57,30 @@ func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...) } - for _, name := range commonDNDPromptFiles { - data, err := fs.ReadFile(sharedFS, "assets/prompts/"+name) + for _, file := range sharedFiles { + name, err := cleanPromptPath(file.Name) if err != nil { - return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err) + 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 } -var commonDNDPromptFiles = []string{ - "common-dnd-system.md", - "common-dnd-transcript.md", - "common-dnd-references.md", -} - type promptMapFS map[string][]byte func (m promptMapFS) Open(name string) (fs.File, error) { diff --git a/internal/modules/sharedassets/prompt_fs_test.go b/internal/modules/sharedassets/prompt_fs_test.go index 896da05..6605f0d 100644 --- a/internal/modules/sharedassets/prompt_fs_test.go +++ b/internal/modules/sharedassets/prompt_fs_test.go @@ -8,34 +8,40 @@ import ( ) func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) { - fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{ - "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, - "assets/prompts/task.md": {Data: []byte("task")}, + 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: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, + {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/dnd.test/dnd.test.yaml": "id: dnd.test", - "assets/prompts/dnd.test/task.md": "task", - "assets/prompts/dnd.test/sharedassets/common-dnd-system.md": "", - "assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md": "", - "assets/prompts/dnd.test/sharedassets/common-dnd-references.md": "", + "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 len(data) == 0 { - t.Fatalf("ReadFile(%q) returned empty content", path) - } - if want != "" && !strings.Contains(string(data), want) { - t.Fatalf("ReadFile(%q) = %q, want substring %q", path, data, want) + if string(data) != want { + t.Fatalf("ReadFile(%q) = %q, want %q", path, data, want) } } @@ -46,10 +52,17 @@ func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) { 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("dnd.test", fstest.MapFS{}, []ModulePromptFile{ + _, 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") { @@ -58,18 +71,18 @@ func TestModulePromptFSRejectsMissingModuleFile(t *testing.T) { } func TestModulePromptFSRejectsMissingSharedFile(t *testing.T) { - _, err := modulePromptFS("dnd.test", fstest.MapFS{ + _, err := ModulePromptFS("module.test", fstest.MapFS{ "assets/prompts/task.md": {Data: []byte("task")}, }, []ModulePromptFile{ {Name: "task.md", Path: "assets/prompts/task.md"}, - }, fstest.MapFS{}) - if err == nil || !strings.Contains(err.Error(), "read shared prompt asset common-dnd-system.md") { - t.Fatalf("modulePromptFS() error = %v, want missing shared asset context", err) + }, 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("dnd.test", fstest.MapFS{ + _, err := ModulePromptFS("module.test", fstest.MapFS{ "assets/prompts/task.md": {Data: []byte("task")}, }, []ModulePromptFile{ {Name: "nested/task.md", Path: "assets/prompts/task.md"}, @@ -78,3 +91,33 @@ func TestModulePromptFSRejectsNestedVirtualFileName(t *testing.T) { 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) + } +}