Compare commits
5 Commits
3217bb3e12
...
ecba0ad725
| Author | SHA1 | Date | |
|---|---|---|---|
| ecba0ad725 | |||
| b3757dcf7b | |||
| 3e456ec4d4 | |||
| 5b1efc89f6 | |||
| aee48d011e |
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
194
internal/modules/chunk/dnd/scenes/prompt_fs.go
Normal file
194
internal/modules/chunk/dnd/scenes/prompt_fs.go
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
194
internal/modules/extract/dnd/spells/prompt_fs.go
Normal file
194
internal/modules/extract/dnd/spells/prompt_fs.go
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user