Implemented new campaign/session stable inputs and corresponding input source references
All checks were successful
ci/woodpecker/tag/release Pipeline was successful

This commit is contained in:
2026-05-27 09:34:05 -05:00
parent 3ddb3a947b
commit 717451512a
32 changed files with 529 additions and 19 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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},

View File

@@ -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")

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
`

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

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) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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",
},
},
}

View File

@@ -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"},
},
}