Add Notarius reference configuration vocabulary

This commit is contained in:
2026-08-29 14:55:24 +00:00
parent 42ed81cbe1
commit 5831c0c9e6
12 changed files with 476 additions and 42 deletions

View File

@@ -13,9 +13,10 @@ import (
const (
SourceBoundsSession = "narratio.bounds.session"
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
SourceInputSpellCatalog = "narratio.input.spell_catalog"
configuredSourcePrefix = "narratio.artifact."
extractionSourcePrefix = "narratio.extraction."
@@ -52,9 +53,18 @@ type Source struct {
// ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source.
type ScriptoriumInputSourceDescriptor struct {
Source Source
PreparedInput *PreparedInputSourceDescriptor
PreviousSession *PreviousSessionSourceDescriptor
}
// PreparedInputSourceDescriptor describes a prepared stable input's source,
// manifest kind, and canonical staged filename.
type PreparedInputSourceDescriptor struct {
SourceID string
ManifestKind string
Filename string
}
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
type PreviousSessionSourceDescriptor struct {
SourceID string
@@ -158,9 +168,10 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
if trimmed == "" {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
}
if IsStableInputSource(trimmed) {
if prepared, ok := DescribePreparedInputSource(trimmed); ok {
return ScriptoriumInputSourceDescriptor{
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
Source: Source{ID: prepared.SourceID, Kind: SourceKindStableInput},
PreparedInput: &prepared,
}, nil
}
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
@@ -188,12 +199,38 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
// 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
}
_, ok := DescribePreparedInputSource(source)
return ok
}
var preparedInputSources = map[string]PreparedInputSourceDescriptor{
SourceInputPlayers: {
SourceID: SourceInputPlayers,
ManifestKind: "players",
Filename: "players.yml",
},
SourceInputParty: {
SourceID: SourceInputParty,
ManifestKind: "party",
Filename: "party.yml",
},
SourceInputGlossary: {
SourceID: SourceInputGlossary,
ManifestKind: "glossary",
Filename: "glossary.yml",
},
SourceInputSpellCatalog: {
SourceID: SourceInputSpellCatalog,
ManifestKind: "spell_catalog",
Filename: "spell_catalog.json",
},
}
// DescribePreparedInputSource returns the canonical descriptor for a prepared
// stable input source.
func DescribePreparedInputSource(source string) (PreparedInputSourceDescriptor, bool) {
descriptor, ok := preparedInputSources[strings.TrimSpace(source)]
return descriptor, ok
}
// DescribePreviousSessionSource validates a canonical previous-session source id

View File

@@ -170,6 +170,7 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
{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: "prepared spell catalog input", source: "narratio.input.spell_catalog", 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},
@@ -211,6 +212,43 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
}
}
func TestDescribePreparedInputSource(t *testing.T) {
tests := []struct {
source string
manifestKind string
filename string
}{
{source: SourceInputPlayers, manifestKind: "players", filename: "players.yml"},
{source: SourceInputParty, manifestKind: "party", filename: "party.yml"},
{source: SourceInputGlossary, manifestKind: "glossary", filename: "glossary.yml"},
{source: SourceInputSpellCatalog, manifestKind: "spell_catalog", filename: "spell_catalog.json"},
}
for _, tt := range tests {
t.Run(tt.manifestKind, func(t *testing.T) {
descriptor, ok := DescribePreparedInputSource(" " + tt.source + " ")
if !ok {
t.Fatalf("DescribePreparedInputSource(%q) ok = false", tt.source)
}
if descriptor.SourceID != tt.source || descriptor.ManifestKind != tt.manifestKind || descriptor.Filename != tt.filename {
t.Fatalf("DescribePreparedInputSource(%q) = %#v", tt.source, descriptor)
}
scriptoriumDescriptor, err := DescribeScriptoriumInputSource(tt.source)
if err != nil {
t.Fatalf("DescribeScriptoriumInputSource(%q) error = %v", tt.source, err)
}
if scriptoriumDescriptor.PreparedInput == nil || *scriptoriumDescriptor.PreparedInput != descriptor {
t.Fatalf("prepared input descriptor = %#v, want %#v", scriptoriumDescriptor.PreparedInput, descriptor)
}
})
}
if _, ok := DescribePreparedInputSource("narratio.input.unknown"); ok {
t.Fatal("DescribePreparedInputSource(unknown) ok = true")
}
}
func TestValidateInputConfiguredReference(t *testing.T) {
configured := map[string]struct{}{"session_recap": {}}

View File

@@ -138,6 +138,74 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
}
func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
tests := []struct {
name string
campaignValue string
sessionValue string
wantPath string
wantSource string
}{
{name: "omitted", wantPath: "", wantSource: "campaign_config"},
{name: "campaign inherited", campaignValue: "./campaign-spells.json", wantPath: "./campaign-spells.json", wantSource: "campaign_config"},
{name: "session override", campaignValue: "./campaign-spells.json", sessionValue: "./session-spells.json", wantPath: "./session-spells.json", wantSource: "session_config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
campaignSpell := ""
if tt.campaignValue != "" {
campaignSpell = " spell_catalog_file: " + tt.campaignValue + "\n"
}
sessionSpell := ""
if tt.sessionValue != "" {
sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n"
}
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 players_file: ./players.yml\n party_file: ./party.yml\n"+campaignSpell,
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell,
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
wantConfigPath := campaignPath
if tt.wantSource == "session_config" {
wantConfigPath = sessionPath
}
assertResolvedStableInput(t, cfg.StableInputs.SpellCatalogFile, tt.wantPath, wantConfigPath, tt.wantSource)
if cfg.Session.Inputs.SpellCatalogFile != tt.wantPath {
t.Fatalf("session spell_catalog_file = %q, want %q", cfg.Session.Inputs.SpellCatalogFile, tt.wantPath)
}
})
}
}
func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
tests := []struct {
name string
campaignLine string
sessionLine string
wantErr string
}{
{name: "campaign", campaignLine: " spell_catalog_file: ' '\n", wantErr: "campaign.inputs.spell_catalog_file"},
{name: "session", sessionLine: " spell_catalog_file: ' '\n", wantErr: "session.inputs.spell_catalog_file"},
}
for _, tt := range tests {
t.Run(tt.name, func(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 players_file: ./players.yml\n party_file: ./party.yml\n"+tt.campaignLine,
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine,
)
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("LoadWithSessionOptions() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
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",

View File

@@ -49,11 +49,12 @@ type CampaignConfig struct {
// CampaignInputsConfig contains stable campaign-level input file references.
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"`
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"`
SpellCatalogFile string `yaml:"spell_catalog_file"`
}
// SessionConfig contains per-session inputs and metadata.
@@ -266,6 +267,7 @@ type NotariusConfig struct {
PipelineID string `yaml:"pipeline_id"`
Timeout string `yaml:"timeout"`
WorkingDirectory string `yaml:"working_directory"`
References map[string]string `yaml:"references"`
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
}
@@ -285,14 +287,15 @@ type NotificationConfig struct {
// SessionInputsConfig contains per-session input references.
type SessionInputsConfig struct {
AudioDir string `yaml:"audio_dir"`
AudioFiles []string `yaml:"audio_files"`
AudioS3 *SessionAudioS3Input `yaml:"audio_s3"`
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"`
AudioDir string `yaml:"audio_dir"`
AudioFiles []string `yaml:"audio_files"`
AudioS3 *SessionAudioS3Input `yaml:"audio_s3"`
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"`
SpellCatalogFile string `yaml:"spell_catalog_file"`
}
// SessionAudioS3Input configures S3 session-audio input discovery.
@@ -303,11 +306,12 @@ type SessionAudioS3Input struct {
// ResolvedStableInputs records where stable input file paths came from after
// campaign/session merge.
type ResolvedStableInputs struct {
SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile
SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile
SpellCatalogFile ResolvedInputFile
}
// ResolvedInputFile records one merged config path and its source config file.

View File

@@ -35,6 +35,9 @@ const (
DefaultAuditaReport = true
DefaultNotariusBinary = "notarius"
DefaultNotariusTimeout = "3h"
// MaxNotariusReferenceBindings is the maximum number of CLI reference
// bindings accepted for one Notarius invocation.
MaxNotariusReferenceBindings = 256
DefaultScriptoriumBinary = "scriptorium"
DefaultScriptoriumTimeout = "10m"

View File

@@ -209,6 +209,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
if sessionCfg == nil {
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
}
if campaignCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(campaignCfg.Inputs.SpellCatalogFile) == "" {
return ResolvedStableInputs{}, fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
}
if sessionCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(sessionCfg.Inputs.SpellCatalogFile) == "" {
return ResolvedStableInputs{}, fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
}
campaignName := CampaignID(campaignCfg)
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
@@ -254,6 +260,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
campaignPath,
sessionPath,
),
SpellCatalogFile: selectStableInput(
campaignCfg.Inputs.SpellCatalogFile,
sessionCfg.Inputs.SpellCatalogFile,
campaignPath,
sessionPath,
),
}
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
@@ -261,6 +273,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path
return stable, nil
}

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -108,6 +109,7 @@ func TestNotariusStrictYAML(t *testing.T) {
{name: "unknown output field", yaml: "notarius:\n outputs:\n npc_registry:\n lane_id: npc-registry\n unknown: true\n"},
{name: "unsupported session id", yaml: "notarius:\n session_id: forbidden\n"},
{name: "unsupported model", yaml: "notarius:\n model: forbidden\n"},
{name: "duplicate reference selector", yaml: "notarius:\n references:\n party: narratio.input.party\n party: narratio.input.players\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -122,6 +124,134 @@ func TestNotariusStrictYAML(t *testing.T) {
}
}
func TestNotariusReferenceValidationAndNormalization(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = map[string]string{
" party ": " narratio.input.party ",
" chunk . players ": "narratio.input.players",
" npc-registry . extract . glossary ": "narratio.input.glossary",
"spells": "narratio.input.spell_catalog",
}
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius() error = %v", err)
}
want := map[string]string{
"party": "narratio.input.party",
"chunk.players": "narratio.input.players",
"npc-registry.extract.glossary": "narratio.input.glossary",
"spells": "narratio.input.spell_catalog",
}
if len(cfg.References) != len(want) {
t.Fatalf("normalized references = %#v, want %#v", cfg.References, want)
}
for selector, source := range want {
if cfg.References[selector] != source {
t.Fatalf("references[%q] = %q, want %q", selector, cfg.References[selector], source)
}
}
}
func TestNotariusReferenceValidationRejectsInvalidBindings(t *testing.T) {
tests := []struct {
name string
references map[string]string
wantErr string
}{
{name: "empty selector", references: map[string]string{" ": "narratio.input.party"}, wantErr: "selector is required"},
{name: "equals in selector", references: map[string]string{"party=x": "narratio.input.party"}, wantErr: "must not contain"},
{name: "invalid stage", references: map[string]string{"lane.prepare.party": "narratio.input.party"}, wantErr: "middle component"},
{name: "empty source", references: map[string]string{"party": " "}, wantErr: "source is required"},
{name: "unsupported source", references: map[string]string{"party": "narratio.input.unknown"}, wantErr: "not a supported prepared input source"},
{
name: "normalized collision",
references: map[string]string{
"chunk.party": "narratio.input.party",
" chunk . party ": "narratio.input.players",
},
wantErr: "normalize to",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = tt.references
err := validateNotarius(cfg, nil)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validateNotarius() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestNotariusReferenceLimit(t *testing.T) {
for _, count := range []int{MaxNotariusReferenceBindings, MaxNotariusReferenceBindings + 1} {
t.Run(fmt.Sprintf("count_%d", count), func(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = make(map[string]string, count)
for i := 0; i < count; i++ {
cfg.References[fmt.Sprintf("lane-%03d.party", i)] = "narratio.input.party"
}
err := validateNotarius(cfg, nil)
if count == MaxNotariusReferenceBindings {
if err != nil {
t.Fatalf("validateNotarius() at limit error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), "at most 256 bindings") {
t.Fatalf("validateNotarius() above limit error = %v", err)
}
})
}
}
func TestNotariusNilAndEmptyReferencesAreValid(t *testing.T) {
for _, references := range []map[string]string{nil, {}} {
cfg := validNotariusConfig()
cfg.References = references
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius(%#v) error = %v", references, err)
}
}
}
func TestNotariusSpellCatalogReferenceRequiresEffectiveInput(t *testing.T) {
cfg := loadedValidConfig(t)
cfg.Pipeline.Notarius = validNotariusConfig()
cfg.Pipeline.Notarius.References = map[string]string{"spells": "narratio.input.spell_catalog"}
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), "requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file") {
t.Fatalf("Validate() error = %v, want missing spell catalog input", err)
}
cfg.StableInputs.SpellCatalogFile = ResolvedInputFile{Path: "./spells.json", Source: "campaign_config"}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() with spell catalog error = %v", err)
}
}
func validNotariusConfig() *NotariusConfig {
return &NotariusConfig{
Enabled: true,
Binary: "notarius",
ConfigPath: "./notarius.yml",
PipelineID: "dnd-session",
Timeout: "45m",
WorkingDirectory: ".",
Outputs: map[string]NotariusOutputConfig{
"npc_registry": {
LaneID: "npc-registry",
MediaType: "application/json",
SchemaID: "notarius.dnd.npc_registry",
SchemaVersion: "v1",
},
},
}
}
func TestNotariusEnabledValidation(t *testing.T) {
valid := `notarius:
enabled: true

View File

@@ -12,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
@@ -39,7 +40,7 @@ func Validate(cfg *Config) error {
if err := validateSession(cfg.Session); err != nil {
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
}
if err := validateCrossConfig(cfg.Pipeline, cfg.Session); err != nil {
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
return fmt.Errorf("pipeline/session config invalid: %w", err)
}
@@ -71,6 +72,9 @@ func validateCampaign(cfg *CampaignConfig) error {
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("campaign.inputs.party_file is required")
}
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
}
return nil
}
@@ -547,6 +551,39 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
if strings.TrimSpace(cfg.WorkingDirectory) == "" {
return fmt.Errorf("pipeline.notarius.working_directory is required when pipeline.notarius.enabled is true")
}
if len(cfg.References) > MaxNotariusReferenceBindings {
return fmt.Errorf("pipeline.notarius.references must contain at most %d bindings", MaxNotariusReferenceBindings)
}
referenceKeys := make([]string, 0, len(cfg.References))
for selector := range cfg.References {
referenceKeys = append(referenceKeys, selector)
}
sort.Strings(referenceKeys)
var normalizedReferences map[string]string
if cfg.References != nil {
normalizedReferences = make(map[string]string, len(cfg.References))
}
referenceOwners := make(map[string]string, len(cfg.References))
for _, rawSelector := range referenceKeys {
selector, err := notariusref.NormalizeSelector(rawSelector)
if err != nil {
return fmt.Errorf("pipeline.notarius.references selector %q is invalid: %w", rawSelector, err)
}
if previous, ok := referenceOwners[selector]; ok {
return fmt.Errorf("pipeline.notarius.references selectors %q and %q normalize to %q", previous, rawSelector, selector)
}
referenceOwners[selector] = rawSelector
source := strings.TrimSpace(cfg.References[rawSelector])
if source == "" {
return fmt.Errorf("pipeline.notarius.references.%s source is required", selector)
}
if _, ok := artifactpolicy.DescribePreparedInputSource(source); !ok {
return fmt.Errorf("pipeline.notarius.references.%s source %q is not a supported prepared input source", selector, source)
}
normalizedReferences[selector] = source
}
reservedSources := map[string]string{}
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
@@ -613,6 +650,7 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
cfg.PipelineID = strings.TrimSpace(cfg.PipelineID)
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
cfg.WorkingDirectory = filepath.Clean(cfg.WorkingDirectory)
cfg.References = normalizedReferences
cfg.Outputs = normalizedOutputs
return nil
}
@@ -762,6 +800,9 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("session.inputs.party_file is required")
}
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
}
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
@@ -797,10 +838,17 @@ func validateSessionIdentifier(fieldName, value string, required bool) error {
return nil
}
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error {
if pipeline == nil || session == nil {
return nil
}
if pipeline.Notarius != nil && pipeline.Notarius.Enabled {
for selector, source := range pipeline.Notarius.References {
if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" {
return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector)
}
}
}
audioS3Enabled := session.Inputs.AudioS3 != nil
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)

View File

@@ -0,0 +1,43 @@
// Package notariusref owns Narratio's Notarius CLI reference-selector
// vocabulary without depending on Notarius implementation packages.
package notariusref
import (
"fmt"
"strings"
)
// NormalizeSelector validates and normalizes a Notarius v0.6 reference
// selector. It intentionally validates selector structure only; Notarius owns
// target, slot, and media compatibility.
func NormalizeSelector(value string) (string, error) {
selector := strings.TrimSpace(value)
if selector == "" {
return "", fmt.Errorf("reference selector is required")
}
if strings.Contains(selector, "=") {
return "", fmt.Errorf("reference selector must not contain '='")
}
parts := strings.Split(selector, ".")
for index := range parts {
parts[index] = strings.TrimSpace(parts[index])
if parts[index] == "" {
return "", fmt.Errorf("reference selector components must not be empty")
}
}
switch len(parts) {
case 1, 2:
return strings.Join(parts, "."), nil
case 3:
switch parts[1] {
case "extract", "merge", "normalize":
return strings.Join(parts, "."), nil
default:
return "", fmt.Errorf("three-component reference selector must use extract, merge, or normalize as its middle component")
}
default:
return "", fmt.Errorf("reference selector must use slot, chunk.slot, lane.slot, or lane.stage.slot")
}
}

View File

@@ -0,0 +1,58 @@
package notariusref
import (
"strings"
"testing"
)
func TestNormalizeSelectorAcceptsDocumentedForms(t *testing.T) {
tests := []struct {
name string
value string
want string
}{
{name: "pipeline", value: "party", want: "party"},
{name: "chunk", value: "chunk.party", want: "chunk.party"},
{name: "lane", value: "npc-registry.party", want: "npc-registry.party"},
{name: "extract", value: "npc-registry.extract.party", want: "npc-registry.extract.party"},
{name: "merge", value: "npc-registry.merge.party", want: "npc-registry.merge.party"},
{name: "normalize", value: "npc-registry.normalize.party", want: "npc-registry.normalize.party"},
{name: "whitespace", value: " npc-registry . extract . party ", want: "npc-registry.extract.party"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := NormalizeSelector(test.value)
if err != nil {
t.Fatalf("NormalizeSelector(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("NormalizeSelector(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestNormalizeSelectorRejectsInvalidForms(t *testing.T) {
tests := []struct {
name string
value string
wantErr string
}{
{name: "empty", value: "", wantErr: "required"},
{name: "whitespace", value: " ", wantErr: "required"},
{name: "empty first", value: ".party", wantErr: "components"},
{name: "empty middle", value: "lane..party", wantErr: "components"},
{name: "empty final", value: "lane.", wantErr: "components"},
{name: "equals", value: "party=/tmp/party.yml", wantErr: "must not contain"},
{name: "invalid stage", value: "lane.chunk.party", wantErr: "extract, merge, or normalize"},
{name: "too many components", value: "lane.extract.party.extra", wantErr: "must use"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := NormalizeSelector(test.value)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("NormalizeSelector(%q) error = %v, want containing %q", test.value, err, test.wantErr)
}
})
}
}

View File

@@ -728,16 +728,8 @@ func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (
}
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
}
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
return descriptor.Filename, ok
}
func buildAnalyzeRuntimeArtifactCatalog(