Add Notarius reference configuration vocabulary
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": {}}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
43
internal/notariusref/selector.go
Normal file
43
internal/notariusref/selector.go
Normal 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")
|
||||
}
|
||||
}
|
||||
58
internal/notariusref/selector_test.go
Normal file
58
internal/notariusref/selector_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user