Compare commits

..

5 Commits

23 changed files with 594 additions and 41 deletions

View File

@@ -41,10 +41,12 @@ request. Prompt metadata hashes remain based on prompt asset source, not
rendered reference bytes.
LLM-backed modules own Scriptorium prompt definitions and response schemas in
their embedded assets. Module contracts should expose prompt IDs, versions,
input material names, and non-secret prompt/schema hashes through manifest
metadata; they should not expose Scriptorium public types through chunk,
extract, or normalize contracts.
their embedded assets. Module-owned prompts live under each module's shallow
`assets/prompts` tree and schemas live under `assets/schemas`. Shared reusable
prompt fragments live under `internal/modules/sharedassets`. Module contracts
should expose prompt IDs, versions, input material names, and non-secret
prompt/schema hashes through manifest metadata; they should not expose
Scriptorium public types through chunk, extract, or normalize contracts.
Chunk modules receive the structured LLM client, configured Scriptorium profile
ID, prompt session ID, and raw source input material through
@@ -104,6 +106,10 @@ prompt ID, prompt version, transcript input material, response schema, and
session ID to the runtime; validates model-authored source-unit boundaries; and
converts each scene into a deterministic source chunk.
Its prompt definition lives under `assets/prompts` and its schema under
`assets/schemas`. Shared reusable prompt fragments are provided by
`internal/modules/sharedassets`.
Requires:
- `source.transcript`
@@ -138,6 +144,10 @@ input materials, response schema, and session ID to the runtime; converts
spell-cast responses into artifact candidates; and supplies deterministic
validators.
Its prompt definition lives under `assets/prompts` and its schema under
`assets/schemas`. Shared reusable prompt fragments are provided by
`internal/modules/sharedassets`.
Requires:
- `chunks`

View File

@@ -55,6 +55,7 @@ Production module packages live under `internal/modules`:
- `input/seriatim`
- `chunk/generic`
- `chunk/dnd/scenes`
- `extract/dnd/spells`
- `merge/appendorder`
- `normalize/noop`
@@ -63,6 +64,12 @@ Production module packages live under `internal/modules`:
Each module package owns its contract implementation, module spec,
registration, options, focused tests, and module-specific errors.
Module-owned prompts and schemas live in each module's shallow `assets/prompts`
and `assets/schemas` directories. Shared reusable D&D prompt fragments live in
`internal/modules/sharedassets`.
Shared asset package: `internal/modules/sharedassets`
## Fixtures And Tests
The repository uses focused package tests plus a fixture-driven CLI workflow.
@@ -81,6 +88,7 @@ servers.
- Source-format details stay in input modules and integration docs.
- Extraction-domain details stay in extract modules and artifact docs.
- Shared prompt fragments stay in `internal/modules/sharedassets`.
- Provider wire details stay in the LLM runtime and provider integration docs.
- Durable output contracts belong in integration docs.
- Operator procedures belong in `docs/operations.md`, not internal docs.

View File

@@ -11,12 +11,12 @@ import (
"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/chunk/generic"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/input/seriatim"
"gitea.maximumdirect.net/eric/notarius/internal/modules/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
)
func productionRegistries() (pipeline.Registries, error) {
@@ -63,7 +63,7 @@ func productionCatalog() (pipeline.ModuleCatalog, error) {
func productionPromptAssets() (*llm.AssetRegistry, error) {
registry := llm.NewAssetRegistry()
if err := promptassets.Register(registry); err != nil {
if err := sharedassets.Register(registry); err != nil {
return nil, fmt.Errorf("register shared dnd prompt assets: %w", err)
}
if err := scenes.RegisterPromptAssets(registry); err != nil {

View File

@@ -11,6 +11,7 @@ import (
"sort"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
@@ -25,6 +26,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
"gitea.maximumdirect.net/eric/scriptorium"
)
func TestRunNoArgsWritesUsageToStdout(t *testing.T) {
@@ -179,6 +181,86 @@ func TestProductionCatalogIncludesDefaultModules(t *testing.T) {
}
}
func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) {
registry, err := productionPromptAssets()
if err != nil {
t.Fatalf("productionPromptAssets() error = %v, want nil", err)
}
promptFS, err := registry.PromptFS()
if err != nil {
t.Fatalf("PromptFS() error = %v, want nil", err)
}
for _, name := range []string{
"common-dnd-system.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
"dnd.scenes/dnd.scenes.yaml",
"dnd.scenes/task.md",
"dnd.scenes/instructions.md",
"dnd.spells/dnd.spells.yaml",
"dnd.spells/task.md",
"dnd.spells/instructions.md",
} {
if _, err := promptFS.Open(name); err != nil {
t.Fatalf("PromptFS().Open(%q) error = %v, want nil", name, err)
}
}
options, err := registry.ScriptoriumOptions()
if err != nil {
t.Fatalf("ScriptoriumOptions() error = %v, want nil", err)
}
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "production-test-profile",
Endpoint: "http://127.0.0.1:1/v1",
Model: "production-test-model",
})))
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v, want nil", err)
}
scenesPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: scenes.PromptID,
PromptVersion: scenes.ResponseSchemaVersion,
ProfileID: "production-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", `{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`),
"roster": scriptorium.Inline("Aria: cleric"),
"glossary": scriptorium.Inline("Brightmantle: temple"),
},
})
if err != nil {
t.Fatalf("scene Prepare() error = %v, want nil", err)
}
if got := len(scenesPrepared.Messages); got != 5 {
t.Fatalf("scene message count = %d, want 5", got)
}
if !strings.Contains(scenesPrepared.Messages[3].Content, "Divide the provided transcript") {
t.Fatalf("scene task message missing module text: %q", scenesPrepared.Messages[3].Content)
}
spellsPrepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: spells.PromptID,
PromptVersion: spells.SchemaVersion,
ProfileID: "production-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.InlineWithURI("file:///session.json", `{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`),
"roster": scriptorium.Inline("Mira: wizard"),
"glossary": scriptorium.Inline("Shield: abjuration"),
},
})
if err != nil {
t.Fatalf("spell Prepare() error = %v, want nil", err)
}
if got := len(spellsPrepared.Messages); got != 5 {
t.Fatalf("spell message count = %d, want 5", got)
}
if !strings.Contains(spellsPrepared.Messages[3].Content, "Extract Dungeons & Dragons spell-cast artifacts") {
t.Fatalf("spell task message missing module text: %q", spellsPrepared.Messages[3].Content)
}
}
func TestRunConfigValidateUsesProductionCatalogByDefault(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
var stdout bytes.Buffer

View File

@@ -98,6 +98,37 @@ func TestAssetRegistryRejectsDuplicateAssetPaths(t *testing.T) {
}
}
func TestAssetRegistryCombinesNamespacedPromptSources(t *testing.T) {
registry := NewAssetRegistry()
mustRegisterPromptFS(t, registry, fstest.MapFS{
"dnd.spells/dnd.spells.yaml": {Data: []byte(validPromptYAML("schema.json"))},
"dnd.spells/task.md": {Data: []byte("spell task")},
"dnd.spells/instructions.md": {Data: []byte("spell instructions")},
}, ".")
mustRegisterPromptFS(t, registry, fstest.MapFS{
"dnd.scenes/dnd.scenes.yaml": {Data: []byte(validPromptYAML("schema.json"))},
"dnd.scenes/task.md": {Data: []byte("scene task")},
"dnd.scenes/instructions.md": {Data: []byte("scene instructions")},
}, ".")
fsys, err := registry.PromptFS()
if err != nil {
t.Fatalf("PromptFS() error = %v, want nil", err)
}
for _, name := range []string{
"dnd.spells/dnd.spells.yaml",
"dnd.spells/task.md",
"dnd.spells/instructions.md",
"dnd.scenes/dnd.scenes.yaml",
"dnd.scenes/task.md",
"dnd.scenes/instructions.md",
} {
if _, err := fsys.Open(name); err != nil {
t.Fatalf("PromptFS().Open(%q) error = %v, want nil", name, err)
}
}
}
func TestHashAssetsOmitsRawAssetContent(t *testing.T) {
hash, err := HashAssets([]AssetHashPart{{
FS: fstest.MapFS{"prompt.md": {Data: []byte("secret prompt text")}},

View File

@@ -2,5 +2,5 @@ package scenes
import "embed"
//go:embed assets/schemas/*.json assets/scriptorium/prompts/*.yaml assets/scriptorium/prompts/dnd/scenes/*.md
//go:embed assets/schemas/*.json assets/prompts/*.yaml assets/prompts/*.md
var embeddedAssets embed.FS

View File

@@ -13,19 +13,19 @@ inputs:
content_type: text/plain
messages:
- role: system
content_file: ./shared/system.md
content_file: ./common-dnd-system.md
- role: user
content_file: ./shared/transcript.md
content_file: ./common-dnd-transcript.md
cache_control:
type: ephemeral
- role: user
content_file: ./shared/references.md
content_file: ./common-dnd-references.md
cache_control:
type: ephemeral
- role: user
content_file: ./dnd/scenes/task.md
content_file: ./task.md
- role: user
content_file: ./dnd/scenes/instructions.md
content_file: ./instructions.md
output:
format: json
validation_mode: json_schema

View File

@@ -0,0 +1,194 @@
package scenes
import (
"bytes"
"fmt"
"io"
"io/fs"
"path"
"sort"
"strings"
"time"
)
func modulePromptFS(shared fs.FS) (fs.FS, error) {
assetFS, err := promptMapFSFromEmbedded(map[string]string{
"assets/prompts/dnd.scenes/dnd.scenes.yaml": "assets/prompts/dnd.scenes.yaml",
"assets/prompts/dnd.scenes/task.md": "assets/prompts/task.md",
"assets/prompts/dnd.scenes/instructions.md": "assets/prompts/instructions.md",
})
if err != nil {
return nil, err
}
assets := assetFS.(promptMapFS)
if shared == nil {
return assets, nil
}
for _, name := range []string{
"common-dnd-system.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
} {
data, err := fs.ReadFile(shared, name)
if err != nil {
return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err)
}
assets["assets/prompts/dnd.scenes/"+name] = append([]byte(nil), data...)
}
return assets, nil
}
func promptMapFSFromEmbedded(files map[string]string) (fs.FS, error) {
assets := make(promptMapFS, len(files))
for virtualPath, embeddedPath := range files {
data, err := fs.ReadFile(embeddedAssets, embeddedPath)
if err != nil {
return nil, fmt.Errorf("read prompt asset %s: %w", embeddedPath, err)
}
assets[virtualPath] = append([]byte(nil), data...)
}
return assets, nil
}
type promptMapFS map[string][]byte
func (m promptMapFS) Open(name string) (fs.File, error) {
cleaned, err := cleanPromptPath(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 := cleanPromptPath(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
}
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

@@ -3,23 +3,40 @@ package scenes
import (
"bytes"
"fmt"
"io/fs"
"sort"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
)
const scriptoriumPromptRoot = "assets/scriptorium/prompts"
const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err := registry.RegisterPromptFS(embeddedAssets, scriptoriumPromptRoot); err != nil {
sharedPromptFS, err := promptSharedFS()
if err != nil {
return fmt.Errorf("prepare shared prompt assets: %w", err)
}
promptFS, err := modulePromptFS(sharedPromptFS)
if err != nil {
return fmt.Errorf("prepare scene prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func promptSharedFS() (fs.FS, error) {
registry := llm.NewAssetRegistry()
if err := sharedassets.Register(registry); err != nil {
return nil, err
}
return registry.PromptFS()
}
func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet {
return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput),
@@ -48,10 +65,10 @@ func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot)
func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.scenes.yaml"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/task.md"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/instructions.md"},
}, append(promptassets.CommonHashParts(), promptassets.ReferenceHashParts()...)...)
{FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"},
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
}, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
})
return scriptoriumPromptHash, scriptoriumPromptHashErr

View File

@@ -8,7 +8,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
"gitea.maximumdirect.net/eric/scriptorium"
)
@@ -91,7 +91,7 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
func prepareScenesPrompt(t *testing.T, transcript []byte, roster string, glossary string) *scriptorium.PreparedRun {
t.Helper()
registry := llm.NewAssetRegistry()
if err := promptassets.Register(registry); err != nil {
if err := sharedassets.Register(registry); err != nil {
t.Fatalf("register shared prompt assets: %v", err)
}
if err := RegisterPromptAssets(registry); err != nil {

View File

@@ -2,5 +2,5 @@ package spells
import "embed"
//go:embed assets/schemas/*.json assets/scriptorium/prompts/*.yaml assets/scriptorium/prompts/dnd/spells/*.md
//go:embed assets/schemas/*.json assets/prompts/*.yaml assets/prompts/*.md
var embeddedAssets embed.FS

View File

@@ -13,19 +13,19 @@ inputs:
content_type: text/plain
messages:
- role: system
content_file: ./shared/system.md
content_file: ./common-dnd-system.md
- role: user
content_file: ./shared/transcript.md
content_file: ./common-dnd-transcript.md
cache_control:
type: ephemeral
- role: user
content_file: ./shared/references.md
content_file: ./common-dnd-references.md
cache_control:
type: ephemeral
- role: user
content_file: ./dnd/spells/task.md
content_file: ./task.md
- role: user
content_file: ./dnd/spells/instructions.md
content_file: ./instructions.md
output:
format: json
validation_mode: json_schema

View File

@@ -0,0 +1,194 @@
package spells
import (
"bytes"
"fmt"
"io"
"io/fs"
"path"
"sort"
"strings"
"time"
)
func modulePromptFS(shared fs.FS) (fs.FS, error) {
assetFS, err := promptMapFSFromEmbedded(map[string]string{
"assets/prompts/dnd.spells/dnd.spells.yaml": "assets/prompts/dnd.spells.yaml",
"assets/prompts/dnd.spells/task.md": "assets/prompts/task.md",
"assets/prompts/dnd.spells/instructions.md": "assets/prompts/instructions.md",
})
if err != nil {
return nil, err
}
assets := assetFS.(promptMapFS)
if shared == nil {
return assets, nil
}
for _, name := range []string{
"common-dnd-system.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
} {
data, err := fs.ReadFile(shared, name)
if err != nil {
return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err)
}
assets["assets/prompts/dnd.spells/"+name] = append([]byte(nil), data...)
}
return assets, nil
}
func promptMapFSFromEmbedded(files map[string]string) (fs.FS, error) {
assets := make(promptMapFS, len(files))
for virtualPath, embeddedPath := range files {
data, err := fs.ReadFile(embeddedAssets, embeddedPath)
if err != nil {
return nil, fmt.Errorf("read prompt asset %s: %w", embeddedPath, err)
}
assets[virtualPath] = append([]byte(nil), data...)
}
return assets, nil
}
type promptMapFS map[string][]byte
func (m promptMapFS) Open(name string) (fs.File, error) {
cleaned, err := cleanPromptPath(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 := cleanPromptPath(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
}
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

@@ -3,23 +3,40 @@ package spells
import (
"bytes"
"fmt"
"io/fs"
"sort"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
)
const scriptoriumPromptRoot = "assets/scriptorium/prompts"
const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err := registry.RegisterPromptFS(embeddedAssets, scriptoriumPromptRoot); err != nil {
sharedPromptFS, err := promptSharedFS()
if err != nil {
return fmt.Errorf("prepare shared prompt assets: %w", err)
}
promptFS, err := modulePromptFS(sharedPromptFS)
if err != nil {
return fmt.Errorf("prepare spell prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func promptSharedFS() (fs.FS, error) {
registry := llm.NewAssetRegistry()
if err := sharedassets.Register(registry); err != nil {
return nil, err
}
return registry.PromptFS()
}
func promptInputs(req contracts.ExtractionRequest) contracts.LLMInputSet {
return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput),
@@ -48,10 +65,10 @@ func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot)
func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.spells.yaml"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/task.md"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/instructions.md"},
}, append(promptassets.CommonHashParts(), promptassets.ReferenceHashParts()...)...)
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
}, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
})
return scriptoriumPromptHash, scriptoriumPromptHashErr

View File

@@ -9,7 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/promptassets"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
"gitea.maximumdirect.net/eric/scriptorium"
)
@@ -144,7 +144,7 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
func prepareSpellsPrompt(t *testing.T, transcript []byte, roster string, glossary string) *scriptorium.PreparedRun {
t.Helper()
registry := llm.NewAssetRegistry()
if err := promptassets.Register(registry); err != nil {
if err := sharedassets.Register(registry); err != nil {
t.Fatalf("register shared prompt assets: %v", err)
}
if err := RegisterPromptAssets(registry); err != nil {

View File

@@ -1,4 +1,4 @@
package promptassets
package sharedassets
import (
"embed"
@@ -6,7 +6,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
)
//go:embed assets/prompts/shared/*.md
//go:embed assets/prompts/*.md
var embeddedAssets embed.FS
func Register(registry *llm.AssetRegistry) error {
@@ -15,13 +15,13 @@ func Register(registry *llm.AssetRegistry) error {
func CommonHashParts() []llm.AssetHashPart {
return []llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/prompts/shared/system.md"},
{FS: embeddedAssets, Path: "assets/prompts/shared/transcript.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
}
}
func ReferenceHashParts() []llm.AssetHashPart {
return []llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/prompts/shared/references.md"},
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-references.md"},
}
}