Expand canonical party artifact families
This commit is contained in:
@@ -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.<name>.inputs.<input_nam
|
||||
loading. Use the canonical `source` identifier to select the input; Narratio
|
||||
does not provide adapter-specific input passthrough fields.
|
||||
|
||||
### Scriptorium Artifact Families
|
||||
|
||||
`pipeline.scriptorium.artifact_families` declares a shared artifact template
|
||||
for every canonical campaign character. Configuration resolution expands each
|
||||
family into ordinary `pipeline.scriptorium.artifacts` entries before analyze
|
||||
planning or Scriptorium invocation. A legacy party cannot be used for a family.
|
||||
|
||||
For each `pipeline.scriptorium.artifact_families.<name>`:
|
||||
|
||||
| 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 `<family>_<character_id>` 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -755,7 +755,7 @@ and Scriptorium.
|
||||
|
||||
## Stage 13 — Basic Party-Driven Artifact Family Expansion
|
||||
|
||||
**Status: Pending**
|
||||
**Status: Completed**
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
254
internal/config/artifact_families.go
Normal file
254
internal/config/artifact_families.go
Normal file
@@ -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
|
||||
}
|
||||
227
internal/config/artifact_families_test.go
Normal file
227
internal/config/artifact_families_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -35,6 +35,15 @@ type PipelineConfig struct {
|
||||
Notification NotificationConfig `yaml:"notification"`
|
||||
|
||||
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.
|
||||
@@ -241,6 +250,7 @@ type ScriptoriumConfig struct {
|
||||
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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,8 @@ type pipelineResolutionMetadata struct {
|
||||
selectedProfile *pipelineProfileSelection
|
||||
effectiveDigest string
|
||||
ownership []pipelineFieldOwnership
|
||||
artifactFamilies map[string]ScriptoriumArtifactFamilyConfig
|
||||
artifactFamiliesExpanded bool
|
||||
}
|
||||
|
||||
type pipelineProfileSelection struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user