From fb9a4d14f4334e59aff664a748ebed0abcbf5970 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 30 Aug 2026 14:15:57 +0000 Subject: [PATCH] Expand canonical party artifact families --- docs/config.md | 30 +++ docs/internal/configuration.md | 17 ++ docs/internal/stage-analyze.md | 7 +- docs/roadmap/implementation.md | 2 +- internal/config/artifact_families.go | 254 ++++++++++++++++++++++ internal/config/artifact_families_test.go | 227 +++++++++++++++++++ internal/config/config.go | 71 +++++- internal/config/load.go | 4 + internal/config/pipeline_composition.go | 14 +- internal/config/validate.go | 5 + 10 files changed, 616 insertions(+), 15 deletions(-) create mode 100644 internal/config/artifact_families.go create mode 100644 internal/config/artifact_families_test.go diff --git a/docs/config.md b/docs/config.md index 8f700d2..d1dda97 100644 --- a/docs/config.md +++ b/docs/config.md @@ -323,6 +323,7 @@ Rules: | `pipeline.scriptorium.timeout` | duration | No | `10m` | | `pipeline.scriptorium.render_debug` | bool | No | `false` | | `pipeline.scriptorium.artifacts` | map | No | empty | +| `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character | | `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented | ### Notarius Reference Bindings @@ -422,6 +423,35 @@ For each artifact input `pipeline.scriptorium.artifacts..inputs.`: + +| Field | Type | Required | Rule | +| --- | --- | --- | --- | +| `enabled`, `prompt_id`, `profile_id`, `timeout`, `render_debug`, `depends_on`, `inputs`, `vars` | ordinary artifact fields | No | copied to each generated artifact under the corresponding ordinary rules | +| `for_each` | string | Yes | exactly `party.characters` | +| `output_path_pattern` | string | Yes | safe path beneath `artifacts/` with exactly one `{character_id}` token and no other brace syntax | +| `member_vars` | map | No | maps an ordinary Scriptorium variable name to a supported canonical character selector | +| `member_dependencies` | list | No | retained as typed family metadata; it does not yet add ordinary dependencies | +| `publish` | map | No | typed family publish metadata (`enabled`, `required`, `dest_pattern`); it does not yet create publish outputs | + +Generated keys are `_` and generated output paths must +not collide with explicit artifacts or another generated artifact. Families +expand even when disabled; normal analyze selection still omits disabled +artifacts unless they are explicitly selected by their concrete key. + +Supported `member_vars` selectors are `character_id`, `player.name`, +`character.name`, `character.class_summary`, and `character.alias_summary`. +Their resolved values are strings. A member variable may not reuse a static +`vars` name; `session_id` remains owned and overwritten by Narratio as for any +other Scriptorium artifact. + ### Notifications Narratio currently supports only `notification.mode: noop`, which is also the diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index 57777d9..c2a880b 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -114,3 +114,20 @@ deterministic players projection without involving campaign or runtime wiring. `party_resolution_test.go` protects campaign-owned party loading, canonical input restrictions, legacy overrides, source provenance, and virtual players input selection. + +## Artifact Family Resolution + +Pipeline loading retains `scriptorium.artifact_families` as a resolution-only +declaration. Once campaign party resolution establishes a canonical roster, +configuration expands families in sorted family-key and character-ID order +into ordinary `ScriptoriumArtifactConfig` values. The expansion owns the narrow +`{character_id}` output substitution, closed member-variable selectors, key and +output collision checks, and the runtime-only family-origin catalog. It then +removes family declarations from `ScriptoriumConfig`, runs ordinary Scriptorium +validation, and refreshes the effective pipeline digest. Stages and adapters +therefore receive only concrete artifact maps. + +The catalog retains sorted family member keys plus family/character/source +origins and the typed dependency/publish declarations for their later owners. +It does not create a new runtime artifact type, dependency edge, or publish +rule during this resolution step. diff --git a/docs/internal/stage-analyze.md b/docs/internal/stage-analyze.md index fb6c1cc..af8d7e1 100644 --- a/docs/internal/stage-analyze.md +++ b/docs/internal/stage-analyze.md @@ -7,7 +7,9 @@ dependency order, and safely materialize validated outputs. ## Inputs -- configured artifacts from `pipeline.scriptorium.artifacts` +- ordinary configured artifacts from `pipeline.scriptorium.artifacts`; canonical + party artifact families have already expanded into this map during + configuration resolution - optional selected artifact keys supplied through the stage environment - built-in, configured, extraction, and previous-session source references in artifact inputs @@ -165,7 +167,8 @@ Supported source families: ## Related Contracts And Tests - [Configuration](../config.md#scriptorium-artifact-entries) owns artifact - fields and source-selection rules. + fields and source-selection rules, including + [artifact families](../config.md#scriptorium-artifact-families). - [CLI](../cli.md) owns user-visible artifact selection. - [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract. - Implementation and tests: `internal/stage/analyze.go`, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3f50275..05d4320 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -755,7 +755,7 @@ and Scriptorium. ## Stage 13 — Basic Party-Driven Artifact Family Expansion -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/config/artifact_families.go b/internal/config/artifact_families.go new file mode 100644 index 0000000..dad1829 --- /dev/null +++ b/internal/config/artifact_families.go @@ -0,0 +1,254 @@ +package config + +import ( + "fmt" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" +) + +const ( + partyCharactersFamilySource = "party.characters" + characterIDToken = "{character_id}" +) + +var memberVariableSelectors = map[string]func(PartyCharacter) string{ + "character_id": func(character PartyCharacter) string { return character.ID }, + "player.name": func(character PartyCharacter) string { return character.Player.Name }, + "character.name": func(character PartyCharacter) string { return character.Character.Name }, + "character.class_summary": func(character PartyCharacter) string { return character.ClassSummary() }, + "character.alias_summary": func(character PartyCharacter) string { return character.AliasSummary() }, +} + +func retainArtifactFamilyDeclarations(cfg *PipelineConfig) { + if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil { + return + } + cfg.resolution.artifactFamilies = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies) +} + +func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) error { + if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil || cfg.resolution.artifactFamiliesExpanded { + return nil + } + families := cfg.resolution.artifactFamilies + if len(families) == 0 { + families = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies) + } + if len(families) == 0 { + return nil + } + if party.Mode != PartyModeCanonical || party.Canonical == nil { + return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party") + } + + expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts) + familyKeys := sortedFamilyKeys(families) + for _, familyKey := range familyKeys { + if !artifactpolicy.IsConfiguredKey(familyKey) { + return fmt.Errorf("pipeline.scriptorium.artifact_families keys must match ^[a-z][a-z0-9_]*$") + } + if _, exists := expanded[familyKey]; exists { + return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey) + } + } + + characters := append([]PartyCharacter(nil), party.Canonical.Characters...) + sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID }) + catalog := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(familyKeys)), Members: map[string]ArtifactFamilyMemberOrigin{}} + outputOwners := make(map[string]string, len(expanded)) + for key, artifact := range expanded { + if output := strings.TrimSpace(artifact.OutputPath); output != "" { + outputOwners[output] = key + } + } + for _, familyKey := range familyKeys { + family := families[familyKey] + prefix := "pipeline.scriptorium.artifact_families." + familyKey + if strings.TrimSpace(family.ForEach) != partyCharactersFamilySource { + return fmt.Errorf("%s.for_each must be %q", prefix, partyCharactersFamilySource) + } + if err := validateFamilyOutputPattern(prefix+".output_path_pattern", family.OutputPathPattern); err != nil { + return err + } + familyOrigin := ArtifactFamilyOrigin{ + Members: make([]string, 0, len(characters)), + MemberDependencies: append([]string(nil), family.MemberDependencies...), + Publish: cloneArtifactFamilyPublish(family.Publish), + Source: artifactFamilySource(cfg, familyKey), + } + for _, character := range characters { + key := familyKey + "_" + character.ID + if !artifactpolicy.IsConfiguredKey(key) { + return fmt.Errorf("%s generates invalid artifact key %q", prefix, key) + } + if _, exists := expanded[key]; exists { + return fmt.Errorf("%s generates artifact key %q that collides with another artifact", prefix, key) + } + outputPath := strings.ReplaceAll(family.OutputPathPattern, characterIDToken, character.ID) + if prior, exists := outputOwners[outputPath]; exists { + return fmt.Errorf("%s.output_path_pattern generates output path %q already used by artifact %q", prefix, outputPath, prior) + } + vars, err := resolveFamilyVars(prefix, family, character) + if err != nil { + return err + } + expanded[key] = ScriptoriumArtifactConfig{ + Enabled: family.Enabled, DependsOn: append([]string(nil), family.DependsOn...), RenderDebug: family.RenderDebug, + PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout, + Inputs: cloneArtifactInputs(family.Inputs), Vars: vars, + } + outputOwners[outputPath] = key + familyOrigin.Members = append(familyOrigin.Members, key) + catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source} + } + catalog.Families[familyKey] = familyOrigin + } + cfg.Scriptorium.Artifacts = expanded + cfg.Scriptorium.ArtifactFamilies = nil + cfg.familyCatalog = catalog + cfg.resolution.artifactFamiliesExpanded = true + if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil { + return err + } + if err := recomputePipelineEffectiveDigest(cfg); err != nil { + return fmt.Errorf("refresh expanded pipeline digest: %w", err) + } + return nil +} + +func validateFamilyOutputPattern(field, pattern string) error { + if strings.Count(pattern, characterIDToken) != 1 { + return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken) + } + remainder := strings.ReplaceAll(pattern, characterIDToken, "") + if strings.ContainsAny(remainder, "{}") { + return fmt.Errorf("%s contains unsupported brace syntax", field) + } + return nil +} + +func resolveFamilyVars(prefix string, family ScriptoriumArtifactFamilyConfig, character PartyCharacter) (map[string]any, error) { + vars := make(map[string]any, len(family.Vars)+len(family.MemberVars)) + for name, value := range family.Vars { + vars[name] = value + } + for name, selector := range family.MemberVars { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("%s.member_vars keys must be non-empty", prefix) + } + if _, exists := vars[name]; exists { + return nil, fmt.Errorf("%s.member_vars.%s conflicts with static vars", prefix, name) + } + resolve, ok := memberVariableSelectors[strings.TrimSpace(selector)] + if !ok { + return nil, fmt.Errorf("%s.member_vars.%s selector %q is unsupported", prefix, name, selector) + } + vars[name] = resolve(character) + } + return vars, nil +} + +func sortedFamilyKeys(families map[string]ScriptoriumArtifactFamilyConfig) []string { + keys := make([]string, 0, len(families)) + for key := range families { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func artifactFamilySource(cfg *PipelineConfig, family string) string { + if cfg == nil || cfg.resolution == nil { + return "" + } + prefix := "scriptorium.artifact_families." + family + "." + for _, ownership := range cfg.resolution.ownership { + if strings.HasPrefix(ownership.path, prefix) && len(ownership.sources) > 0 { + return ownership.sources[0] + } + } + return pipelineDefaultOwnershipSource +} + +func cloneArtifactDefinitions(in map[string]ScriptoriumArtifactConfig) map[string]ScriptoriumArtifactConfig { + if len(in) == 0 { + return map[string]ScriptoriumArtifactConfig{} + } + out := make(map[string]ScriptoriumArtifactConfig, len(in)) + for key, artifact := range in { + artifact.DependsOn = append([]string(nil), artifact.DependsOn...) + artifact.Inputs = cloneArtifactInputs(artifact.Inputs) + artifact.Vars = cloneArtifactVars(artifact.Vars) + out[key] = artifact + } + return out +} + +func cloneArtifactInputs(in map[string]ScriptoriumInputConfig) map[string]ScriptoriumInputConfig { + if len(in) == 0 { + return nil + } + out := make(map[string]ScriptoriumInputConfig, len(in)) + for key, input := range in { + out[key] = input + } + return out +} + +func cloneArtifactVars(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func cloneArtifactFamilyDefinitions(in map[string]ScriptoriumArtifactFamilyConfig) map[string]ScriptoriumArtifactFamilyConfig { + if len(in) == 0 { + return nil + } + out := make(map[string]ScriptoriumArtifactFamilyConfig, len(in)) + for key, family := range in { + family.DependsOn = append([]string(nil), family.DependsOn...) + family.MemberDependencies = append([]string(nil), family.MemberDependencies...) + family.Inputs = cloneArtifactInputs(family.Inputs) + family.Vars = cloneArtifactVars(family.Vars) + if len(family.MemberVars) > 0 { + memberVars := make(map[string]string, len(family.MemberVars)) + for name, selector := range family.MemberVars { + memberVars[name] = selector + } + family.MemberVars = memberVars + } + family.Publish = cloneArtifactFamilyPublish(family.Publish) + out[key] = family + } + return out +} + +func cloneArtifactFamilyPublish(in *ScriptoriumArtifactFamilyPublishConfig) *ScriptoriumArtifactFamilyPublishConfig { + if in == nil { + return nil + } + copy := *in + return © +} + +func cloneArtifactFamilyCatalog(in ArtifactFamilyCatalog) ArtifactFamilyCatalog { + out := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(in.Families)), Members: make(map[string]ArtifactFamilyMemberOrigin, len(in.Members))} + for key, family := range in.Families { + family.Members = append([]string(nil), family.Members...) + family.MemberDependencies = append([]string(nil), family.MemberDependencies...) + family.Publish = cloneArtifactFamilyPublish(family.Publish) + out.Families[key] = family + } + for key, member := range in.Members { + out.Members[key] = member + } + return out +} diff --git a/internal/config/artifact_families_test.go b/internal/config/artifact_families_test.go new file mode 100644 index 0000000..0165fd1 --- /dev/null +++ b/internal/config/artifact_families_test.go @@ -0,0 +1,227 @@ +package config + +import ( + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +func TestArtifactFamiliesExpandCanonicalCharactersDeterministically(t *testing.T) { + dir := t.TempDir() + writePartyResolutionFile(t, dir+"/party.yml", `schema_version: narratio.party.v1 +characters: + zeta: + player: {name: Zara} + character: + name: Zeta + alias: [Z] + classes: [{name: wizard, level: 8}, {name: fighter}] + alpha: + player: {name: Ada} + character: + name: Alpha + alias: [A, The First] + classes: [{name: ranger}] +`) + pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(` + character_items: + enabled: false + for_each: party.characters + prompt_id: dnd.character_items + profile_id: production + output_path_pattern: artifacts/characters/{character_id}/items.md + inputs: + transcript: {source: narratio.transcript.final_trimmed, required: true} + member_vars: + id: character_id + player: player.name + name: character.name + classes: character.class_summary + aliases: character.alias_summary + vars: + static_flag: true + character_meta: + enabled: true + for_each: party.characters + prompt_id: dnd.character_meta + profile_id: production + output_path_pattern: artifacts/characters/{character_id}/meta.md + inputs: + transcript: {source: narratio.transcript.final_trimmed, required: true} +`)) + + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if len(cfg.Pipeline.Scriptorium.ArtifactFamilies) != 0 { + t.Fatalf("adapter-facing artifact families = %#v, want none", cfg.Pipeline.Scriptorium.ArtifactFamilies) + } + keys := sortedArtifactKeys(cfg.Pipeline.Scriptorium.Artifacts) + wantKeys := []string{"character_items_alpha", "character_items_zeta", "character_meta_alpha", "character_meta_zeta"} + if !reflect.DeepEqual(keys, wantKeys) { + t.Fatalf("artifact keys = %#v, want %#v", keys, wantKeys) + } + alpha := cfg.Pipeline.Scriptorium.Artifacts["character_items_alpha"] + if alpha.Enabled || alpha.OutputPath != "artifacts/characters/alpha/items.md" { + t.Fatalf("alpha artifact = %#v", alpha) + } + if !reflect.DeepEqual(alpha.Vars, map[string]any{ + "static_flag": true, "id": "alpha", "player": "Ada", "name": "Alpha", "classes": "ranger", "aliases": "A, The First", + }) { + t.Fatalf("alpha vars = %#v", alpha.Vars) + } + zeta := cfg.Pipeline.Scriptorium.Artifacts["character_items_zeta"] + if got := zeta.Vars["classes"]; got != "wizard 8 / fighter" { + t.Fatalf("zeta class summary = %#v", got) + } + catalog := ArtifactFamilies(cfg.Pipeline) + if got := catalog.Families["character_meta"].Members; !reflect.DeepEqual(got, []string{"character_meta_alpha", "character_meta_zeta"}) { + t.Fatalf("meta members = %#v", got) + } + if got := catalog.Members["character_items_zeta"]; got.Family != "character_items" || got.CharacterID != "zeta" || got.Source == "" { + t.Fatalf("zeta origin = %#v", got) + } + + firstDigest := EffectivePipelineDigest(cfg.Pipeline) + writePartyResolutionFile(t, dir+"/party.yml", `schema_version: narratio.party.v1 +characters: + alpha: + player: {name: Ada} + character: + name: Alpha + alias: [A, The First] + classes: [{name: ranger}] + zeta: + player: {name: Zara} + character: + name: Zeta + alias: [Z] + classes: [{name: wizard, level: 8}, {name: fighter}] +`) + again, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("reordered LoadWithSessionOptions() error = %v", err) + } + if !reflect.DeepEqual(cfg.Pipeline.Scriptorium.Artifacts, again.Pipeline.Scriptorium.Artifacts) || !reflect.DeepEqual(ArtifactFamilies(cfg.Pipeline), ArtifactFamilies(again.Pipeline)) || firstDigest != EffectivePipelineDigest(again.Pipeline) { + t.Fatalf("reordered source maps changed expansion") + } +} + +func TestArtifactFamiliesRejectInvalidExpansionDeclarations(t *testing.T) { + tests := []struct { + name string + party string + families string + wantError string + }{ + {name: "legacy party", party: "legacy: party\n", families: validFamilyYAML, wantError: "requires a canonical campaign party"}, + {name: "invalid iteration", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "party.characters", "party.players", 1), wantError: ".for_each must be"}, + {name: "invalid token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "{character_id}", "{character_name}", 1), wantError: "must contain exactly one"}, + {name: "repeated token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "meta.md", "{character_id}.md", 1), wantError: "must contain exactly one"}, + {name: "member selector", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "character.name", "character.unknown", 1), wantError: "selector"}, + {name: "static variable collision", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "member_vars:\n character_name", "vars:\n character_name: static\n member_vars:\n character_name", 1), wantError: "conflicts with static vars"}, + {name: "unsafe output", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "artifacts/characters/{character_id}/meta.md", "../{character_id}.md", 1), wantError: "must not contain path traversal"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + writePartyResolutionFile(t, dir+"/party.yml", test.party) + pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(test.families)) + _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.wantError) + } + }) + } +} + +func TestArtifactFamilyStrictDecodeRejectsUnknownFields(t *testing.T) { + dir := t.TempDir() + pipelinePath, _, _ := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(validFamilyYAML+" unsupported: true\n")) + _, err := LoadPipeline(pipelinePath) + if err == nil || !strings.Contains(err.Error(), "field unsupported not found") { + t.Fatalf("LoadPipeline() error = %v", err) + } +} + +func TestArtifactFamiliesRejectGeneratedKeyAndOutputCollisions(t *testing.T) { + document, err := ParseParty([]byte(canonicalPartyFixture)) + if err != nil { + t.Fatal(err) + } + base := ScriptoriumArtifactFamilyConfig{ + ForEach: partyCharactersFamilySource, PromptID: "dnd.character_meta", + OutputPathPattern: "artifacts/characters/{character_id}/meta.md", + } + for _, test := range []struct { + name string + artifacts map[string]ScriptoriumArtifactConfig + wantError string + }{ + { + name: "generated key", artifacts: map[string]ScriptoriumArtifactConfig{ + "character_meta_arannis": {OutputPath: "artifacts/other.md"}, + }, wantError: "generates artifact key", + }, + { + name: "generated output", artifacts: map[string]ScriptoriumArtifactConfig{ + "existing": {OutputPath: "artifacts/characters/arannis/meta.md"}, + }, wantError: "generates output path", + }, + } { + t.Run(test.name, func(t *testing.T) { + pipeline := &PipelineConfig{ + Scriptorium: &ScriptoriumConfig{Artifacts: test.artifacts}, + resolution: &pipelineResolutionMetadata{artifactFamilies: map[string]ScriptoriumArtifactFamilyConfig{"character_meta": base}}, + } + err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}) + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("expandPipelineArtifactFamilies() error = %v, want %q", err, test.wantError) + } + }) + } +} + +const validFamilyYAML = ` + character_meta: + enabled: true + for_each: party.characters + prompt_id: dnd.character_meta + profile_id: production + output_path_pattern: artifacts/characters/{character_id}/meta.md + member_vars: + character_name: character.name +` + +func artifactFamilyPipelineYAML(families string) string { + lines := strings.Split(strings.TrimPrefix(families, "\n"), "\n") + for index, line := range lines { + if line != "" { + lines[index] = " " + line + } + } + return "workspace:\n root: /tmp/narratio-work\nwhisperx:\n transcribe_url: https://example.test/transcribe\nnotification:\n mode: noop\nscriptorium:\n binary: scriptorium\n artifact_families:\n" + strings.Join(lines, "\n") +} + +func writeArtifactFamilyConfig(t *testing.T, dir, pipeline string) (string, string, string) { + t.Helper() + pipelinePath := writePartyResolutionFile(t, filepath.Join(dir, "pipeline.yml"), pipeline) + campaignPath := writePartyResolutionFile(t, filepath.Join(dir, "campaign.yml"), "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n") + sessionPath := writePartyResolutionFile(t, filepath.Join(dir, "session.yml"), "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n") + return pipelinePath, campaignPath, sessionPath +} + +func sortedArtifactKeys(artifacts map[string]ScriptoriumArtifactConfig) []string { + keys := make([]string, 0, len(artifacts)) + for key := range artifacts { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/config/config.go b/internal/config/config.go index 18e09d3..32e614c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,7 +34,16 @@ type PipelineConfig struct { Notarius *NotariusConfig `yaml:"notarius"` Notification NotificationConfig `yaml:"notification"` - resolution *pipelineResolutionMetadata `yaml:"-"` + resolution *pipelineResolutionMetadata `yaml:"-"` + familyCatalog ArtifactFamilyCatalog `yaml:"-"` +} + +// ArtifactFamilies returns a copy of runtime-only expansion provenance. +func ArtifactFamilies(cfg *PipelineConfig) ArtifactFamilyCatalog { + if cfg == nil { + return ArtifactFamilyCatalog{} + } + return cloneArtifactFamilyCatalog(cfg.familyCatalog) } // CampaignsConfig configures the local campaign registry. @@ -236,11 +245,12 @@ type RenderConfig struct { // ScriptoriumConfig configures Scriptorium-backed artifact generation. type ScriptoriumConfig struct { - Binary string `yaml:"binary"` - ConfigPath string `yaml:"config_path"` - Timeout string `yaml:"timeout"` - RenderDebug bool `yaml:"render_debug"` - Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"` + Binary string `yaml:"binary"` + ConfigPath string `yaml:"config_path"` + Timeout string `yaml:"timeout"` + RenderDebug bool `yaml:"render_debug"` + Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"` + ArtifactFamilies map[string]ScriptoriumArtifactFamilyConfig `yaml:"artifact_families"` } // ScriptoriumArtifactConfig configures one named output artifact workflow. @@ -256,6 +266,55 @@ type ScriptoriumArtifactConfig struct { Vars map[string]any `yaml:"vars"` } +// ScriptoriumArtifactFamilyConfig declares the shared configuration expanded +// into one ordinary artifact for each canonical party character. +type ScriptoriumArtifactFamilyConfig struct { + Enabled bool `yaml:"enabled"` + ForEach string `yaml:"for_each"` + DependsOn []string `yaml:"depends_on"` + RenderDebug *bool `yaml:"render_debug"` + PromptID string `yaml:"prompt_id"` + ProfileID string `yaml:"profile_id"` + OutputPathPattern string `yaml:"output_path_pattern"` + Timeout string `yaml:"timeout"` + Inputs map[string]ScriptoriumInputConfig `yaml:"inputs"` + Vars map[string]any `yaml:"vars"` + MemberVars map[string]string `yaml:"member_vars"` + MemberDependencies []string `yaml:"member_dependencies"` + Publish *ScriptoriumArtifactFamilyPublishConfig `yaml:"publish"` +} + +// ScriptoriumArtifactFamilyPublishConfig retains the typed declaration for +// family publishing. It is resolved by the publish owner. +type ScriptoriumArtifactFamilyPublishConfig struct { + Enabled bool `yaml:"enabled"` + Required bool `yaml:"required"` + DestPattern string `yaml:"dest_pattern"` +} + +// ArtifactFamilyCatalog records the runtime-only origin of expanded artifacts. +// It is intentionally kept outside the Scriptorium adapter configuration. +type ArtifactFamilyCatalog struct { + Families map[string]ArtifactFamilyOrigin + Members map[string]ArtifactFamilyMemberOrigin +} + +// ArtifactFamilyOrigin records one declared family and its source ownership. +type ArtifactFamilyOrigin struct { + Members []string + MemberDependencies []string + Publish *ScriptoriumArtifactFamilyPublishConfig + Source string +} + +// ArtifactFamilyMemberOrigin identifies the canonical party member that +// produced one ordinary concrete artifact. +type ArtifactFamilyMemberOrigin struct { + Family string + CharacterID string + Source string +} + // ScriptoriumInputConfig configures one named prompt input source. type ScriptoriumInputConfig struct { Source string `yaml:"source"` diff --git a/internal/config/load.go b/internal/config/load.go index 07e47b2..708e8d1 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -38,6 +38,7 @@ func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineCo if err := finalizePipelineResolution(cfg); err != nil { return nil, fmt.Errorf("load pipeline config: %w", err) } + retainArtifactFamilyDeclarations(cfg) return cfg, nil } @@ -204,6 +205,9 @@ func LoadPipelineCampaign(pipelinePath string, pipeline *PipelineConfig, campaig return LoadedPipelineCampaign{}, err } } + if err := expandPipelineArtifactFamilies(pipeline, party); err != nil { + return LoadedPipelineCampaign{}, err + } return LoadedPipelineCampaign{ PipelinePath: pipelinePath, Pipeline: pipeline, diff --git a/internal/config/pipeline_composition.go b/internal/config/pipeline_composition.go index b209fff..c4c5d38 100644 --- a/internal/config/pipeline_composition.go +++ b/internal/config/pipeline_composition.go @@ -13,12 +13,14 @@ import ( ) type pipelineResolutionMetadata struct { - rootPath string - imports []string - sources []string - selectedProfile *pipelineProfileSelection - effectiveDigest string - ownership []pipelineFieldOwnership + rootPath string + imports []string + sources []string + selectedProfile *pipelineProfileSelection + effectiveDigest string + ownership []pipelineFieldOwnership + artifactFamilies map[string]ScriptoriumArtifactFamilyConfig + artifactFamiliesExpanded bool } type pipelineProfileSelection struct { diff --git a/internal/config/validate.go b/internal/config/validate.go index 7e00274..ac2589f 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -671,6 +671,7 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error configuredArtifacts := make(map[string]struct{}, len(cfg.Artifacts)) referencedArtifacts := make(map[string]struct{}) + outputOwners := make(map[string]string, len(cfg.Artifacts)) for artifactName := range cfg.Artifacts { if !artifactpolicy.IsConfiguredKey(artifactName) { return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$") @@ -693,6 +694,10 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error if err := validatePathWithinRoot(pathField, artifactCfg.OutputPath, DefaultScriptoriumArtifactOutputRoot); err != nil { return err } + if previous, exists := outputOwners[artifactCfg.OutputPath]; exists { + return fmt.Errorf("%s.output_path %q duplicates artifact %q", pathField, artifactCfg.OutputPath, previous) + } + outputOwners[artifactCfg.OutputPath] = artifactName } if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil { return err