2 Commits

Author SHA1 Message Date
717451512a Implemented new campaign/session stable inputs and corresponding input source references
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-27 09:34:05 -05:00
3ddb3a947b Update pipeline defaults so trim is enabled when omitted 2026-05-27 08:35:02 -05:00
38 changed files with 703 additions and 105 deletions

View File

@@ -44,7 +44,7 @@ using configured object storage.
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load. - Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
- Pipeline defaults are applied before validation. - Pipeline defaults are applied before validation.
- Campaign and session identities must agree. - Campaign and session identities must agree.
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`) resolve from session overrides when provided, otherwise from campaign defaults. - Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, `players_file`, `party_file`) resolve from session overrides when provided, otherwise from campaign defaults.
- Exactly one audio mode must be configured in session input: - Exactly one audio mode must be configured in session input:
- local (`audio_dir` or `audio_files`), or - local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`). - S3 (`audio_s3.prefix`).
@@ -69,6 +69,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
``` ```
`session.yml` (local audio) `session.yml` (local audio)
@@ -184,12 +186,12 @@ Rules:
| `pipeline.normalize.output_path` | string | No | `transcripts/final.json` | | `pipeline.normalize.output_path` | string | No | `transcripts/final.json` |
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` | | `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
| `pipeline.normalize.report` | bool | No | `true` | | `pipeline.normalize.report` | bool | No | `true` |
| `pipeline.trim.enabled` | bool | No | `false` | | `pipeline.trim.enabled` | bool | No | `true` |
| `pipeline.trim.output_path` | string | Conditional | required when trim enabled | | `pipeline.trim.output_path` | string | No | `transcripts/final.trimmed.json` |
| `pipeline.trim.bounds.prompt_id` | string | Conditional | required when trim enabled | | `pipeline.trim.bounds.prompt_id` | string | No | `dnd.session_bounds` |
| `pipeline.trim.bounds.profile_id` | string | No | empty | | `pipeline.trim.bounds.profile_id` | string | No | empty |
| `pipeline.trim.bounds.transcript_input_name` | string | Conditional | required when trim enabled | | `pipeline.trim.bounds.transcript_input_name` | string | No | `transcript` |
| `pipeline.trim.bounds.output_path` | string | Conditional | required when trim enabled | | `pipeline.trim.bounds.output_path` | string | No | `artifacts/session_bounds.json` |
| `pipeline.trim.bounds.timeout` | duration | No | `10m` | | `pipeline.trim.bounds.timeout` | duration | No | `10m` |
| `pipeline.trim.bounds.render_debug` | bool | No | `false` | | `pipeline.trim.bounds.render_debug` | bool | No | `false` |
| `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true | | `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true |
@@ -229,7 +231,7 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| Field | Type | Required | Rule | | Field | Type | Required | Rule |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `source` | string | Yes | built-in runtime source, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` | | `source` | string | Yes | built-in runtime source, prepared input source, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` |
| `artifact` | string | No | optional passthrough adapter field | | `artifact` | string | No | optional passthrough adapter field |
| `path` | string | No | optional passthrough adapter field | | `path` | string | No | optional passthrough adapter field |
| `required` | bool | No | optional input requirement | | `required` | bool | No | optional input requirement |
@@ -243,6 +245,8 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| `inputs.speakers_file` | string | Yes | stable input default | | `inputs.speakers_file` | string | Yes | stable input default |
| `inputs.autocorrect_file` | string | Yes | stable input default | | `inputs.autocorrect_file` | string | Yes | stable input default |
| `inputs.glossary_file` | string | Yes | stable input default | | `inputs.glossary_file` | string | Yes | stable input default |
| `inputs.players_file` | string | Yes | stable input default |
| `inputs.party_file` | string | Yes | stable input default |
### Session ### Session
@@ -256,6 +260,8 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| `inputs.speakers_file` | string | No | overrides campaign stable input | | `inputs.speakers_file` | string | No | overrides campaign stable input |
| `inputs.autocorrect_file` | string | No | overrides campaign stable input | | `inputs.autocorrect_file` | string | No | overrides campaign stable input |
| `inputs.glossary_file` | string | No | overrides campaign stable input | | `inputs.glossary_file` | string | No | overrides campaign stable input |
| `inputs.players_file` | string | No | overrides campaign stable input |
| `inputs.party_file` | string | No | overrides campaign stable input |
| `inputs.audio_dir` | string | Conditional | local audio mode | | `inputs.audio_dir` | string | Conditional | local audio mode |
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode | | `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode | | `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |

View File

@@ -10,6 +10,7 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
Supported source families: Supported source families:
- built-ins: `narratio.transcript.*`, `narratio.bounds.session` - built-ins: `narratio.transcript.*`, `narratio.bounds.session`
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`, `narratio.input.glossary`
- configured artifacts: `narratio.artifact.<key>` - configured artifacts: `narratio.artifact.<key>`
- previous-session cache: `narratio.previous_session.artifact.<key>` - previous-session cache: `narratio.previous_session.artifact.<key>`
@@ -23,12 +24,14 @@ Supported source families:
- marks non-executable configured artifacts as reusable when output files already exist. - marks non-executable configured artifacts as reusable when output files already exist.
- validates selected artifact dependency order (cycle-safe topo ordering). - validates selected artifact dependency order (cycle-safe topo ordering).
- resolves required/optional inputs per artifact source definition. - resolves required/optional inputs per artifact source definition.
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
- resolves previous-session sources from local `previous/` cache only. - resolves previous-session sources from local `previous/` cache only.
- runs optional render-debug, then artifact execution. - runs optional render-debug, then artifact execution.
- validates non-empty output files and materializes canonical outputs. - validates non-empty output files and materializes canonical outputs.
## Failure Semantics ## Failure Semantics
- required missing configured/previous-session inputs fail. - required missing configured/previous-session inputs fail.
- missing required prepared stable input source includes prepare rerun guidance.
- missing required previous-session source includes prepare rerun guidance. - missing required previous-session source includes prepare rerun guidance.
- missing required `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` inputs includes render rerun guidance. - missing required `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` inputs includes render rerun guidance.
- dependency cycles or unavailable required dependencies fail. - dependency cycles or unavailable required dependencies fail.

View File

@@ -5,7 +5,7 @@ Materialize canonical current-session inputs before processing stages.
## Inputs ## Inputs
- resolved `campaign.yml`, `session.yml`, and pipeline config - resolved `campaign.yml`, `session.yml`, and pipeline config
- stable input files (`speakers`, `autocorrect`, `glossary`) - stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
- audio source: - audio source:
- local `audio_dir`/`audio_files`, or - local `audio_dir`/`audio_files`, or
- S3 `audio_s3.prefix` - S3 `audio_s3.prefix`
@@ -18,6 +18,8 @@ Materialize canonical current-session inputs before processing stages.
- `inputs/speakers.yml` - `inputs/speakers.yml`
- `inputs/autocorrect.yml` - `inputs/autocorrect.yml`
- `inputs/glossary.yml` - `inputs/glossary.yml`
- `inputs/players.yml`
- `inputs/party.yml`
- `audio/*.flac` - `audio/*.flac`
- optional `previous/manifest.json` - optional `previous/manifest.json`
- optional `previous/artifacts/**` - optional `previous/artifacts/**`

View File

@@ -1,7 +1,7 @@
# Stage: trim # Stage: trim
## Purpose ## Purpose
Produce a final-trimmed transcript; optionally generate bounds-driven trim. Produce a final-trimmed transcript. By default, the stage generates bounds and applies a bounds-driven trim.
## Inputs ## Inputs
- `transcripts/final.json` - `transcripts/final.json`
@@ -11,9 +11,6 @@ Produce a final-trimmed transcript; optionally generate bounds-driven trim.
- when trim enabled: `artifacts/session_bounds.json` - when trim enabled: `artifacts/session_bounds.json`
## Key Behavior ## Key Behavior
When `trim.enabled=false`:
- copies normalized transcript to trimmed output.
When `trim.enabled=true`: When `trim.enabled=true`:
- runs Scriptorium bounds artifact generation; - runs Scriptorium bounds artifact generation;
- optionally runs render-debug output generation; - optionally runs render-debug output generation;
@@ -22,6 +19,9 @@ When `trim.enabled=true`:
- either copies unchanged transcript or runs Seriatim trim; - either copies unchanged transcript or runs Seriatim trim;
- validates trimmed transcript and materializes bounds output. - validates trimmed transcript and materializes bounds output.
When `trim.enabled=false`:
- copies normalized transcript to trimmed output.
## Invariants ## Invariants
- normalized transcript is required input. - normalized transcript is required input.
- bounds output exists only in enabled trim path. - bounds output exists only in enabled trim path.

View File

@@ -36,6 +36,8 @@ narratio session init 2026-04-04 --remote --force
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values. If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
Campaigns must provide stable input files for speakers, autocorrect, glossary, players, and party. Session files may override those paths for one session. The `prepare` stage materializes them under `inputs/`; configured Scriptorium artifacts can reference prepared `players`, `party`, and `glossary` files with `narratio.input.players`, `narratio.input.party`, and `narratio.input.glossary`.
## Standard Session Workflow ## Standard Session Workflow
1. Select pipeline/campaign/session config. 1. Select pipeline/campaign/session config.

View File

@@ -4,3 +4,5 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml

View File

@@ -0,0 +1,2 @@
- name: Example Hero
type: pc

View File

@@ -0,0 +1,2 @@
- name: Example Player
role: player

View File

@@ -110,17 +110,16 @@ normalize:
report: true report: true
trim: trim:
# Keep disabled unless bounds prompt integration is configured. # Optional; defaults shown explicitly.
enabled: false enabled: true
output_path: transcripts/final.trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd.session_bounds prompt_id: dnd.session_bounds
profile_id: local-fast profile_id: ""
transcript_input_name: transcript transcript_input_name: transcript
output_path: reports/session_bounds.json output_path: artifacts/session_bounds.json
timeout: 10m timeout: 10m
render_debug: false render_debug: false
render_output_path: reports/session_bounds.render.json
seriatim: seriatim:
report: false report: false
@@ -144,6 +143,15 @@ scriptorium:
previous_recap: previous_recap:
source: narratio.previous_session.artifact.session_recap source: narratio.previous_session.artifact.session_recap
required: false required: false
players:
source: narratio.input.players
required: true
party:
source: narratio.input.party
required: true
glossary:
source: narratio.input.glossary
required: false
vars: vars:
session_id: true session_id: true
session_date: true session_date: true

View File

@@ -71,9 +71,6 @@ normalize:
output_schema: seriatim-intermediate output_schema: seriatim-intermediate
report: true report: true
trim:
enabled: false
scriptorium: scriptorium:
binary: scriptorium binary: scriptorium
config_path: /usr/local/etc/scriptorium/config.yml config_path: /usr/local/etc/scriptorium/config.yml
@@ -93,6 +90,15 @@ scriptorium:
previous_recap: previous_recap:
source: narratio.previous_session.artifact.session_recap source: narratio.previous_session.artifact.session_recap
required: false required: false
players:
source: narratio.input.players
required: true
party:
source: narratio.input.party
required: true
glossary:
source: narratio.input.glossary
required: false
vars: vars:
session_id: true session_id: true
session_date: true session_date: true

View File

@@ -227,6 +227,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
@@ -290,6 +292,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
@@ -385,10 +389,14 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`) `)
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "party.yml"), "[]\n")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -466,10 +474,13 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
url = transcribeURL[0] url = transcribeURL[0]
} }
seriatimBinary := writeSeriatimAppTestWrapper(t) seriatimBinary := writeSeriatimAppTestWrapper(t)
scriptoriumBinary := writeScriptoriumAppTestWrapper(t)
auditaBinary := writeAuditaAppTestWrapper(t) auditaBinary := writeAuditaAppTestWrapper(t)
t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1") t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1")
t.Setenv("GO_WANT_APP_SCRIPTORIUM_HELPER", "1")
t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1") t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1")
t.Setenv("AUDITA_LLM_API_KEY", "test-audita-key") t.Setenv("AUDITA_LLM_API_KEY", "test-audita-key")
t.Setenv("PATH", filepath.Dir(scriptoriumBinary)+string(os.PathListSeparator)+os.Getenv("PATH"))
pipelineYAML := `workspace: pipelineYAML := `workspace:
root: ` + workspaceRoot + ` root: ` + workspaceRoot + `
@@ -514,6 +525,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
@@ -532,6 +545,8 @@ inputs:
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath return pipelinePath, campaignPath, sessionPath
@@ -545,6 +560,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err) t.Fatalf("write campaign.yml: %v", err)
@@ -591,6 +608,60 @@ func writeSeriatimAppTestWrapper(t *testing.T) string {
return path return path
} }
func writeScriptoriumAppTestWrapper(t *testing.T) string {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable() error = %v", err)
}
path := filepath.Join(t.TempDir(), "scriptorium")
content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestScriptoriumAppHelper -- \"$@\"\n"
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
return path
}
func TestScriptoriumAppHelper(t *testing.T) {
if os.Getenv("GO_WANT_APP_SCRIPTORIUM_HELPER") != "1" {
return
}
args := os.Args
start := -1
for i := range args {
if args[i] == "--" {
start = i + 1
break
}
}
if start < 0 || start >= len(args) {
_, _ = os.Stderr.WriteString("missing -- args separator\n")
os.Exit(2)
}
runArgs := args[start:]
outputPath := appSeriatimFlagValue(runArgs, "--out")
if strings.TrimSpace(outputPath) == "" {
outputPath = appSeriatimFlagValue(runArgs, "--output")
}
if strings.TrimSpace(outputPath) == "" {
_, _ = os.Stderr.WriteString("missing output flag\n")
os.Exit(2)
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir output dir: %v\n", err))
os.Exit(2)
}
if err := os.WriteFile(outputPath, []byte(`{"trim_action":"copy","warnings":[]}`), 0o644); err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err))
os.Exit(2)
}
_, _ = os.Stdout.WriteString("scriptorium helper stdout\n")
_, _ = os.Stderr.WriteString("scriptorium helper stderr\n")
os.Exit(0)
}
func TestSeriatimAppHelper(t *testing.T) { func TestSeriatimAppHelper(t *testing.T) {
if os.Getenv("GO_WANT_APP_SERIATIM_HELPER") != "1" { if os.Getenv("GO_WANT_APP_SERIATIM_HELPER") != "1" {
return return

View File

@@ -109,12 +109,16 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`), 0o644); err != nil { `), 0o644); err != nil {
t.Fatalf("write explicit campaign: %v", err) t.Fatalf("write explicit campaign: %v", err)
} }
mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "party.yml"), "[]\n")
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
var storeInitCalls int var storeInitCalls int
@@ -445,6 +449,9 @@ inputs:
if !strings.Contains(stdout.String(), "OK audio") { if !strings.Contains(stdout.String(), "OK audio") {
t.Fatalf("stdout = %q, want OK audio", stdout.String()) t.Fatalf("stdout = %q, want OK audio", stdout.String())
} }
if !strings.Contains(stdout.String(), "OK inputs players:") || !strings.Contains(stdout.String(), "OK inputs party:") {
t.Fatalf("stdout = %q, want players and party input findings", stdout.String())
}
} }
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) { func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
@@ -862,6 +869,8 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
"narratio.transcript.final_trimmed locked", "narratio.transcript.final_trimmed locked",
"narratio.transcript.final_trimmed locked remote=published", "narratio.transcript.final_trimmed locked remote=published",
"narratio.transcript.final dest=transcripts/full.json remote=published", "narratio.transcript.final dest=transcripts/full.json remote=published",
"Stable input players:",
"Stable input party:",
} { } {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Fatalf("stdout = %q, want %q", out, want) t.Fatalf("stdout = %q, want %q", out, want)

View File

@@ -57,6 +57,8 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
{name: "speakers", in: cfg.StableInputs.SpeakersFile}, {name: "speakers", in: cfg.StableInputs.SpeakersFile},
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile}, {name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
{name: "glossary", in: cfg.StableInputs.GlossaryFile}, {name: "glossary", in: cfg.StableInputs.GlossaryFile},
{name: "players", in: cfg.StableInputs.PlayersFile},
{name: "party", in: cfg.StableInputs.PartyFile},
} }
out := make([]stableInputCheck, 0, len(items)) out := make([]stableInputCheck, 0, len(items))
for _, item := range items { for _, item := range items {

View File

@@ -118,6 +118,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)

View File

@@ -397,6 +397,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err) t.Fatalf("write pipeline config: %v", err)
@@ -407,6 +409,8 @@ inputs:
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n") mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath return pipelinePath, campaignPath, sessionPath

View File

@@ -236,8 +236,8 @@ func TestRunStageTrimExecutes(t *testing.T) {
if m.Stages["trim"] == nil || m.Stages["trim"].Status != manifest.StatusSucceeded { if m.Stages["trim"] == nil || m.Stages["trim"].Status != manifest.StatusSucceeded {
t.Fatalf("trim stage = %#v, want succeeded", m.Stages["trim"]) t.Fatalf("trim stage = %#v, want succeeded", m.Stages["trim"])
} }
if m.Stages["trim"].Metadata == nil || m.Stages["trim"].Metadata["trim_action"] != "copy_disabled" { if m.Stages["trim"].Metadata == nil || m.Stages["trim"].Metadata["trim_action"] != "copy" {
t.Fatalf("trim stage metadata = %#v, want trim_action=copy_disabled", m.Stages["trim"].Metadata) t.Fatalf("trim stage metadata = %#v, want trim_action=copy", m.Stages["trim"].Metadata)
} }
} }

View File

@@ -1024,11 +1024,13 @@ func testConfig(t *testing.T) *config.Config {
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n") mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n") mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio") mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio")
return &config.Config{ return &config.Config{
@@ -1053,6 +1055,16 @@ func testConfig(t *testing.T) *config.Config {
ConfigPath: campaignPath, ConfigPath: campaignPath,
Source: "campaign_config", Source: "campaign_config",
}, },
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
}, },
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
@@ -1062,6 +1074,8 @@ func testConfig(t *testing.T) *config.Config {
SpeakersFile: "./speakers.yml", SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml", AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml", GlossaryFile: "./glossary.yml",
PlayersFile: "./players.yml",
PartyFile: "./party.yml",
}, },
}, },
} }
@@ -1084,6 +1098,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign campaign: sample-campaign
@@ -1092,6 +1108,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
mustWriteFile(t, pipelinePath, pipelineYAML) mustWriteFile(t, pipelinePath, pipelineYAML)
mustWriteFile(t, campaignPath, campaignYAML) mustWriteFile(t, campaignPath, campaignYAML)

View File

@@ -22,6 +22,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
t.Fatalf("write session template: %v", err) t.Fatalf("write session template: %v", err)
@@ -75,6 +77,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)

View File

@@ -13,6 +13,10 @@ import (
const ( const (
SourceBoundsSession = "narratio.bounds.session" SourceBoundsSession = "narratio.bounds.session"
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
configuredSourcePrefix = "narratio.artifact." configuredSourcePrefix = "narratio.artifact."
previousConfiguredSrcPrefix = "narratio.previous_session.artifact." previousConfiguredSrcPrefix = "narratio.previous_session.artifact."
) )
@@ -31,6 +35,7 @@ const (
SourceKindBuiltIn SourceKind = "built_in" SourceKindBuiltIn SourceKind = "built_in"
SourceKindConfiguredArtifact SourceKind = "configured_artifact" SourceKindConfiguredArtifact SourceKind = "configured_artifact"
SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact" SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact"
SourceKindStableInput SourceKind = "stable_input"
) )
// Source describes one normalized artifact source identifier. // Source describes one normalized artifact source identifier.
@@ -118,6 +123,11 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
if trimmed == "" { if trimmed == "" {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
} }
if IsStableInputSource(trimmed) {
return ScriptoriumInputSourceDescriptor{
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
}, nil
}
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") { if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
descriptor, err := DescribePreviousSessionSource(trimmed) descriptor, err := DescribePreviousSessionSource(trimmed)
if err != nil { if err != nil {
@@ -140,6 +150,17 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
return ScriptoriumInputSourceDescriptor{Source: classified}, nil return ScriptoriumInputSourceDescriptor{Source: classified}, nil
} }
// IsStableInputSource reports whether source is a prepared stable input source
// available only to Scriptorium input resolution.
func IsStableInputSource(source string) bool {
switch strings.TrimSpace(source) {
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
return true
default:
return false
}
}
// DescribePreviousSessionSource validates a canonical previous-session source id // DescribePreviousSessionSource validates a canonical previous-session source id
// and returns both previous and configured-source vocabulary descriptors. // and returns both previous and configured-source vocabulary descriptors.
func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) { func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) {

View File

@@ -112,6 +112,9 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
}{ }{
{name: "built in", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn}, {name: "built in", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn},
{name: "built in markdown", source: "narratio.transcript.final_markdown", wantKind: SourceKindBuiltIn}, {name: "built in markdown", source: "narratio.transcript.final_markdown", wantKind: SourceKindBuiltIn},
{name: "prepared players input", source: "narratio.input.players", wantKind: SourceKindStableInput},
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
{name: "prepared glossary input", source: "narratio.input.glossary", wantKind: SourceKindStableInput},
{name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"}, {name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
{name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true}, {name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true},
{name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource}, {name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource},

View File

@@ -37,7 +37,7 @@ notification:
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) { func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
@@ -52,7 +52,7 @@ func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) { func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
@@ -67,7 +67,7 @@ func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
@@ -82,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) { func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
@@ -97,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
@@ -114,12 +114,14 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./campaign-players.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
} }
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n", "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n players_file: ./session-players.yml\n party_file: ./session-party.yml\n",
) )
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
@@ -132,11 +134,32 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config") assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./session-players.yml", sessionPath, "session_config")
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
}
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
err = Validate(cfg)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") {
t.Fatalf("error = %q, want players_file required", err.Error())
}
} }
func TestCampaignSessionMismatchFails(t *testing.T) { func TestCampaignSessionMismatchFails(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
) )
@@ -151,7 +174,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) {
func TestLoadMissingCampaignFileFails(t *testing.T) { func TestLoadMissingCampaignFileFails(t *testing.T) {
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")

View File

@@ -51,6 +51,8 @@ type CampaignInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"` SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"` AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"` GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
} }
// SessionConfig contains per-session inputs and metadata. // SessionConfig contains per-session inputs and metadata.
@@ -188,7 +190,7 @@ type NormalizeConfig struct {
// TrimConfig configures trim-stage transcript boundary behavior. // TrimConfig configures trim-stage transcript boundary behavior.
type TrimConfig struct { type TrimConfig struct {
Enabled bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
OutputPath string `yaml:"output_path"` OutputPath string `yaml:"output_path"`
Bounds TrimBoundsConfig `yaml:"bounds"` Bounds TrimBoundsConfig `yaml:"bounds"`
Seriatim TrimSeriatimConfig `yaml:"seriatim"` Seriatim TrimSeriatimConfig `yaml:"seriatim"`
@@ -265,6 +267,8 @@ type SessionInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"` SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"` AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"` GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
} }
// SessionAudioS3Input configures S3 session-audio input discovery. // SessionAudioS3Input configures S3 session-audio input discovery.
@@ -278,6 +282,8 @@ type ResolvedStableInputs struct {
SpeakersFile ResolvedInputFile SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile
} }
// ResolvedInputFile records one merged config path and its source config file. // ResolvedInputFile records one merged config path and its source config file.

View File

@@ -38,14 +38,19 @@ const (
DefaultScriptoriumTimeout = "10m" DefaultScriptoriumTimeout = "10m"
DefaultScriptoriumArtifactOutputRoot = "artifacts" DefaultScriptoriumArtifactOutputRoot = "artifacts"
DefaultTrimBoundsTimeout = "10m" DefaultTrimEnabled = true
DefaultTrimSeriatimReport = false DefaultTrimOutputPath = artifactmodel.TranscriptPathFinalTrimmed
DefaultRenderEnabled = true DefaultTrimBoundsPromptID = "dnd.session_bounds"
DefaultRenderFormat = "markdown" DefaultTrimBoundsTranscriptInputName = "transcript"
DefaultRenderTitle = "" DefaultTrimBoundsOutputPath = "artifacts/session_bounds.json"
DefaultRenderTimestamps = true DefaultTrimBoundsTimeout = "10m"
DefaultRenderSegmentIDs = true DefaultTrimSeriatimReport = false
DefaultRenderMetadata = false DefaultRenderEnabled = true
DefaultRenderFormat = "markdown"
DefaultRenderTitle = ""
DefaultRenderTimestamps = true
DefaultRenderSegmentIDs = true
DefaultRenderMetadata = false
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
DefaultNormalizeOutputSchema = "seriatim-intermediate" DefaultNormalizeOutputSchema = "seriatim-intermediate"

View File

@@ -226,11 +226,25 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
campaignPath, campaignPath,
sessionPath, sessionPath,
), ),
PlayersFile: selectStableInput(
campaignCfg.Inputs.PlayersFile,
sessionCfg.Inputs.PlayersFile,
campaignPath,
sessionPath,
),
PartyFile: selectStableInput(
campaignCfg.Inputs.PartyFile,
sessionCfg.Inputs.PartyFile,
campaignPath,
sessionPath,
),
} }
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
return stable, nil return stable, nil
} }
@@ -336,7 +350,13 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
cfg.Normalize = &NormalizeConfig{} cfg.Normalize = &NormalizeConfig{}
} }
applyNormalizeDefaults(cfg.Normalize) applyNormalizeDefaults(cfg.Normalize)
if cfg.Trim == nil {
cfg.Trim = &TrimConfig{}
}
applyTrimDefaults(cfg.Trim) applyTrimDefaults(cfg.Trim)
if trimEnabled(cfg.Trim) && cfg.Scriptorium == nil {
cfg.Scriptorium = &ScriptoriumConfig{}
}
applyRenderDefaults(&cfg.Render) applyRenderDefaults(&cfg.Render)
applyScriptoriumDefaults(cfg.Scriptorium) applyScriptoriumDefaults(cfg.Scriptorium)
} }
@@ -500,6 +520,21 @@ func applyTrimDefaults(cfg *TrimConfig) {
if cfg == nil { if cfg == nil {
return return
} }
if cfg.Enabled == nil {
cfg.Enabled = boolPtr(DefaultTrimEnabled)
}
if strings.TrimSpace(cfg.OutputPath) == "" {
cfg.OutputPath = DefaultTrimOutputPath
}
if strings.TrimSpace(cfg.Bounds.PromptID) == "" {
cfg.Bounds.PromptID = DefaultTrimBoundsPromptID
}
if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" {
cfg.Bounds.TranscriptInputName = DefaultTrimBoundsTranscriptInputName
}
if strings.TrimSpace(cfg.Bounds.OutputPath) == "" {
cfg.Bounds.OutputPath = DefaultTrimBoundsOutputPath
}
if cfg.Bounds.Timeout == "" { if cfg.Bounds.Timeout == "" {
cfg.Bounds.Timeout = DefaultTrimBoundsTimeout cfg.Bounds.Timeout = DefaultTrimBoundsTimeout
} }
@@ -508,6 +543,10 @@ func applyTrimDefaults(cfg *TrimConfig) {
} }
} }
func trimEnabled(cfg *TrimConfig) bool {
return cfg != nil && cfg.Enabled != nil && *cfg.Enabled
}
func applyRenderDefaults(cfg **RenderConfig) { func applyRenderDefaults(cfg **RenderConfig) {
if cfg == nil { if cfg == nil {
return return

View File

@@ -36,6 +36,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
checkDefault: true, checkDefault: true,
wantRoot: "/tmp/narratio", wantRoot: "/tmp/narratio",
@@ -55,6 +57,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
checkDefault: true, checkDefault: true,
wantRoot: "/tmp/narratio", wantRoot: "/tmp/narratio",
@@ -72,6 +76,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
checkDefault: true, checkDefault: true,
wantRoot: DefaultWorkspaceRoot, wantRoot: DefaultWorkspaceRoot,
@@ -88,6 +94,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "pipeline file", wantLoadErr: "pipeline file",
}, },
@@ -106,6 +114,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "strict decode failed", wantLoadErr: "strict decode failed",
}, },
@@ -123,6 +133,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "strict decode failed", wantLoadErr: "strict decode failed",
}, },
@@ -143,6 +155,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "strict decode failed", wantLoadErr: "strict decode failed",
}, },
@@ -163,6 +177,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured",
}, },
@@ -177,6 +193,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
unknown_field: true unknown_field: true
`, `,
wantLoadErr: "session file", wantLoadErr: "session file",
@@ -196,6 +214,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "session config \"session.yml\" invalid: session.session_id is required", wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
}, },
@@ -215,6 +235,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
}, },
{ {
@@ -233,6 +255,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id", wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id",
}, },
@@ -250,6 +274,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url is required", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url is required",
}, },
@@ -268,6 +294,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url must be a valid URL", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url must be a valid URL",
}, },
@@ -287,6 +315,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.timeout must be a valid duration", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.timeout must be a valid duration",
}, },
@@ -306,6 +336,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retry_delay must be a valid duration", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retry_delay must be a valid duration",
}, },
@@ -325,6 +357,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retries must be >= 0", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retries must be >= 0",
}, },
@@ -344,6 +378,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0",
}, },
@@ -363,6 +399,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "strict decode failed", wantLoadErr: "strict decode failed",
}, },
@@ -383,6 +421,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "strict decode failed", wantLoadErr: "strict decode failed",
}, },
@@ -401,6 +441,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
}, },
{ {
@@ -419,6 +461,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.timeout must be a valid duration", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.timeout must be a valid duration",
}, },
@@ -438,6 +482,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full",
}, },
@@ -457,6 +503,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.coalesce_gap must be >= 0", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.coalesce_gap must be >= 0",
}, },
@@ -477,6 +525,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.env.overlap_word_run_gap must be > 0 when provided", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.env.overlap_word_run_gap must be > 0 when provided",
}, },
@@ -498,6 +548,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "strict decode failed", wantLoadErr: "strict decode failed",
}, },
@@ -518,6 +570,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
}, },
{ {
@@ -538,6 +592,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.timeout must be a valid duration", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.timeout must be a valid duration",
}, },
@@ -559,6 +615,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
}, },
{ {
@@ -581,6 +639,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be non-empty", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be non-empty",
}, },
@@ -604,6 +664,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be one of: glossary, homophones, spoken_word, grammar", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be one of: glossary, homophones, spoken_word, grammar",
}, },
@@ -625,6 +687,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.base_url must be a valid URL", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.base_url must be a valid URL",
}, },
@@ -646,6 +710,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantLoadErr: "strict decode failed", wantLoadErr: "strict decode failed",
}, },
@@ -667,6 +733,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.total_llm_concurrency must be > 0", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.total_llm_concurrency must be > 0",
}, },
@@ -688,6 +756,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.proposal_llm_concurrency must be > 0", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.proposal_llm_concurrency must be > 0",
}, },
@@ -709,6 +779,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.validation_llm_concurrency must be > 0", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.validation_llm_concurrency must be > 0",
}, },
@@ -730,6 +802,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.output_schema must be one of: bare-segments, audita-v1", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.output_schema must be one of: bare-segments, audita-v1",
}, },
@@ -751,6 +825,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.work_dir_retention must be one of: always, auto, never", wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.work_dir_retention must be one of: always, auto, never",
}, },
@@ -916,7 +992,16 @@ func TestValidateMissingAudioSource(t *testing.T) {
Report: boolPtr(true), Report: boolPtr(true),
}, },
}, },
Campaign: &CampaignConfig{CampaignID: "sample-campaign"}, Campaign: &CampaignConfig{
CampaignID: "sample-campaign",
Inputs: CampaignInputsConfig{
SpeakersFile: "speakers.yml",
AutocorrectFile: "autocorrect.yml",
GlossaryFile: "glossary.yml",
PlayersFile: "players.yml",
PartyFile: "party.yml",
},
},
Session: &SessionConfig{ Session: &SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
@@ -924,6 +1009,8 @@ func TestValidateMissingAudioSource(t *testing.T) {
SpeakersFile: "speakers.yml", SpeakersFile: "speakers.yml",
AutocorrectFile: "autocorrect.yml", AutocorrectFile: "autocorrect.yml",
GlossaryFile: "glossary.yml", GlossaryFile: "glossary.yml",
PlayersFile: "players.yml",
PartyFile: "party.yml",
}, },
}, },
} }
@@ -1009,6 +1096,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err) t.Fatalf("write campaign.yml: %v", err)

View File

@@ -215,6 +215,27 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
transcript_markdown: transcript_markdown:
source: narratio.transcript.final_markdown source: narratio.transcript.final_markdown
required: true required: true
`,
},
{
name: "prepared stable input sources are accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
players:
source: narratio.input.players
required: true
party:
source: narratio.input.party
required: true
glossary:
source: narratio.input.glossary
required: false
`, `,
}, },
{ {
@@ -575,4 +596,6 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `

View File

@@ -17,6 +17,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
@@ -40,6 +42,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
@@ -65,6 +69,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
@@ -93,6 +99,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
@@ -124,6 +132,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
@@ -148,6 +158,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)

View File

@@ -238,6 +238,15 @@ func TestPublishOutputValidation(t *testing.T) {
`, `,
wantErr: "source \"narratio.unknown\" is unsupported", wantErr: "source \"narratio.unknown\" is unsupported",
}, },
{
name: "prepared input source rejected",
ruleYML: `publish:
outputs:
- source: "narratio.input.players"
dest: "inputs/players.yml"
`,
wantErr: "source \"narratio.input.players\" is unsupported",
},
{ {
name: "duplicate destination rejected", name: "duplicate destination rejected",
ruleYML: `publish: ruleYML: `publish:
@@ -426,6 +435,15 @@ publish:
`, `,
wantErr: "pipeline.publish.locks[0].source \"narratio.unknown\" is unsupported", wantErr: "pipeline.publish.locks[0].source \"narratio.unknown\" is unsupported",
}, },
{
name: "prepared input source rejected",
pipelineYML: testPipelineBaseYAML + `
publish:
locks:
- source: narratio.input.players
`,
wantErr: "pipeline.publish.locks[0].source \"narratio.input.players\" is unsupported",
},
{ {
name: "duplicate source rejected", name: "duplicate source rejected",
pipelineYML: testPipelineBaseYAML + ` pipelineYML: testPipelineBaseYAML + `
@@ -579,6 +597,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
}, },
{ {
@@ -591,6 +611,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantErr: "session.inputs.audio_s3.prefix must be a relative path", wantErr: "session.inputs.audio_s3.prefix must be a relative path",
}, },
@@ -604,6 +626,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantErr: "session.inputs.audio_s3.prefix must not contain path traversal", wantErr: "session.inputs.audio_s3.prefix must not contain path traversal",
}, },
@@ -618,6 +642,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`, `,
wantErr: "mutually exclusive", wantErr: "mutually exclusive",
}, },
@@ -664,6 +690,8 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
` `
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML) pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
cfg, err := Load(pipelinePath, sessionPath) cfg, err := Load(pipelinePath, sessionPath)

View File

@@ -13,6 +13,20 @@ func TestTrimLoadAndValidate(t *testing.T) {
wantValidateErr string wantValidateErr string
assert func(t *testing.T, cfg *Config) assert func(t *testing.T, cfg *Config)
}{ }{
{
name: "trim defaults when omitted",
trimYAML: "",
assert: func(t *testing.T, cfg *Config) {
t.Helper()
assertDefaultTrimConfig(t, cfg)
if cfg.Pipeline.Scriptorium == nil {
t.Fatal("scriptorium config should be defaulted when trim is enabled by default")
}
if cfg.Pipeline.Scriptorium.Binary != DefaultScriptoriumBinary {
t.Fatalf("scriptorium.binary = %q, want %q", cfg.Pipeline.Scriptorium.Binary, DefaultScriptoriumBinary)
}
},
},
{ {
name: "valid trim config", name: "valid trim config",
trimYAML: `trim: trimYAML: `trim:
@@ -34,8 +48,8 @@ func TestTrimLoadAndValidate(t *testing.T) {
if cfg.Pipeline.Trim == nil { if cfg.Pipeline.Trim == nil {
t.Fatal("trim config should be present") t.Fatal("trim config should be present")
} }
if cfg.Pipeline.Trim.Enabled != true { if cfg.Pipeline.Trim.Enabled == nil || !*cfg.Pipeline.Trim.Enabled {
t.Fatalf("trim.enabled = %t, want true", cfg.Pipeline.Trim.Enabled) t.Fatalf("trim.enabled = %#v, want true", cfg.Pipeline.Trim.Enabled)
} }
if cfg.Pipeline.Trim.Bounds.ProfileID != "" { if cfg.Pipeline.Trim.Bounds.ProfileID != "" {
t.Fatalf("trim.bounds.profile_id = %q, want empty", cfg.Pipeline.Trim.Bounds.ProfileID) t.Fatalf("trim.bounds.profile_id = %q, want empty", cfg.Pipeline.Trim.Bounds.ProfileID)
@@ -43,67 +57,25 @@ func TestTrimLoadAndValidate(t *testing.T) {
}, },
}, },
{ {
name: "enabled omitted defaults disabled", name: "enabled omitted defaults enabled",
trimYAML: `trim: trimYAML: `trim:
output_path: transcripts/final.trimmed.json
bounds:
prompt_id: dnd_session.bounds
transcript_input_name: transcript
output_path: artifacts/session_bounds.json
`, `,
assert: func(t *testing.T, cfg *Config) { assert: func(t *testing.T, cfg *Config) {
t.Helper() t.Helper()
if cfg.Pipeline.Trim == nil { assertDefaultTrimConfig(t, cfg)
t.Fatal("trim config should be present")
}
if cfg.Pipeline.Trim.Enabled {
t.Fatal("trim.enabled should default to false when omitted")
}
}, },
}, },
{ {
name: "missing prompt id fails when enabled", name: "explicit disabled remains disabled",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: false
output_path: transcripts/final.trimmed.json
bounds:
transcript_input_name: transcript
output_path: artifacts/session_bounds.json
`, `,
wantValidateErr: "pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true", assert: func(t *testing.T, cfg *Config) {
}, t.Helper()
{ if cfg.Pipeline.Trim == nil || cfg.Pipeline.Trim.Enabled == nil || *cfg.Pipeline.Trim.Enabled {
name: "missing transcript input name fails when enabled", t.Fatalf("trim.enabled = %#v, want false", cfg.Pipeline.Trim)
trimYAML: `trim: }
enabled: true },
output_path: transcripts/final.trimmed.json
bounds:
prompt_id: dnd_session.bounds
output_path: artifacts/session_bounds.json
`,
wantValidateErr: "pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true",
},
{
name: "missing bounds output path fails when enabled",
trimYAML: `trim:
enabled: true
output_path: transcripts/final.trimmed.json
bounds:
prompt_id: dnd_session.bounds
transcript_input_name: transcript
`,
wantValidateErr: "pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true",
},
{
name: "missing trimmed output path fails when enabled",
trimYAML: `trim:
enabled: true
bounds:
prompt_id: dnd_session.bounds
transcript_input_name: transcript
output_path: artifacts/session_bounds.json
`,
wantValidateErr: "pipeline.trim.output_path is required when pipeline.trim.enabled is true",
}, },
{ {
name: "invalid timeout fails", name: "invalid timeout fails",
@@ -185,3 +157,31 @@ func TestTrimLoadAndValidate(t *testing.T) {
}) })
} }
} }
func assertDefaultTrimConfig(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Trim == nil {
t.Fatal("trim config should be present")
}
if cfg.Pipeline.Trim.Enabled == nil || !*cfg.Pipeline.Trim.Enabled {
t.Fatalf("trim.enabled = %#v, want true", cfg.Pipeline.Trim.Enabled)
}
if cfg.Pipeline.Trim.OutputPath != DefaultTrimOutputPath {
t.Fatalf("trim.output_path = %q, want %q", cfg.Pipeline.Trim.OutputPath, DefaultTrimOutputPath)
}
if cfg.Pipeline.Trim.Bounds.PromptID != DefaultTrimBoundsPromptID {
t.Fatalf("trim.bounds.prompt_id = %q, want %q", cfg.Pipeline.Trim.Bounds.PromptID, DefaultTrimBoundsPromptID)
}
if cfg.Pipeline.Trim.Bounds.TranscriptInputName != DefaultTrimBoundsTranscriptInputName {
t.Fatalf("trim.bounds.transcript_input_name = %q, want %q", cfg.Pipeline.Trim.Bounds.TranscriptInputName, DefaultTrimBoundsTranscriptInputName)
}
if cfg.Pipeline.Trim.Bounds.OutputPath != DefaultTrimBoundsOutputPath {
t.Fatalf("trim.bounds.output_path = %q, want %q", cfg.Pipeline.Trim.Bounds.OutputPath, DefaultTrimBoundsOutputPath)
}
if cfg.Pipeline.Trim.Bounds.Timeout != DefaultTrimBoundsTimeout {
t.Fatalf("trim.bounds.timeout = %q, want %q", cfg.Pipeline.Trim.Bounds.Timeout, DefaultTrimBoundsTimeout)
}
if cfg.Pipeline.Trim.Seriatim.Report == nil || *cfg.Pipeline.Trim.Seriatim.Report != DefaultTrimSeriatimReport {
t.Fatalf("trim.seriatim.report = %#v, want %t", cfg.Pipeline.Trim.Seriatim.Report, DefaultTrimSeriatimReport)
}
}

View File

@@ -51,6 +51,21 @@ func validateCampaign(cfg *CampaignConfig) error {
if CampaignID(cfg) == "" { if CampaignID(cfg) == "" {
return fmt.Errorf("campaign.campaign_id is required") return fmt.Errorf("campaign.campaign_id is required")
} }
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
return fmt.Errorf("campaign.inputs.speakers_file is required")
}
if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" {
return fmt.Errorf("campaign.inputs.autocorrect_file is required")
}
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("campaign.inputs.glossary_file is required")
}
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
return fmt.Errorf("campaign.inputs.players_file is required")
}
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("campaign.inputs.party_file is required")
}
return nil return nil
} }
@@ -276,7 +291,10 @@ func validateTrim(cfg *TrimConfig) error {
if cfg == nil { if cfg == nil {
return nil return nil
} }
if !cfg.Enabled { if cfg.Enabled == nil {
return fmt.Errorf("pipeline.trim.enabled must be set (defaults should populate this)")
}
if !*cfg.Enabled {
return nil return nil
} }
@@ -592,6 +610,12 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("session.inputs.glossary_file is required") return fmt.Errorf("session.inputs.glossary_file is required")
} }
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
return fmt.Errorf("session.inputs.players_file is required")
}
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("session.inputs.party_file is required")
}
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != "" hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0 hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0

View File

@@ -632,6 +632,16 @@ func resolveScriptoriumInput(
if describeErr != nil { if describeErr != nil {
return "", false, nil, describeErr return "", false, nil, describeErr
} }
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, paths)
if err != nil {
if inputCfg.Required {
return "", false, nil, err
}
return "", false, nil, nil
}
return resolvedPath, ok, nil, nil
}
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact { if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
if err == nil { if err == nil {
@@ -684,6 +694,36 @@ func resolveScriptoriumInput(
} }
} }
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
filename, ok := preparedStableInputFilename(sourceID)
if !ok {
return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID)
}
path := filepath.Join(paths.InputsDir, filename)
if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil {
return "", false, fmt.Errorf(
"prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w",
sourceID,
paths.SessionID,
err,
)
}
return path, true, nil
}
func preparedStableInputFilename(sourceID string) (string, bool) {
switch strings.TrimSpace(sourceID) {
case artifactpolicy.SourceInputPlayers:
return "players.yml", true
case artifactpolicy.SourceInputParty:
return "party.yml", true
case artifactpolicy.SourceInputGlossary:
return "glossary.yml", true
default:
return "", false
}
}
func buildAnalyzeRuntimeArtifactCatalog( func buildAnalyzeRuntimeArtifactCatalog(
paths artifacts.SessionPaths, paths artifacts.SessionPaths,
scriptoriumCfg *config.ScriptoriumConfig, scriptoriumCfg *config.ScriptoriumConfig,

View File

@@ -986,6 +986,96 @@ func TestAnalyzeSupportsRenderedMarkdownTranscriptSourceWhenConfigured(t *testin
} }
} }
func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
playersPath := filepath.Join(paths.InputsDir, "players.yml")
partyPath := filepath.Join(paths.InputsDir, "party.yml")
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
writeAnalyzeFile(t, playersPath, "- Eric\n")
writeAnalyzeFile(t, partyPath, "- Arannis\n")
writeAnalyzeFile(t, glossaryPath, "- term: Ten Towns\n")
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
Source: "narratio.input.players",
Required: true,
}
artifact.Inputs["party"] = config.ScriptoriumInputConfig{
Source: "narratio.input.party",
Required: true,
}
artifact.Inputs["glossary"] = config.ScriptoriumInputConfig{
Source: "narratio.input.glossary",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
}
if fake.RunRequests[0].InputPaths["players"] != playersPath {
t.Fatalf("players input = %q, want %q", fake.RunRequests[0].InputPaths["players"], playersPath)
}
if fake.RunRequests[0].InputPaths["party"] != partyPath {
t.Fatalf("party input = %q, want %q", fake.RunRequests[0].InputPaths["party"], partyPath)
}
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
t.Fatalf("glossary input = %q, want %q", fake.RunRequests[0].InputPaths["glossary"], glossaryPath)
}
}
func TestAnalyzeMissingRequiredPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
Source: "narratio.input.players",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "prepared input source \"narratio.input.players\" is unavailable") ||
!strings.Contains(err.Error(), "run narratio run-stage prepare 2026-05-03 --force") {
t.Fatalf("error = %q, want prepared input guidance", err.Error())
}
}
func TestAnalyzeMissingOptionalPreparedStableInputIsOmitted(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
Source: "narratio.input.players",
Required: false,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
}
if _, ok := fake.RunRequests[0].InputPaths["players"]; ok {
t.Fatalf("players input should be omitted: %#v", fake.RunRequests[0].InputPaths)
}
}
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) { func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)

View File

@@ -33,11 +33,13 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
campaignPath := filepath.Join(cfgDir, "campaign.yml") campaignPath := filepath.Join(cfgDir, "campaign.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n") writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n") writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n") writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") writeStageTestFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "a") writeStageTestFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "a")
wf := &whisperx.FakeClient{} wf := &whisperx.FakeClient{}
@@ -82,6 +84,16 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
ConfigPath: campaignPath, ConfigPath: campaignPath,
Source: "campaign_config", Source: "campaign_config",
}, },
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
}, },
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",

View File

@@ -32,6 +32,8 @@ func (prepareStage) Declares() IODecl {
{Kind: "config", Category: "inputs", RelativePath: "speakers.yml"}, {Kind: "config", Category: "inputs", RelativePath: "speakers.yml"},
{Kind: "config", Category: "inputs", RelativePath: "autocorrect.yml"}, {Kind: "config", Category: "inputs", RelativePath: "autocorrect.yml"},
{Kind: "config", Category: "inputs", RelativePath: "glossary.yml"}, {Kind: "config", Category: "inputs", RelativePath: "glossary.yml"},
{Kind: "config", Category: "inputs", RelativePath: "players.yml"},
{Kind: "config", Category: "inputs", RelativePath: "party.yml"},
{Kind: "audio", Category: "audio", RelativePath: "*.flac"}, {Kind: "audio", Category: "audio", RelativePath: "*.flac"},
}, },
} }
@@ -75,6 +77,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc) speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc)
autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc) autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc)
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc) glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc)
partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc)
speakersSrc, err := resolveConfigRelativePath(speakersInput) speakersSrc, err := resolveConfigRelativePath(speakersInput)
if err != nil { if err != nil {
@@ -88,6 +92,14 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare: glossary path: %w", err) return nil, fmt.Errorf("prepare: glossary path: %w", err)
} }
playersSrc, err := resolveConfigRelativePath(playersInput)
if err != nil {
return nil, fmt.Errorf("prepare: players path: %w", err)
}
partySrc, err := resolveConfigRelativePath(partyInput)
if err != nil {
return nil, fmt.Errorf("prepare: party path: %w", err)
}
for _, required := range []struct { for _, required := range []struct {
path string path string
@@ -96,6 +108,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
{path: speakersSrc, name: "speakers.yml"}, {path: speakersSrc, name: "speakers.yml"},
{path: autocorrectSrc, name: "autocorrect.yml"}, {path: autocorrectSrc, name: "autocorrect.yml"},
{path: glossarySrc, name: "glossary.yml"}, {path: glossarySrc, name: "glossary.yml"},
{path: playersSrc, name: "players.yml"},
{path: partySrc, name: "party.yml"},
} { } {
if err := requireFile(required.path, required.name); err != nil { if err := requireFile(required.path, required.name); err != nil {
return nil, fmt.Errorf("prepare: %w", err) return nil, fmt.Errorf("prepare: %w", err)
@@ -107,7 +121,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err) return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
} }
inputs := make([]manifest.InputRecord, 0, 6+len(resolvedLocalAudio)) inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio))
registerInput := func(kind, path, checksum string) { registerInput := func(kind, path, checksum string) {
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum}) inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
} }
@@ -166,6 +180,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source}, {kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source}, {kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source}, {kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
{kind: "players", src: playersSrc, dst: filepath.Join(paths.InputsDir, "players.yml"), source: playersInput.Source},
{kind: "party", src: partySrc, dst: filepath.Join(paths.InputsDir, "party.yml"), source: partyInput.Source},
} { } {
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst) checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
if err != nil { if err != nil {

View File

@@ -41,6 +41,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
filepath.Join(paths.InputsDir, "speakers.yml"), filepath.Join(paths.InputsDir, "speakers.yml"),
filepath.Join(paths.InputsDir, "autocorrect.yml"), filepath.Join(paths.InputsDir, "autocorrect.yml"),
filepath.Join(paths.InputsDir, "glossary.yml"), filepath.Join(paths.InputsDir, "glossary.yml"),
filepath.Join(paths.InputsDir, "players.yml"),
filepath.Join(paths.InputsDir, "party.yml"),
filepath.Join(paths.AudioDir, "alice.flac"), filepath.Join(paths.AudioDir, "alice.flac"),
filepath.Join(paths.AudioDir, "bob.flac"), filepath.Join(paths.AudioDir, "bob.flac"),
} { } {
@@ -49,8 +51,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
} }
} }
if len(m.Inputs) != 8 { if len(m.Inputs) != 10 {
t.Fatalf("manifest inputs len = %d, want 8", len(m.Inputs)) t.Fatalf("manifest inputs len = %d, want 10", len(m.Inputs))
} }
for _, in := range m.Inputs { for _, in := range m.Inputs {
if in.Checksum == "" { if in.Checksum == "" {
@@ -613,11 +615,15 @@ inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`) `)
writeFile(t, sessionPath, "session_id: 2026-05-03\n") writeFile(t, sessionPath, "session_id: 2026-05-03\n")
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
cfg := &config.Config{ cfg := &config.Config{
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
@@ -651,6 +657,16 @@ inputs:
ConfigPath: campaignPath, ConfigPath: campaignPath,
Source: "campaign_config", Source: "campaign_config",
}, },
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
}, },
} }

View File

@@ -236,10 +236,12 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
campaignPath := filepath.Join(cfgDir, "campaign.yml") campaignPath := filepath.Join(cfgDir, "campaign.yml")
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
writeFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n") writeFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
retries := 3 retries := 3
concurrency := 2 concurrency := 2
@@ -267,12 +269,16 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
SpeakersFile: "./speakers.yml", SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml", AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml", GlossaryFile: "./glossary.yml",
PlayersFile: "./players.yml",
PartyFile: "./party.yml",
}, },
}, },
StableInputs: config.ResolvedStableInputs{ StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"}, SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"},
AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"}, AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"},
GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"}, GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"},
PlayersFile: config.ResolvedInputFile{Path: "./players.yml", ConfigPath: campaignPath, Source: "campaign_config"},
PartyFile: config.ResolvedInputFile{Path: "./party.yml", ConfigPath: campaignPath, Source: "campaign_config"},
}, },
} }

View File

@@ -71,7 +71,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag
} }
trimCfg := env.Config.Pipeline.Trim trimCfg := env.Config.Pipeline.Trim
enabled := trimCfg != nil && trimCfg.Enabled enabled := trimCfg != nil && trimCfg.Enabled != nil && *trimCfg.Enabled
canonicalTrimmedPath, err := resolveTrimmedOutputPath(paths, trimCfg) canonicalTrimmedPath, err := resolveTrimmedOutputPath(paths, trimCfg)
if err != nil { if err != nil {

View File

@@ -275,7 +275,8 @@ func TestTrimStageDisabledCopiesNormalizedTranscript(t *testing.T) {
writeFile(t, normalized, normalizedBody) writeFile(t, normalized, normalizedBody)
disabled := *env.Config.Pipeline.Trim disabled := *env.Config.Pipeline.Trim
disabled.Enabled = false enabled := false
disabled.Enabled = &enabled
env.Config.Pipeline.Trim = &disabled env.Config.Pipeline.Trim = &disabled
result, err := (trimStage{}).Run(context.Background(), env, m) result, err := (trimStage{}).Run(context.Background(), env, m)
@@ -424,6 +425,7 @@ func setupTrimEnv(t *testing.T) (*Env, *manifest.Manifest, *boundsScriptoriumRun
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
seriatimReport := false seriatimReport := false
trimEnabled := true
cfg := &config.Config{ cfg := &config.Config{
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
SessionPath: sessionPath, SessionPath: sessionPath,
@@ -437,7 +439,7 @@ func setupTrimEnv(t *testing.T) (*Env, *manifest.Manifest, *boundsScriptoriumRun
Report: &seriatimReport, Report: &seriatimReport,
}, },
Trim: &config.TrimConfig{ Trim: &config.TrimConfig{
Enabled: true, Enabled: &trimEnabled,
OutputPath: "transcripts/final.trimmed.json", OutputPath: "transcripts/final.trimmed.json",
Bounds: config.TrimBoundsConfig{ Bounds: config.TrimBoundsConfig{
PromptID: "dnd_session.bounds", PromptID: "dnd_session.bounds",