Add previous session ID templating and CLI support
This commit is contained in:
15
docs/cli.md
15
docs/cli.md
@@ -30,6 +30,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
|
||||
- `--config <path>`: optional explicit `pipeline.yml` path.
|
||||
- `--session <path>`: optional explicit `session.yml` path.
|
||||
- `--session-id <value>`: session template variable value.
|
||||
- `--previous-session-id <value>`: previous-session template variable value.
|
||||
- `--force`: force stage execution.
|
||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
||||
|
||||
@@ -38,6 +39,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
|
||||
- `--config <path>`
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--previous-session-id <value>`
|
||||
- `--force`
|
||||
|
||||
### `resume`
|
||||
@@ -45,6 +47,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
|
||||
- `--config <path>`
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--previous-session-id <value>`
|
||||
- `--force`
|
||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
||||
|
||||
@@ -53,6 +56,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
|
||||
- `--config <path>`
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--previous-session-id <value>`
|
||||
- `--force`
|
||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
||||
- positional `<stage>`: required stage name.
|
||||
@@ -74,6 +78,7 @@ Valid stage names:
|
||||
- `--config <path>`
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--previous-session-id <value>`
|
||||
- `--dry-run`: plan restore actions without writing local files.
|
||||
- `--force`: overwrite local conflicting files with remote archive files.
|
||||
- `--include-audio`: include durable archived `audio/**` files in restore scope.
|
||||
@@ -92,7 +97,7 @@ Purpose:
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>]
|
||||
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
|
||||
```
|
||||
|
||||
Success output:
|
||||
@@ -112,7 +117,7 @@ Purpose:
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force]
|
||||
narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force]
|
||||
```
|
||||
|
||||
Success output includes:
|
||||
@@ -132,7 +137,7 @@ Purpose:
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>]
|
||||
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
|
||||
```
|
||||
|
||||
Success output:
|
||||
@@ -172,7 +177,7 @@ Purpose:
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
|
||||
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
|
||||
```
|
||||
|
||||
Success output:
|
||||
@@ -196,7 +201,7 @@ Purpose:
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio restore [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--dry-run] [--force] [--include-audio]
|
||||
narratio restore [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio]
|
||||
```
|
||||
|
||||
Success output (dry-run):
|
||||
|
||||
@@ -48,9 +48,13 @@ Template behavior:
|
||||
- supported placeholders:
|
||||
- `{{session_id}}`
|
||||
- `{{ session_id }}`
|
||||
- `{{previous_session_id}}`
|
||||
- `{{ previous_session_id }}`
|
||||
- `--session-id <value>` supplies the placeholder value.
|
||||
- `--previous-session-id <value>` supplies the previous-session placeholder value.
|
||||
- unresolved placeholders fail load.
|
||||
- if rendered `session_id` mismatches `--session-id`, load fails.
|
||||
- if rendered `previous_session_id` mismatches `--previous-session-id`, load fails.
|
||||
|
||||
## 4. Minimal pipeline config
|
||||
|
||||
@@ -69,6 +73,7 @@ Why this is sufficient:
|
||||
|
||||
```yaml
|
||||
session_id: "{{ session_id }}"
|
||||
previous_session_id: "{{ previous_session_id }}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
@@ -279,6 +284,7 @@ Restore-related implications:
|
||||
| Path | Type | Required | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| `session.session_id` | string | Yes | none |
|
||||
| `session.previous_session_id` | string | No | empty |
|
||||
| `session.campaign` | string | Yes | none |
|
||||
| `session.date` | string | No | empty |
|
||||
| `session.title` | string | No | empty |
|
||||
@@ -297,6 +303,10 @@ Audio-source rule:
|
||||
- `audio_s3.prefix`
|
||||
- `audio_s3` cannot be combined with local audio fields.
|
||||
|
||||
Previous-session rule:
|
||||
|
||||
- if `session.previous_session_id` is set, it must not equal `session.session_id`.
|
||||
|
||||
## 9. Secrets
|
||||
|
||||
Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`.
|
||||
|
||||
@@ -22,10 +22,12 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -44,7 +46,8 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
|
||||
@@ -28,17 +28,19 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var dryRun bool
|
||||
var force bool
|
||||
var includeAudio bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
||||
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
|
||||
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
|
||||
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
|
||||
fs.Usage = func() {
|
||||
_, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--session <path>] [--session-id <value>] [--dry-run] [--force] [--include-audio]")
|
||||
_, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--session <path>] [--session-id <value>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
|
||||
_, _ = fmt.Fprintln(out)
|
||||
_, _ = fmt.Fprintln(out, "Flags:")
|
||||
fs.PrintDefaults()
|
||||
@@ -63,7 +65,8 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore: %w", err)
|
||||
|
||||
@@ -19,11 +19,13 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution")
|
||||
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
|
||||
|
||||
@@ -43,7 +45,8 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("resume: %w", err)
|
||||
|
||||
@@ -17,11 +17,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
|
||||
|
||||
@@ -41,7 +43,8 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
|
||||
@@ -17,11 +17,13 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
|
||||
|
||||
@@ -54,7 +56,8 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("run-stage: %w", err)
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlanUsesDiscoveredSessionTemplateWithSessionID(t *testing.T) {
|
||||
func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
sessionTemplate := `session_id: "{{ session_id }}"
|
||||
previous_session_id: "{{ previous_session_id }}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
@@ -36,7 +37,11 @@ inputs:
|
||||
t.Cleanup(func() { _ = os.Chdir(originalWD) })
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session-id", "2026-04-04"}, &out); err != nil {
|
||||
if err := Plan(context.Background(), []string{
|
||||
"--config", pipelinePath,
|
||||
"--session-id", "2026-04-04",
|
||||
"--previous-session-id", "2026-03-28",
|
||||
}, &out); err != nil {
|
||||
t.Fatalf("Plan() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "narratio plan: workdir prepared") {
|
||||
@@ -58,6 +63,38 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFailsWhenPreviousSessionIDMismatchesConcreteSession(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
sessionYAML := `session_id: 2026-05-03
|
||||
previous_session_id: 2026-04-26
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Plan(context.Background(), []string{
|
||||
"--config", pipelinePath,
|
||||
"--session", sessionPath,
|
||||
"--session-id", "2026-05-03",
|
||||
"--previous-session-id", "2026-04-25",
|
||||
}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "previous_session_id mismatch") {
|
||||
t.Fatalf("error = %q, want mismatch context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
@@ -27,11 +27,12 @@ type PipelineConfig struct {
|
||||
|
||||
// SessionConfig contains per-session inputs and metadata.
|
||||
type SessionConfig struct {
|
||||
SessionID string `yaml:"session_id"`
|
||||
Campaign string `yaml:"campaign"`
|
||||
Date string `yaml:"date"`
|
||||
Title string `yaml:"title"`
|
||||
Inputs SessionInputsConfig `yaml:"inputs"`
|
||||
SessionID string `yaml:"session_id"`
|
||||
PreviousSessionID string `yaml:"previous_session_id"`
|
||||
Campaign string `yaml:"campaign"`
|
||||
Date string `yaml:"date"`
|
||||
Title string `yaml:"title"`
|
||||
Inputs SessionInputsConfig `yaml:"inputs"`
|
||||
}
|
||||
|
||||
// WorkspaceConfig configures local workspace behavior.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -28,7 +29,8 @@ func LoadSession(path string) (*SessionConfig, error) {
|
||||
|
||||
// SessionLoadOptions configures session template rendering behavior.
|
||||
type SessionLoadOptions struct {
|
||||
SessionID string
|
||||
SessionID string
|
||||
PreviousSessionID string
|
||||
}
|
||||
|
||||
// LoadSessionWithOptions loads session configuration from a YAML file with
|
||||
@@ -56,6 +58,16 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi
|
||||
strings.TrimSpace(cfg.SessionID),
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(opts.PreviousSessionID) != "" &&
|
||||
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
|
||||
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
|
||||
return nil, fmt.Errorf(
|
||||
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match rendered previous_session_id %q",
|
||||
path,
|
||||
strings.TrimSpace(opts.PreviousSessionID),
|
||||
strings.TrimSpace(cfg.PreviousSessionID),
|
||||
)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -114,24 +126,36 @@ var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)
|
||||
|
||||
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) {
|
||||
sessionID := strings.TrimSpace(opts.SessionID)
|
||||
previousSessionID := strings.TrimSpace(opts.PreviousSessionID)
|
||||
rendered := content
|
||||
if sessionID != "" {
|
||||
rendered = strings.ReplaceAll(rendered, "{{session_id}}", sessionID)
|
||||
rendered = strings.ReplaceAll(rendered, "{{ session_id }}", sessionID)
|
||||
rendered = replaceTemplateVariable(rendered, "session_id", sessionID)
|
||||
}
|
||||
if previousSessionID != "" {
|
||||
rendered = replaceTemplateVariable(rendered, "previous_session_id", previousSessionID)
|
||||
}
|
||||
|
||||
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
|
||||
if len(unresolved) > 0 {
|
||||
seenVars := map[string]struct{}{}
|
||||
vars := make([]string, 0, len(unresolved))
|
||||
for _, m := range unresolved {
|
||||
if len(m) > 1 {
|
||||
vars = append(vars, m[1])
|
||||
name := m[1]
|
||||
if _, ok := seenVars[name]; ok {
|
||||
continue
|
||||
}
|
||||
seenVars[name] = struct{}{}
|
||||
vars = append(vars, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(vars)
|
||||
if len(vars) > 0 {
|
||||
hints := unresolvedTemplateHints(vars)
|
||||
return "", fmt.Errorf(
|
||||
"session file template rendering failed: unresolved template variable(s): %s; pass --session-id when using {{ session_id }}",
|
||||
"session file template rendering failed: unresolved template variable(s): %s%s",
|
||||
strings.Join(vars, ", "),
|
||||
hints,
|
||||
)
|
||||
}
|
||||
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
|
||||
@@ -140,6 +164,35 @@ func renderSessionTemplate(content string, opts SessionLoadOptions) (string, err
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func replaceTemplateVariable(content, name, value string) string {
|
||||
rendered := strings.ReplaceAll(content, "{{"+name+"}}", value)
|
||||
rendered = strings.ReplaceAll(rendered, "{{ "+name+" }}", value)
|
||||
return rendered
|
||||
}
|
||||
|
||||
func unresolvedTemplateHints(vars []string) string {
|
||||
seen := map[string]struct{}{}
|
||||
flags := make([]string, 0, 2)
|
||||
for _, name := range vars {
|
||||
switch name {
|
||||
case "session_id":
|
||||
if _, ok := seen["--session-id"]; !ok {
|
||||
seen["--session-id"] = struct{}{}
|
||||
flags = append(flags, "--session-id")
|
||||
}
|
||||
case "previous_session_id":
|
||||
if _, ok := seen["--previous-session-id"]; !ok {
|
||||
seen["--previous-session-id"] = struct{}{}
|
||||
flags = append(flags, "--previous-session-id")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "; pass " + strings.Join(flags, " and ") + " when using those template variable(s)"
|
||||
}
|
||||
|
||||
func shortName(path, fallback string) string {
|
||||
base := filepath.Base(path)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
|
||||
@@ -187,6 +187,43 @@ inputs:
|
||||
`,
|
||||
wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
|
||||
},
|
||||
{
|
||||
name: "valid previous_session_id passes",
|
||||
pipelineYAML: `workspace:
|
||||
root: /tmp/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||
seriatim:
|
||||
binary: seriatim
|
||||
`,
|
||||
sessionYAML: `session_id: 2026-05-03
|
||||
previous_session_id: 2026-04-26
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "previous_session_id equal to session_id fails",
|
||||
pipelineYAML: `workspace:
|
||||
root: /tmp/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||
seriatim:
|
||||
binary: seriatim
|
||||
`,
|
||||
sessionYAML: `session_id: 2026-05-03
|
||||
previous_session_id: 2026-05-03
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`,
|
||||
wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id",
|
||||
},
|
||||
{
|
||||
name: "missing transcribe_url fails",
|
||||
pipelineYAML: `workspace:
|
||||
|
||||
@@ -55,6 +55,34 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsRendersPreviousSessionPlaceholder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: "{{ session_id }}"
|
||||
previous_session_id: "{{ previous_session_id }}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{
|
||||
SessionID: "2026-04-04",
|
||||
PreviousSessionID: "2026-03-28",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessionWithOptions() error = %v", err)
|
||||
}
|
||||
if cfg.PreviousSessionID != "2026-03-28" {
|
||||
t.Fatalf("PreviousSessionID = %q, want 2026-03-28", cfg.PreviousSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
@@ -82,6 +110,37 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsUnresolvedPreviousSessionPlaceholderFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: 2026-05-03
|
||||
previous_session_id: "{{ previous_session_id }}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unresolved template variable") {
|
||||
t.Fatalf("error = %q, want unresolved-variable context", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "previous_session_id") {
|
||||
t.Fatalf("error = %q, want previous_session_id variable", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--previous-session-id") {
|
||||
t.Fatalf("error = %q, want previous-session-id guidance", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsMismatchFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
@@ -106,6 +165,34 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsPreviousSessionMismatchFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: 2026-05-03
|
||||
previous_session_id: 2026-04-26
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{
|
||||
SessionID: "2026-05-03",
|
||||
PreviousSessionID: "2026-04-25",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "previous_session_id mismatch") {
|
||||
t.Fatalf("error = %q, want mismatch context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
|
||||
@@ -487,8 +487,14 @@ func validateScriptorium(cfg *ScriptoriumConfig) error {
|
||||
}
|
||||
|
||||
func validateSession(cfg *SessionConfig) error {
|
||||
if strings.TrimSpace(cfg.SessionID) == "" {
|
||||
return fmt.Errorf("session.session_id is required")
|
||||
if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSessionIdentifier("session.previous_session_id", cfg.PreviousSessionID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) == strings.TrimSpace(cfg.SessionID) {
|
||||
return fmt.Errorf("session.previous_session_id must not equal session.session_id")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Campaign) == "" {
|
||||
return fmt.Errorf("session.campaign is required")
|
||||
@@ -525,6 +531,16 @@ func validateSession(cfg *SessionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSessionIdentifier(fieldName, value string, required bool) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
if required {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
|
||||
if pipeline == nil || session == nil {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user