diff --git a/docs/config.md b/docs/config.md index f2ca18f..356e2e4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -44,7 +44,7 @@ using configured object storage. - Session files must be concrete; unresolved `{{ ... }}` placeholders fail load. - Pipeline defaults are applied before validation. - 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: - local (`audio_dir` or `audio_files`), or - S3 (`audio_s3.prefix`). @@ -69,6 +69,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ``` `session.yml` (local audio) @@ -229,7 +231,7 @@ For each artifact input `pipeline.scriptorium.artifacts..inputs.`, or `narratio.previous_session.artifact.` | +| `source` | string | Yes | built-in runtime source, prepared input source, `narratio.artifact.`, or `narratio.previous_session.artifact.` | | `artifact` | string | No | optional passthrough adapter field | | `path` | string | No | optional passthrough adapter field | | `required` | bool | No | optional input requirement | @@ -243,6 +245,8 @@ For each artifact input `pipeline.scriptorium.artifacts..inputs..inputs.` - previous-session cache: `narratio.previous_session.artifact.` @@ -23,12 +24,14 @@ Supported source families: - marks non-executable configured artifacts as reusable when output files already exist. - validates selected artifact dependency order (cycle-safe topo ordering). - 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. - runs optional render-debug, then artifact execution. - validates non-empty output files and materializes canonical outputs. ## Failure Semantics - 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 `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` inputs includes render rerun guidance. - dependency cycles or unavailable required dependencies fail. diff --git a/docs/internal/stage-prepare.md b/docs/internal/stage-prepare.md index 517893f..2cc048f 100644 --- a/docs/internal/stage-prepare.md +++ b/docs/internal/stage-prepare.md @@ -5,7 +5,7 @@ Materialize canonical current-session inputs before processing stages. ## Inputs - resolved `campaign.yml`, `session.yml`, and pipeline config -- stable input files (`speakers`, `autocorrect`, `glossary`) +- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`) - audio source: - local `audio_dir`/`audio_files`, or - S3 `audio_s3.prefix` @@ -18,6 +18,8 @@ Materialize canonical current-session inputs before processing stages. - `inputs/speakers.yml` - `inputs/autocorrect.yml` - `inputs/glossary.yml` +- `inputs/players.yml` +- `inputs/party.yml` - `audio/*.flac` - optional `previous/manifest.json` - optional `previous/artifacts/**` diff --git a/docs/operations.md b/docs/operations.md index e015f9c..6cefae3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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. +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 1. Select pipeline/campaign/session config. diff --git a/examples/campaigns/sample-campaign/campaign.yml b/examples/campaigns/sample-campaign/campaign.yml index 58725d2..39288c3 100644 --- a/examples/campaigns/sample-campaign/campaign.yml +++ b/examples/campaigns/sample-campaign/campaign.yml @@ -4,3 +4,5 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml diff --git a/examples/campaigns/sample-campaign/party.yml b/examples/campaigns/sample-campaign/party.yml new file mode 100644 index 0000000..4ef5724 --- /dev/null +++ b/examples/campaigns/sample-campaign/party.yml @@ -0,0 +1,2 @@ +- name: Example Hero + type: pc diff --git a/examples/campaigns/sample-campaign/players.yml b/examples/campaigns/sample-campaign/players.yml new file mode 100644 index 0000000..1f1d75d --- /dev/null +++ b/examples/campaigns/sample-campaign/players.yml @@ -0,0 +1,2 @@ +- name: Example Player + role: player diff --git a/examples/pipeline.full.annotated.yml b/examples/pipeline.full.annotated.yml index 8ae161e..bed9db4 100644 --- a/examples/pipeline.full.annotated.yml +++ b/examples/pipeline.full.annotated.yml @@ -143,6 +143,15 @@ scriptorium: previous_recap: source: narratio.previous_session.artifact.session_recap required: false + players: + source: narratio.input.players + required: true + party: + source: narratio.input.party + required: true + glossary: + source: narratio.input.glossary + required: false vars: session_id: true session_date: true diff --git a/examples/pipeline.production.yml b/examples/pipeline.production.yml index 743122e..b9fcf50 100644 --- a/examples/pipeline.production.yml +++ b/examples/pipeline.production.yml @@ -90,6 +90,15 @@ scriptorium: previous_recap: source: narratio.previous_session.artifact.session_recap required: false + players: + source: narratio.input.players + required: true + party: + source: narratio.input.party + required: true + glossary: + source: narratio.input.glossary + required: false vars: session_id: true session_date: true diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 2429a9b..98288d7 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -227,6 +227,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) @@ -290,6 +292,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) @@ -385,10 +389,14 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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, "autocorrect.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 stderr bytes.Buffer @@ -517,6 +525,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { @@ -535,6 +545,8 @@ inputs: 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, "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") return pipelinePath, campaignPath, sessionPath @@ -548,6 +560,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign.yml: %v", err) diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index db6d8bc..378318c 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -109,12 +109,16 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `), 0o644); err != nil { 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, "autocorrect.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{} var storeInitCalls int @@ -445,6 +449,9 @@ inputs: if !strings.Contains(stdout.String(), "OK audio") { 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) { @@ -862,6 +869,8 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) { "narratio.transcript.final_trimmed locked", "narratio.transcript.final_trimmed locked remote=published", "narratio.transcript.final dest=transcripts/full.json remote=published", + "Stable input players:", + "Stable input party:", } { if !strings.Contains(out, want) { t.Fatalf("stdout = %q, want %q", out, want) diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go index fdc66d3..2afff23 100644 --- a/internal/app/operator_inspection.go +++ b/internal/app/operator_inspection.go @@ -57,6 +57,8 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck { {name: "speakers", in: cfg.StableInputs.SpeakersFile}, {name: "autocorrect", in: cfg.StableInputs.AutocorrectFile}, {name: "glossary", in: cfg.StableInputs.GlossaryFile}, + {name: "players", in: cfg.StableInputs.PlayersFile}, + {name: "party", in: cfg.StableInputs.PartyFile}, } out := make([]stableInputCheck, 0, len(items)) for _, item := range items { diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index 42fa214..60e5db9 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -118,6 +118,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index c67e1ab..6ba01fd 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -397,6 +397,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { 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, "autocorrect.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") return pipelinePath, campaignPath, sessionPath diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 67e83f9..4f6dacb 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -1024,11 +1024,13 @@ func testConfig(t *testing.T) *config.Config { pipelinePath := filepath.Join(cfgDir, "pipeline.yml") 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, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.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") return &config.Config{ @@ -1053,6 +1055,16 @@ func testConfig(t *testing.T) *config.Config { 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", + }, }, Session: &config.SessionConfig{ SessionID: "2026-05-03", @@ -1062,6 +1074,8 @@ func testConfig(t *testing.T) *config.Config { SpeakersFile: "./speakers.yml", AutocorrectFile: "./autocorrect.yml", GlossaryFile: "./glossary.yml", + PlayersFile: "./players.yml", + PartyFile: "./party.yml", }, }, } @@ -1084,6 +1098,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` sessionYAML := `session_id: 2026-05-03 campaign: sample-campaign @@ -1092,6 +1108,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` mustWriteFile(t, pipelinePath, pipelineYAML) mustWriteFile(t, campaignPath, campaignYAML) diff --git a/internal/app/session_cli_test.go b/internal/app/session_cli_test.go index 8081b30..7a9db7f 100644 --- a/internal/app/session_cli_test.go +++ b/internal/app/session_cli_test.go @@ -22,6 +22,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil { t.Fatalf("write session template: %v", err) @@ -75,6 +77,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) diff --git a/internal/artifactpolicy/policy.go b/internal/artifactpolicy/policy.go index 0fad146..febc773 100644 --- a/internal/artifactpolicy/policy.go +++ b/internal/artifactpolicy/policy.go @@ -13,6 +13,10 @@ import ( const ( SourceBoundsSession = "narratio.bounds.session" + SourceInputPlayers = "narratio.input.players" + SourceInputParty = "narratio.input.party" + SourceInputGlossary = "narratio.input.glossary" + configuredSourcePrefix = "narratio.artifact." previousConfiguredSrcPrefix = "narratio.previous_session.artifact." ) @@ -31,6 +35,7 @@ const ( SourceKindBuiltIn SourceKind = "built_in" SourceKindConfiguredArtifact SourceKind = "configured_artifact" SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact" + SourceKindStableInput SourceKind = "stable_input" ) // Source describes one normalized artifact source identifier. @@ -118,6 +123,11 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri if trimmed == "" { return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource } + if IsStableInputSource(trimmed) { + return ScriptoriumInputSourceDescriptor{ + Source: Source{ID: trimmed, Kind: SourceKindStableInput}, + }, nil + } if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") { descriptor, err := DescribePreviousSessionSource(trimmed) if err != nil { @@ -140,6 +150,17 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri 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 // and returns both previous and configured-source vocabulary descriptors. func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) { diff --git a/internal/artifactpolicy/policy_test.go b/internal/artifactpolicy/policy_test.go index 3d32b99..fdab53f 100644 --- a/internal/artifactpolicy/policy_test.go +++ b/internal/artifactpolicy/policy_test.go @@ -112,6 +112,9 @@ func TestDescribeScriptoriumInputSource(t *testing.T) { }{ {name: "built in", source: "narratio.transcript.final_trimmed", 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: "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}, diff --git a/internal/config/campaign_config_test.go b/internal/config/campaign_config_test.go index e46229d..f71ea57 100644 --- a/internal/config/campaign_config_test.go +++ b/internal/config/campaign_config_test.go @@ -37,7 +37,7 @@ notification: func TestCampaignStrictDecodeAcceptsCampaignID(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", + "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", ) @@ -52,7 +52,7 @@ func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) { func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.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", ) @@ -67,7 +67,7 @@ func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) { func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.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", ) @@ -82,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.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", ) @@ -97,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) { func TestCampaignSessionMergeFillsStableInputs(t *testing.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", ) @@ -114,12 +114,14 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { 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.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) { 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", - "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.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 players_file: ./session-players.yml\n party_file: ./session-party.yml\n", ) 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.AutocorrectFile, "./campaign-autocorrect.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) { 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", ) @@ -151,7 +174,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) { func TestLoadMissingCampaignFileFails(t *testing.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", ) missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") diff --git a/internal/config/config.go b/internal/config/config.go index 28a9b36..dbf89a3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,8 @@ type CampaignInputsConfig struct { SpeakersFile string `yaml:"speakers_file"` AutocorrectFile string `yaml:"autocorrect_file"` GlossaryFile string `yaml:"glossary_file"` + PlayersFile string `yaml:"players_file"` + PartyFile string `yaml:"party_file"` } // SessionConfig contains per-session inputs and metadata. @@ -265,6 +267,8 @@ type SessionInputsConfig struct { SpeakersFile string `yaml:"speakers_file"` AutocorrectFile string `yaml:"autocorrect_file"` GlossaryFile string `yaml:"glossary_file"` + PlayersFile string `yaml:"players_file"` + PartyFile string `yaml:"party_file"` } // SessionAudioS3Input configures S3 session-audio input discovery. @@ -278,6 +282,8 @@ type ResolvedStableInputs struct { SpeakersFile ResolvedInputFile AutocorrectFile ResolvedInputFile GlossaryFile ResolvedInputFile + PlayersFile ResolvedInputFile + PartyFile ResolvedInputFile } // ResolvedInputFile records one merged config path and its source config file. diff --git a/internal/config/load.go b/internal/config/load.go index 0655ed6..85eefa1 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -226,11 +226,25 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig campaignPath, 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.AutocorrectFile = stable.AutocorrectFile.Path sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path + sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path + sessionCfg.Inputs.PartyFile = stable.PartyFile.Path return stable, nil } diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 031a900..91ca10e 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -36,6 +36,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, checkDefault: true, wantRoot: "/tmp/narratio", @@ -55,6 +57,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, checkDefault: true, wantRoot: "/tmp/narratio", @@ -72,6 +76,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, checkDefault: true, wantRoot: DefaultWorkspaceRoot, @@ -88,6 +94,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "pipeline file", }, @@ -106,6 +114,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "strict decode failed", }, @@ -123,6 +133,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "strict decode failed", }, @@ -143,6 +155,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "strict decode failed", }, @@ -163,6 +177,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -177,6 +193,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml unknown_field: true `, wantLoadErr: "session file", @@ -196,6 +214,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantValidate: "session config \"session.yml\" invalid: session.session_id is required", }, @@ -215,6 +235,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, }, { @@ -233,6 +255,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -250,6 +274,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -268,6 +294,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -287,6 +315,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -306,6 +336,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -325,6 +357,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -344,6 +378,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -363,6 +399,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "strict decode failed", }, @@ -383,6 +421,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "strict decode failed", }, @@ -401,6 +441,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, }, { @@ -419,6 +461,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -438,6 +482,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -457,6 +503,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -477,6 +525,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -498,6 +548,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "strict decode failed", }, @@ -518,6 +570,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, }, { @@ -538,6 +592,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -559,6 +615,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, }, { @@ -581,6 +639,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -604,6 +664,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -625,6 +687,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -646,6 +710,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantLoadErr: "strict decode failed", }, @@ -667,6 +733,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -688,6 +756,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -709,6 +779,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -730,6 +802,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -751,6 +825,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.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", }, @@ -916,7 +992,16 @@ func TestValidateMissingAudioSource(t *testing.T) { 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{ SessionID: "2026-05-03", Campaign: "sample-campaign", @@ -924,6 +1009,8 @@ func TestValidateMissingAudioSource(t *testing.T) { SpeakersFile: "speakers.yml", AutocorrectFile: "autocorrect.yml", GlossaryFile: "glossary.yml", + PlayersFile: "players.yml", + PartyFile: "party.yml", }, }, } @@ -1009,6 +1096,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign.yml: %v", err) diff --git a/internal/config/scriptorium_test.go b/internal/config/scriptorium_test.go index f8c732e..d192fc5 100644 --- a/internal/config/scriptorium_test.go +++ b/internal/config/scriptorium_test.go @@ -215,6 +215,27 @@ func TestScriptoriumLoadAndValidate(t *testing.T) { transcript_markdown: source: narratio.transcript.final_markdown 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 autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` diff --git a/internal/config/session_template_test.go b/internal/config/session_template_test.go index c41f005..9aaf434 100644 --- a/internal/config/session_template_test.go +++ b/internal/config/session_template_test.go @@ -17,6 +17,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -40,6 +42,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -65,6 +69,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -93,6 +99,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -124,6 +132,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) @@ -148,6 +158,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) diff --git a/internal/config/storage_publish_test.go b/internal/config/storage_publish_test.go index a883ecb..660baab 100644 --- a/internal/config/storage_publish_test.go +++ b/internal/config/storage_publish_test.go @@ -238,6 +238,15 @@ func TestPublishOutputValidation(t *testing.T) { `, 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", ruleYML: `publish: @@ -426,6 +435,15 @@ publish: `, 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", pipelineYML: testPipelineBaseYAML + ` @@ -579,6 +597,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, }, { @@ -591,6 +611,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantErr: "session.inputs.audio_s3.prefix must be a relative path", }, @@ -604,6 +626,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantErr: "session.inputs.audio_s3.prefix must not contain path traversal", }, @@ -618,6 +642,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `, wantErr: "mutually exclusive", }, @@ -664,6 +690,8 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml ` pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML) cfg, err := Load(pipelinePath, sessionPath) diff --git a/internal/config/validate.go b/internal/config/validate.go index 70de29a..a500cbc 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -51,6 +51,21 @@ func validateCampaign(cfg *CampaignConfig) error { if CampaignID(cfg) == "" { 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 } @@ -595,6 +610,12 @@ func validateSession(cfg *SessionConfig) error { if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { 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) != "" hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0 diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index cbe39e7..bc805f7 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -632,6 +632,16 @@ func resolveScriptoriumInput( if describeErr != nil { 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 { resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) 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( paths artifacts.SessionPaths, scriptoriumCfg *config.ScriptoriumConfig, diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index 008e9e4..e781ca4 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -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) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) diff --git a/internal/stage/placeholders_test.go b/internal/stage/placeholders_test.go index cc69a09..ab9de29 100644 --- a/internal/stage/placeholders_test.go +++ b/internal/stage/placeholders_test.go @@ -33,11 +33,13 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { campaignPath := filepath.Join(cfgDir, "campaign.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml") 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, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.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") wf := &whisperx.FakeClient{} @@ -82,6 +84,16 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { 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", + }, }, Session: &config.SessionConfig{ SessionID: "2026-05-03", diff --git a/internal/stage/prepare.go b/internal/stage/prepare.go index bd395bb..33cf710 100644 --- a/internal/stage/prepare.go +++ b/internal/stage/prepare.go @@ -32,6 +32,8 @@ func (prepareStage) Declares() IODecl { {Kind: "config", Category: "inputs", RelativePath: "speakers.yml"}, {Kind: "config", Category: "inputs", RelativePath: "autocorrect.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"}, }, } @@ -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) autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, 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) if err != nil { @@ -88,6 +92,14 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S if err != nil { 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 { path string @@ -96,6 +108,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S {path: speakersSrc, name: "speakers.yml"}, {path: autocorrectSrc, name: "autocorrect.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 { 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) } - inputs := make([]manifest.InputRecord, 0, 6+len(resolvedLocalAudio)) + inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio)) registerInput := func(kind, path, checksum string) { 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: "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: "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) if err != nil { diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index 1502b71..baa4204 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -41,6 +41,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) { filepath.Join(paths.InputsDir, "speakers.yml"), filepath.Join(paths.InputsDir, "autocorrect.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, "bob.flac"), } { @@ -49,8 +51,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) { } } - if len(m.Inputs) != 8 { - t.Fatalf("manifest inputs len = %d, want 8", len(m.Inputs)) + if len(m.Inputs) != 10 { + t.Fatalf("manifest inputs len = %d, want 10", len(m.Inputs)) } for _, in := range m.Inputs { if in.Checksum == "" { @@ -613,11 +615,15 @@ inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml + players_file: ./players.yml + party_file: ./party.yml `) 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, "autocorrect.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{ Pipeline: &config.PipelineConfig{ @@ -651,6 +657,16 @@ inputs: 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", + }, }, } diff --git a/internal/stage/transcribe_test.go b/internal/stage/transcribe_test.go index a43b6c4..d5e3008 100644 --- a/internal/stage/transcribe_test.go +++ b/internal/stage/transcribe_test.go @@ -236,10 +236,12 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani campaignPath := filepath.Join(cfgDir, "campaign.yml") writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\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, "autocorrect.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 concurrency := 2 @@ -267,12 +269,16 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani SpeakersFile: "./speakers.yml", AutocorrectFile: "./autocorrect.yml", GlossaryFile: "./glossary.yml", + PlayersFile: "./players.yml", + PartyFile: "./party.yml", }, }, StableInputs: config.ResolvedStableInputs{ SpeakersFile: config.ResolvedInputFile{Path: "./speakers.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"}, + PlayersFile: config.ResolvedInputFile{Path: "./players.yml", ConfigPath: campaignPath, Source: "campaign_config"}, + PartyFile: config.ResolvedInputFile{Path: "./party.yml", ConfigPath: campaignPath, Source: "campaign_config"}, }, }