Add Scriptorium configuration contract
This commit is contained in:
20
README.md
20
README.md
@@ -8,7 +8,7 @@ This repository currently contains a **working scaffold** with strict config loa
|
|||||||
|
|
||||||
`narratio` expects two YAML files:
|
`narratio` expects two YAML files:
|
||||||
|
|
||||||
- `pipeline.yml`: pipeline/workspace settings (`workspace`, `storage`, `whisperx`, `seriatim`, `audita`, `analyzer`, `notification`)
|
- `pipeline.yml`: pipeline/workspace settings (`workspace`, `storage`, `whisperx`, `seriatim`, `audita`, optional `scriptorium`, `analyzer`, `notification`)
|
||||||
- `session.yml`: per-session settings (`session_id`, `inputs`, optional metadata)
|
- `session.yml`: per-session settings (`session_id`, `inputs`, optional metadata)
|
||||||
|
|
||||||
WhisperX config contract in `pipeline.yml`:
|
WhisperX config contract in `pipeline.yml`:
|
||||||
@@ -43,6 +43,24 @@ Audita runtime note:
|
|||||||
|
|
||||||
- Narratio currently passes primary LLM concurrency via `AUDITA_LLM_CONCURRENCY` subprocess environment override, not a `--llm-concurrency` flag.
|
- Narratio currently passes primary LLM concurrency via `AUDITA_LLM_CONCURRENCY` subprocess environment override, not a `--llm-concurrency` flag.
|
||||||
|
|
||||||
|
Scriptorium config contract in `pipeline.yml` (optional until `analyze` is implemented):
|
||||||
|
|
||||||
|
- optional section: `scriptorium` (when omitted, current pipeline behavior is unchanged)
|
||||||
|
- required when `scriptorium` is present: `scriptorium.binary` (name on PATH or full path)
|
||||||
|
- optional: `scriptorium.config_path` (if set, it must be non-empty; file existence is validated at execution time, not basic config validation time)
|
||||||
|
- defaulted when omitted inside present `scriptorium` section: `scriptorium.timeout` (`10m`)
|
||||||
|
- optional: `scriptorium.render_debug` (`false` by default through YAML zero-value behavior)
|
||||||
|
- artifact definitions live under `scriptorium.artifacts.<artifact_name>` and support multiple configured artifacts
|
||||||
|
- enabled artifacts require `prompt_id` and `output_path`
|
||||||
|
- artifact `timeout` values must be valid Go durations when provided
|
||||||
|
- input definitions live under `scriptorium.artifacts.<artifact_name>.inputs.<input_name>` and require non-empty `source`
|
||||||
|
- `vars` values currently accept booleans and strings only
|
||||||
|
- Narratio does not store secrets in Scriptorium config; keep credentials in environment management, not YAML
|
||||||
|
|
||||||
|
Scriptorium initial artifact note:
|
||||||
|
|
||||||
|
- this pass does not auto-create `session_recap`; configure `scriptorium.artifacts.session_recap` explicitly in `pipeline.yml`
|
||||||
|
|
||||||
`speakers.yml` note:
|
`speakers.yml` note:
|
||||||
|
|
||||||
- use Seriatim’s documented `match:` format (not the legacy direct mapping style used by older scripts/scaffolds)
|
- use Seriatim’s documented `match:` format (not the legacy direct mapping style used by older scripts/scaffolds)
|
||||||
|
|||||||
@@ -43,6 +43,34 @@ audita:
|
|||||||
validation_llm_concurrency: 1
|
validation_llm_concurrency: 1
|
||||||
report: true
|
report: true
|
||||||
|
|
||||||
|
scriptorium:
|
||||||
|
binary: "scriptorium"
|
||||||
|
config_path: "/etc/scriptorium/config.yml"
|
||||||
|
timeout: "10m"
|
||||||
|
render_debug: false
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: "dnd.session_recap"
|
||||||
|
profile_id: "local-quality"
|
||||||
|
output_path: "artifacts/session_recap.md"
|
||||||
|
timeout: "10m"
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: "processed_transcript"
|
||||||
|
required: true
|
||||||
|
previous_recap:
|
||||||
|
source: "previous_session_artifact"
|
||||||
|
artifact: "session_recap"
|
||||||
|
path: ""
|
||||||
|
required: false
|
||||||
|
vars:
|
||||||
|
session_id: true
|
||||||
|
session_date: true
|
||||||
|
campaign_name: true
|
||||||
|
previous_session_id: true
|
||||||
|
output_kind: "session_recap"
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
timeout: 20m
|
timeout: 20m
|
||||||
artifacts:
|
artifacts:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type PipelineConfig struct {
|
|||||||
WhisperX WhisperXConfig `yaml:"whisperx"`
|
WhisperX WhisperXConfig `yaml:"whisperx"`
|
||||||
Seriatim SeriatimConfig `yaml:"seriatim"`
|
Seriatim SeriatimConfig `yaml:"seriatim"`
|
||||||
Audita AuditaConfig `yaml:"audita"`
|
Audita AuditaConfig `yaml:"audita"`
|
||||||
|
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
||||||
Notification NotificationConfig `yaml:"notification"`
|
Notification NotificationConfig `yaml:"notification"`
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,34 @@ type AuditaConfig struct {
|
|||||||
Report *bool `yaml:"report"`
|
Report *bool `yaml:"report"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
|
||||||
|
type ScriptoriumConfig struct {
|
||||||
|
Binary string `yaml:"binary"`
|
||||||
|
ConfigPath string `yaml:"config_path"`
|
||||||
|
Timeout string `yaml:"timeout"`
|
||||||
|
RenderDebug bool `yaml:"render_debug"`
|
||||||
|
Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptoriumArtifactConfig configures one named output artifact workflow.
|
||||||
|
type ScriptoriumArtifactConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
PromptID string `yaml:"prompt_id"`
|
||||||
|
ProfileID string `yaml:"profile_id"`
|
||||||
|
OutputPath string `yaml:"output_path"`
|
||||||
|
Timeout string `yaml:"timeout"`
|
||||||
|
Inputs map[string]ScriptoriumInputConfig `yaml:"inputs"`
|
||||||
|
Vars map[string]any `yaml:"vars"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptoriumInputConfig configures one named prompt input source.
|
||||||
|
type ScriptoriumInputConfig struct {
|
||||||
|
Source string `yaml:"source"`
|
||||||
|
Artifact string `yaml:"artifact"`
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
// AnalyzerConfig configures analyzer adapter settings.
|
// AnalyzerConfig configures analyzer adapter settings.
|
||||||
type AnalyzerConfig struct {
|
type AnalyzerConfig struct {
|
||||||
BinaryPath string `yaml:"binary_path"`
|
BinaryPath string `yaml:"binary_path"`
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
|||||||
applyWhisperXDefaults(&cfg.WhisperX)
|
applyWhisperXDefaults(&cfg.WhisperX)
|
||||||
applySeriatimDefaults(&cfg.Seriatim)
|
applySeriatimDefaults(&cfg.Seriatim)
|
||||||
applyAuditaDefaults(&cfg.Audita)
|
applyAuditaDefaults(&cfg.Audita)
|
||||||
|
applyScriptoriumDefaults(cfg.Scriptorium)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyWhisperXDefaults(cfg *WhisperXConfig) {
|
func applyWhisperXDefaults(cfg *WhisperXConfig) {
|
||||||
@@ -165,6 +166,15 @@ func applyAuditaDefaults(cfg *AuditaConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyScriptoriumDefaults(cfg *ScriptoriumConfig) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Timeout == "" {
|
||||||
|
cfg.Timeout = "10m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func float64Ptr(v float64) *float64 {
|
func float64Ptr(v float64) *float64 {
|
||||||
p := v
|
p := v
|
||||||
return &p
|
return &p
|
||||||
|
|||||||
203
internal/config/scriptorium_test.go
Normal file
203
internal/config/scriptorium_test.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScriptoriumLoadAndValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scriptoriumYAML string
|
||||||
|
wantLoadErr string
|
||||||
|
wantValidateErr string
|
||||||
|
assert func(t *testing.T, cfg *Config)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid minimal scriptorium config",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: processed_transcript
|
||||||
|
required: true
|
||||||
|
vars:
|
||||||
|
session_id: true
|
||||||
|
output_kind: session_recap
|
||||||
|
`,
|
||||||
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Scriptorium == nil {
|
||||||
|
t.Fatal("scriptorium config should be present")
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Scriptorium.Timeout != "10m" {
|
||||||
|
t.Fatalf("scriptorium.timeout default = %q, want 10m", cfg.Pipeline.Scriptorium.Timeout)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown scriptorium field fails",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
bogus: true
|
||||||
|
`,
|
||||||
|
wantLoadErr: "strict decode failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing binary fails when section present",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
timeout: 10m
|
||||||
|
`,
|
||||||
|
wantValidateErr: "pipeline.scriptorium.binary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled artifact missing prompt id fails",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
`,
|
||||||
|
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.prompt_id is required when enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled artifact missing output path fails",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
`,
|
||||||
|
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.output_path is required when enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid timeout fails",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
timeout: definitely-not-a-duration
|
||||||
|
`,
|
||||||
|
wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional previous recap input is accepted",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: processed_transcript
|
||||||
|
required: true
|
||||||
|
previous_recap:
|
||||||
|
source: previous_session_artifact
|
||||||
|
artifact: session_recap
|
||||||
|
path: ""
|
||||||
|
required: false
|
||||||
|
vars:
|
||||||
|
session_id: true
|
||||||
|
output_kind: session_recap
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple artifact definitions can be decoded",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: processed_transcript
|
||||||
|
required: true
|
||||||
|
player_summary:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.player_summary
|
||||||
|
profile_id: local-fast
|
||||||
|
output_path: artifacts/player_summary.md
|
||||||
|
timeout: 3m
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: processed_transcript
|
||||||
|
required: true
|
||||||
|
`,
|
||||||
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Scriptorium == nil {
|
||||||
|
t.Fatal("scriptorium config should be present")
|
||||||
|
}
|
||||||
|
if len(cfg.Pipeline.Scriptorium.Artifacts) != 2 {
|
||||||
|
t.Fatalf("scriptorium.artifacts count = %d, want 2", len(cfg.Pipeline.Scriptorium.Artifacts))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
pipelineYAML := testPipelineBaseYAML + "\n" + tt.scriptoriumYAML
|
||||||
|
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
||||||
|
|
||||||
|
cfg, err := Load(pipelinePath, sessionPath)
|
||||||
|
if tt.wantLoadErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantLoadErr) {
|
||||||
|
t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.assert != nil {
|
||||||
|
tt.assert(t, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Validate(cfg)
|
||||||
|
if tt.wantValidateErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantValidateErr) {
|
||||||
|
t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidateErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testPipelineBaseYAML = `workspace:
|
||||||
|
root: /tmp/narratio
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
|
seriatim:
|
||||||
|
binary: seriatim
|
||||||
|
audita:
|
||||||
|
binary: audita
|
||||||
|
`
|
||||||
|
|
||||||
|
const testSessionBaseYAML = `session_id: 2026-05-03
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
@@ -42,6 +42,9 @@ func validatePipeline(cfg *PipelineConfig) error {
|
|||||||
if err := validateAudita(cfg.Audita); err != nil {
|
if err := validateAudita(cfg.Audita); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateScriptorium(cfg.Scriptorium); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil {
|
if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -170,6 +173,58 @@ func validateAudita(cfg AuditaConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateScriptorium(cfg *ScriptoriumConfig) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Binary) == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.binary is required when pipeline.scriptorium is configured")
|
||||||
|
}
|
||||||
|
if cfg.ConfigPath != "" && strings.TrimSpace(cfg.ConfigPath) == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.config_path must be non-empty when provided")
|
||||||
|
}
|
||||||
|
if err := validateDuration("pipeline.scriptorium.timeout", cfg.Timeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for artifactName, artifactCfg := range cfg.Artifacts {
|
||||||
|
trimmedArtifactName := strings.TrimSpace(artifactName)
|
||||||
|
if trimmedArtifactName == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts keys must be non-empty")
|
||||||
|
}
|
||||||
|
if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.PromptID) == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.prompt_id is required when enabled", artifactName)
|
||||||
|
}
|
||||||
|
if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.OutputPath) == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when enabled", artifactName)
|
||||||
|
}
|
||||||
|
if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for inputName, inputCfg := range artifactCfg.Inputs {
|
||||||
|
trimmedInputName := strings.TrimSpace(inputName)
|
||||||
|
if trimmedInputName == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs keys must be non-empty", artifactName)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(inputCfg.Source) == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source is required", artifactName, inputName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for varName, varValue := range artifactCfg.Vars {
|
||||||
|
if strings.TrimSpace(varName) == "" {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars keys must be non-empty", artifactName)
|
||||||
|
}
|
||||||
|
switch varValue.(type) {
|
||||||
|
case bool, string:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars.%s must be a string or boolean", artifactName, varName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateSession(cfg *SessionConfig) error {
|
func validateSession(cfg *SessionConfig) error {
|
||||||
if strings.TrimSpace(cfg.SessionID) == "" {
|
if strings.TrimSpace(cfg.SessionID) == "" {
|
||||||
return fmt.Errorf("session.session_id is required")
|
return fmt.Errorf("session.session_id is required")
|
||||||
|
|||||||
Reference in New Issue
Block a user