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. rendered reference bytes.
LLM-backed modules own Scriptorium prompt definitions and response schemas in LLM-backed modules own Scriptorium prompt definitions and response schemas in
their embedded assets. Module contracts should expose prompt IDs, versions, their embedded assets. Module-owned prompts live under each module's shallow
input material names, and non-secret prompt/schema hashes through manifest `assets/prompts` tree and schemas live under `assets/schemas`. Shared reusable
metadata; they should not expose Scriptorium public types through chunk, prompt fragments live under `internal/modules/sharedassets`. Module contracts
extract, or normalize 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 Chunk modules receive the structured LLM client, configured Scriptorium profile
ID, prompt session ID, and raw source input material through 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 session ID to the runtime; validates model-authored source-unit boundaries; and
converts each scene into a deterministic source chunk. 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: Requires:
- `source.transcript` - `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 spell-cast responses into artifact candidates; and supplies deterministic
validators. 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: Requires:
- `chunks` - `chunks`

View File

@@ -55,6 +55,7 @@ Production module packages live under `internal/modules`:
- `input/seriatim` - `input/seriatim`
- `chunk/generic` - `chunk/generic`
- `chunk/dnd/scenes`
- `extract/dnd/spells` - `extract/dnd/spells`
- `merge/appendorder` - `merge/appendorder`
- `normalize/noop` - `normalize/noop`
@@ -63,6 +64,12 @@ Production module packages live under `internal/modules`:
Each module package owns its contract implementation, module spec, Each module package owns its contract implementation, module spec,
registration, options, focused tests, and module-specific errors. 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 ## Fixtures And Tests
The repository uses focused package tests plus a fixture-driven CLI workflow. 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. - Source-format details stay in input modules and integration docs.
- Extraction-domain details stay in extract modules and artifact 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. - Provider wire details stay in the LLM runtime and provider integration docs.
- Durable output contracts belong in integration docs. - Durable output contracts belong in integration docs.
- Operator procedures belong in `docs/operations.md`, not internal 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/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes" "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/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/extract/dnd/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/input/seriatim" "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/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop" "gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json" jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
) )
func productionRegistries() (pipeline.Registries, error) { func productionRegistries() (pipeline.Registries, error) {
@@ -63,7 +63,7 @@ func productionCatalog() (pipeline.ModuleCatalog, error) {
func productionPromptAssets() (*llm.AssetRegistry, error) { func productionPromptAssets() (*llm.AssetRegistry, error) {
registry := llm.NewAssetRegistry() 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) return nil, fmt.Errorf("register shared dnd prompt assets: %w", err)
} }
if err := scenes.RegisterPromptAssets(registry); err != nil { if err := scenes.RegisterPromptAssets(registry); err != nil {

View File

@@ -11,6 +11,7 @@ import (
"sort" "sort"
"strings" "strings"
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config" "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/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop" "gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json" jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
"gitea.maximumdirect.net/eric/scriptorium"
) )
func TestRunNoArgsWritesUsageToStdout(t *testing.T) { 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) { func TestRunConfigValidateUsesProductionCatalogByDefault(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
var stdout bytes.Buffer 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) { func TestHashAssetsOmitsRawAssetContent(t *testing.T) {
hash, err := HashAssets([]AssetHashPart{{ hash, err := HashAssets([]AssetHashPart{{
FS: fstest.MapFS{"prompt.md": {Data: []byte("secret prompt text")}}, FS: fstest.MapFS{"prompt.md": {Data: []byte("secret prompt text")}},

View File

@@ -2,5 +2,5 @@ package scenes
import "embed" 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 var embeddedAssets embed.FS

View File

@@ -13,19 +13,19 @@ inputs:
content_type: text/plain content_type: text/plain
messages: messages:
- role: system - role: system
content_file: ./shared/system.md content_file: ./common-dnd-system.md
- role: user - role: user
content_file: ./shared/transcript.md content_file: ./common-dnd-transcript.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./shared/references.md content_file: ./common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./dnd/scenes/task.md content_file: ./task.md
- role: user - role: user
content_file: ./dnd/scenes/instructions.md content_file: ./instructions.md
output: output:
format: json format: json
validation_mode: json_schema 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 ( import (
"bytes" "bytes"
"fmt" "fmt"
"io/fs"
"sort" "sort"
"sync" "sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "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 { 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 err
} }
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas") 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 { func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet {
return contracts.LLMInputSet{ return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput), "transcript": transcriptPromptInput(req.SourceInput),
@@ -48,10 +65,10 @@ func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot)
func scriptoriumPromptMetadata() (string, error) { func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() { scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{ parts := append([]llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.scenes.yaml"}, {FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/task.md"}, {FS: embeddedAssets, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/instructions.md"}, {FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
}, append(promptassets.CommonHashParts(), promptassets.ReferenceHashParts()...)...) }, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
}) })
return scriptoriumPromptHash, scriptoriumPromptHashErr return scriptoriumPromptHash, scriptoriumPromptHashErr

View File

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

View File

@@ -2,5 +2,5 @@ package spells
import "embed" 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 var embeddedAssets embed.FS

View File

@@ -13,19 +13,19 @@ inputs:
content_type: text/plain content_type: text/plain
messages: messages:
- role: system - role: system
content_file: ./shared/system.md content_file: ./common-dnd-system.md
- role: user - role: user
content_file: ./shared/transcript.md content_file: ./common-dnd-transcript.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./shared/references.md content_file: ./common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./dnd/spells/task.md content_file: ./task.md
- role: user - role: user
content_file: ./dnd/spells/instructions.md content_file: ./instructions.md
output: output:
format: json format: json
validation_mode: json_schema 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 ( import (
"bytes" "bytes"
"fmt" "fmt"
"io/fs"
"sort" "sort"
"sync" "sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "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 { 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 err
} }
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas") 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 { func promptInputs(req contracts.ExtractionRequest) contracts.LLMInputSet {
return contracts.LLMInputSet{ return contracts.LLMInputSet{
"transcript": transcriptPromptInput(req.SourceInput), "transcript": transcriptPromptInput(req.SourceInput),
@@ -48,10 +65,10 @@ func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot)
func scriptoriumPromptMetadata() (string, error) { func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() { scriptoriumPromptHashOnce.Do(func() {
parts := append([]llm.AssetHashPart{ parts := append([]llm.AssetHashPart{
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.spells.yaml"}, {FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/task.md"}, {FS: embeddedAssets, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/instructions.md"}, {FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
}, append(promptassets.CommonHashParts(), promptassets.ReferenceHashParts()...)...) }, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
}) })
return scriptoriumPromptHash, scriptoriumPromptHashErr 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/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "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" "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 { func prepareSpellsPrompt(t *testing.T, transcript []byte, roster string, glossary string) *scriptorium.PreparedRun {
t.Helper() t.Helper()
registry := llm.NewAssetRegistry() 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) t.Fatalf("register shared prompt assets: %v", err)
} }
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {

View File

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