From e38ed8ba97f4fb4118f16f40d8687ef05d37d448 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 19 May 2026 18:58:28 -0500 Subject: [PATCH] Refactor the analyze stage to actually produce the configured artifacts --- internal/stage/analyze.go | 382 ++++++++++++++++++++++++++------- internal/stage/analyze_test.go | 116 ++++++++++ 2 files changed, 423 insertions(+), 75 deletions(-) diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 233ee42..9fbdacd 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -28,12 +28,23 @@ func (analyzeStage) Declares() IODecl { {Kind: "transcript_normalized", Category: "transcripts", RelativePath: "transcripts/normalized.json"}, {Kind: "transcript_trimmed", Category: "transcripts", RelativePath: "transcripts/trimmed.json"}, }, - Outputs: []artifacts.Ref{ - {Kind: "session_recap", Category: "artifacts", RelativePath: "artifacts/session_recap.md"}, - }, + Outputs: nil, } } +type analyzeArtifactExecutionPlan struct { + Name string + Cfg config.ScriptoriumArtifactConfig +} + +type analyzeArtifactExecutionResult struct { + Output artifacts.Ref + Logs []string + GeneratedConfigs []string + Metadata map[string]any + ReusedArtifacts []map[string]any +} + func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { if env == nil || env.Config == nil { return nil, fmt.Errorf("analyze: stage environment config is required") @@ -65,27 +76,11 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("analyze: resolve run-stage layout: %w", err) } if env.Config.Pipeline.Scriptorium == nil { - return &StageResult{ - Metadata: map[string]any{ - "stage": "analyze", - "skipped": true, - "reason": "pipeline.scriptorium is not configured", - }, - }, nil - } - - artifactName, artifactCfg, skipReason, err := selectAnalyzeArtifact(env.Config.Pipeline.Scriptorium) - if err != nil { - return nil, fmt.Errorf("analyze: %w", err) - } - if skipReason != "" { - return &StageResult{ - Metadata: map[string]any{ - "stage": "analyze", - "skipped": true, - "reason": skipReason, - }, - }, nil + return &StageResult{Metadata: map[string]any{ + "stage": "analyze", + "skipped": true, + "reason": "pipeline.scriptorium is not configured", + }}, nil } runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog(paths, env.Config.Pipeline.Scriptorium, env.SelectedAnalyzeArtifacts) @@ -93,41 +88,274 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err) } + plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, runtimeCatalog) + if err != nil { + return nil, fmt.Errorf("analyze: %w", err) + } + if skipReason != "" { + return &StageResult{Metadata: map[string]any{ + "stage": "analyze", + "skipped": true, + "reason": skipReason, + }}, nil + } + transcriptRefs := discoverAnalyzeTranscriptRefs(m, paths) + sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath)) + + outputs := make([]artifacts.Ref, 0, len(plans)) + logs := []string{} + generatedConfigs := []string{} + artifactMetadata := make([]map[string]any, 0, len(plans)) + reusedArtifacts := []map[string]any{} + reusedSeen := map[string]struct{}{} + + for _, plan := range plans { + artifactResult, err := executeAnalyzeArtifact( + ctx, + env, + m, + paths, + runLayout, + sessionID, + sessionDir, + plan, + transcriptRefs, + runtimeCatalog, + ) + if err != nil { + return nil, err + } + + outputs = append(outputs, artifactResult.Output) + logs = append(logs, artifactResult.Logs...) + generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...) + artifactMetadata = append(artifactMetadata, artifactResult.Metadata) + for _, reused := range artifactResult.ReusedArtifacts { + sourceID, _ := reused["source_id"].(string) + path, _ := reused["path"].(string) + key := sourceID + "|" + path + if _, exists := reusedSeen[key]; exists { + continue + } + reusedSeen[key] = struct{}{} + reusedArtifacts = append(reusedArtifacts, reused) + } + + sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name) + if !ok { + return nil, fmt.Errorf("analyze: source id not found for artifact %q", plan.Name) + } + if err := runtimeCatalog.MarkAvailableGenerated(sourceID, artifactResult.Output.AbsolutePath); err != nil { + return nil, fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err) + } + } + + metadata := map[string]any{ + "stage": "analyze", + "selected_artifacts": extractPlanNames(plans), + "generated_artifacts": artifactMetadata, + "reused_artifacts": reusedArtifacts, + "artifact_count": len(artifactMetadata), + "reused_artifact_count": len(reusedArtifacts), + } + if len(artifactMetadata) == 1 { + for k, v := range artifactMetadata[0] { + metadata[k] = v + } + } + + return &StageResult{ + Outputs: outputs, + Logs: dedupeAndSortPaths(logs), + GeneratedConfigs: dedupeAndSortPaths(generatedConfigs), + Metadata: metadata, + }, nil +} + +func buildAnalyzeExecutionPlans( + scriptoriumCfg *config.ScriptoriumConfig, + catalog *artifacts.ArtifactCatalog, +) ([]analyzeArtifactExecutionPlan, string, error) { + if scriptoriumCfg == nil { + return nil, "pipeline.scriptorium is not configured", nil + } + if len(scriptoriumCfg.Artifacts) == 0 { + return nil, "no scriptorium artifacts configured", nil + } + + entries := catalog.ListConfigured() + selected := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.Executable { + selected = append(selected, entry.ConfiguredKey) + } + } + if len(selected) == 0 { + return nil, "no selected scriptorium artifacts to execute", nil + } + + ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, selected, catalog) + if err != nil { + return nil, "", err + } + + plans := make([]analyzeArtifactExecutionPlan, 0, len(ordered)) + for _, name := range ordered { + artifactCfg, ok := scriptoriumCfg.Artifacts[name] + if !ok { + return nil, "", fmt.Errorf("selected artifact %q is not configured", name) + } + plans = append(plans, analyzeArtifactExecutionPlan{Name: name, Cfg: artifactCfg}) + } + return plans, "", nil +} + +func orderSelectedScriptoriumArtifacts( + artifactsCfg map[string]config.ScriptoriumArtifactConfig, + selected []string, + catalog *artifacts.ArtifactCatalog, +) ([]string, error) { + selectedSet := map[string]struct{}{} + for _, key := range selected { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return nil, fmt.Errorf("selected artifact key must be non-empty") + } + selectedSet[trimmed] = struct{}{} + } + + for selectedKey := range selectedSet { + cfg, ok := artifactsCfg[selectedKey] + if !ok { + return nil, fmt.Errorf("selected artifact %q is not configured", selectedKey) + } + for _, dep := range cfg.DependsOn { + trimmedDep := strings.TrimSpace(dep) + if trimmedDep == "" { + continue + } + if _, ok := selectedSet[trimmedDep]; ok { + continue + } + sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep) + if !ok { + return nil, fmt.Errorf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep) + } + entry, ok := catalog.Lookup(sourceID) + if !ok || !entry.Available { + return nil, fmt.Errorf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID) + } + } + } + + indegree := map[string]int{} + edges := map[string][]string{} + for key := range selectedSet { + indegree[key] = 0 + } + for key := range selectedSet { + cfg := artifactsCfg[key] + for _, dep := range cfg.DependsOn { + trimmedDep := strings.TrimSpace(dep) + if _, ok := selectedSet[trimmedDep]; !ok { + continue + } + edges[trimmedDep] = append(edges[trimmedDep], key) + indegree[key]++ + } + } + + for key := range edges { + sort.Strings(edges[key]) + } + + ready := make([]string, 0, len(indegree)) + for key, degree := range indegree { + if degree == 0 { + ready = append(ready, key) + } + } + sort.Strings(ready) + + order := make([]string, 0, len(selectedSet)) + for len(ready) > 0 { + node := ready[0] + ready = ready[1:] + order = append(order, node) + for _, dep := range edges[node] { + indegree[dep]-- + if indegree[dep] == 0 { + ready = append(ready, dep) + sort.Strings(ready) + } + } + } + + if len(order) != len(selectedSet) { + return nil, fmt.Errorf("selected scriptorium artifacts contain a dependency cycle") + } + return order, nil +} + +func executeAnalyzeArtifact( + ctx context.Context, + env *Env, + m *manifest.Manifest, + paths artifacts.SessionPaths, + runLayout runStageLayout, + sessionID string, + sessionDir string, + plan analyzeArtifactExecutionPlan, + transcriptRefs analyzeTranscriptInputs, + runtimeCatalog *artifacts.ArtifactCatalog, +) (*analyzeArtifactExecutionResult, error) { + artifactName := plan.Name + artifactCfg := plan.Cfg inputPaths := map[string]string{} omittedOptionalInputs := []string{} - sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath)) + reusedArtifacts := []map[string]any{} + inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs) for _, inputName := range inputNames { inputCfg := artifactCfg.Inputs[inputName] - resolvedPath, resolved, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog) + resolvedPath, resolved, resolvedArtifact, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog) if resolveErr != nil { - return nil, fmt.Errorf("analyze: resolve input %q: %w", inputName, resolveErr) + return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolveErr) } if !resolved { if inputCfg.Required { - return nil, fmt.Errorf("analyze: required input %q could not be resolved", inputName) + return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName) } omittedOptionalInputs = append(omittedOptionalInputs, inputName) continue } inputPaths[inputName] = resolvedPath + if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk { + reusedArtifacts = append(reusedArtifacts, map[string]any{ + "name": configuredArtifactNameFromSourceID(resolvedArtifact.ID), + "source_id": resolvedArtifact.ID, + "path": resolvedArtifact.Path, + "provenance": resolvedArtifact.Provenance, + }) + } } vars, err := buildScriptoriumVars(artifactCfg.Vars, env.Config.Session) if err != nil { - return nil, fmt.Errorf("analyze: resolve vars: %w", err) + return nil, fmt.Errorf("analyze: resolve vars for artifact %q: %w", artifactName, err) } canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath) if err != nil { - return nil, fmt.Errorf("analyze: resolve output path: %w", err) + return nil, fmt.Errorf("analyze: resolve output path for artifact %q: %w", artifactName, err) } outputPath, err := runLocalPathForCanonical(runLayout, paths, canonicalOutputPath) if err != nil { - return nil, fmt.Errorf("analyze: resolve run-local output path: %w", err) + return nil, fmt.Errorf("analyze: resolve run-local output path for artifact %q: %w", artifactName, err) } + stdoutLogPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".stdout.log") stderrLogPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".stderr.log") generatedConfigPath := filepath.Join(paths.ConfigDir, "scriptorium."+artifactName+".generated.yml") @@ -139,7 +367,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S timeout, err := resolveScriptoriumTimeout(env.Config.Pipeline.Scriptorium.Timeout, artifactCfg.Timeout) if err != nil { - return nil, fmt.Errorf("analyze: resolve timeout: %w", err) + return nil, fmt.Errorf("analyze: resolve timeout for artifact %q: %w", artifactName, err) } logPaths := []string{} @@ -147,6 +375,8 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S meta := map[string]any{ "stage": "analyze", "artifact_name": artifactName, + "source_id": artifacts.ConfiguredArtifactSourceID(artifactName), + "output_kind": "scriptorium_artifact", "prompt_id": artifactCfg.PromptID, "profile_id": artifactCfg.ProfileID, "binary": env.Config.Pipeline.Scriptorium.Binary, @@ -194,10 +424,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S } renderRes, renderErr := env.Scriptorium.RenderArtifact(ctx, renderReq) if renderErr != nil { - return nil, fmt.Errorf("analyze: scriptorium render failed: %w", renderErr) + return nil, fmt.Errorf("analyze: scriptorium render failed for artifact %q: %w", artifactName, renderErr) } if renderRes.ValidationFailed { - return nil, fmt.Errorf("analyze: scriptorium render returned validation_failed=true") + return nil, fmt.Errorf("analyze: scriptorium render returned validation_failed=true for artifact %q", artifactName) } finalRenderOutputPath := coalesceString(renderRes.OutputPath, renderReq.OutputPath) if err := requireNonEmptyFile(finalRenderOutputPath, artifactName+" render output"); err != nil { @@ -245,7 +475,8 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S if runErr != nil { if res.ValidationFailed { return nil, fmt.Errorf( - "analyze: scriptorium validation failed (prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w", + "analyze: scriptorium validation failed (artifact=%q, prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w", + artifactName, req.PromptID, coalesceString(res.OutputPath, req.OutputPath), res.ExitCode, @@ -254,10 +485,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S runErr, ) } - return nil, fmt.Errorf("analyze: scriptorium run failed: %w", runErr) + return nil, fmt.Errorf("analyze: scriptorium run failed for artifact %q: %w", artifactName, runErr) } if res.ValidationFailed { - return nil, fmt.Errorf("analyze: scriptorium run returned validation_failed=true") + return nil, fmt.Errorf("analyze: scriptorium run returned validation_failed=true for artifact %q", artifactName) } finalOutputPath := coalesceString(res.OutputPath, req.OutputPath) @@ -270,7 +501,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S SessionID: sessionID, }) if err != nil { - return nil, fmt.Errorf("analyze: promote artifact output: %w", err) + return nil, fmt.Errorf("analyze: promote artifact output for %q: %w", artifactName, err) } logPaths = append(logPaths, stdoutLogPath, stderrLogPath) @@ -290,39 +521,38 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S meta["adapter_generated_config"] = res.GeneratedConfigPath meta["adapter_stdout_log_path"] = res.StdoutLogPath meta["adapter_stderr_log_path"] = res.StderrLogPath + meta["provenance"] = artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun if res.Metadata != nil { meta["adapter_metadata"] = res.Metadata } - return &StageResult{ - Outputs: []artifacts.Ref{promotedArtifact}, + return &analyzeArtifactExecutionResult{ + Output: promotedArtifact, Logs: logPaths, GeneratedConfigs: generatedConfigs, Metadata: meta, + ReusedArtifacts: reusedArtifacts, }, nil } -func selectAnalyzeArtifact(cfg *config.ScriptoriumConfig) (string, config.ScriptoriumArtifactConfig, string, error) { - if cfg == nil { - return "", config.ScriptoriumArtifactConfig{}, "pipeline.scriptorium is not configured", nil +func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string { + if len(plans) == 0 { + return nil } + out := make([]string, 0, len(plans)) + for _, plan := range plans { + out = append(out, plan.Name) + } + return out +} - enabled := []string{} - for name, artifact := range cfg.Artifacts { - if artifact.Enabled { - enabled = append(enabled, name) - } +func configuredArtifactNameFromSourceID(sourceID string) string { + trimmed := strings.TrimSpace(sourceID) + const prefix = "narratio.artifact." + if !strings.HasPrefix(trimmed, prefix) { + return "" } - sort.Strings(enabled) - if len(enabled) == 0 { - return "", config.ScriptoriumArtifactConfig{}, "no enabled scriptorium artifacts configured", nil - } - - sessionRecapCfg, ok := cfg.Artifacts["session_recap"] - if !ok || !sessionRecapCfg.Enabled { - return "", config.ScriptoriumArtifactConfig{}, "", fmt.Errorf("only artifacts.session_recap is supported in this analyze implementation; enabled=%s", strings.Join(enabled, ",")) - } - return "session_recap", sessionRecapCfg, "", nil + return strings.TrimPrefix(trimmed, prefix) } func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) { @@ -397,45 +627,47 @@ func resolveScriptoriumInput( paths artifacts.SessionPaths, sessionDir string, runtimeCatalog *artifacts.ArtifactCatalog, -) (string, bool, error) { - switch strings.TrimSpace(inputCfg.Source) { +) (string, bool, *artifacts.ResolvedSessionArtifact, error) { + source := strings.TrimSpace(inputCfg.Source) + switch source { case "previous_session_artifact": if strings.TrimSpace(inputCfg.Path) == "" { - return "", false, nil + return "", false, nil, nil } resolved := resolveInputPathForRead(paths, sessionDir, inputCfg.Path) if err := requireFile(resolved, "scriptorium input "+inputName); err != nil { - return "", false, nil + return "", false, nil, nil } - return resolved, true, nil + return resolved, true, nil, nil default: - resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, inputCfg.Source, runtimeCatalog) + resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) if err == nil { - return resolved.Path, true, nil + copy := resolved + return resolved.Path, true, ©, nil } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { - if artifacts.IsConfiguredArtifactSource(inputCfg.Source) { + if artifacts.IsConfiguredArtifactSource(source) { if inputCfg.Required { - return "", false, fmt.Errorf("configured artifact source %q is unavailable", inputCfg.Source) + return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source) } - return "", false, nil + return "", false, nil, nil } - normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(inputCfg.Source) + normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(source) if normalizeErr != nil { - return "", false, normalizeErr + return "", false, nil, normalizeErr } switch normalized { case artifacts.ArtifactTranscriptPolished: - return "", false, nil + return "", false, nil, nil case artifacts.ArtifactTranscriptFull: - return "", false, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first") + return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first") case artifacts.ArtifactTranscriptTrimmed: - return "", false, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first") + return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first") default: - return "", false, nil + return "", false, nil, nil } } - return "", false, err + return "", false, nil, err } } diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index 0fecbed..36f4547 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -444,6 +444,122 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes } } +func TestAnalyzeRunsMultipleIndependentArtifactsInDeterministicOrder(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: true, + PromptID: "dnd.player_handout", + ProfileID: "local-quality", + OutputPath: "artifacts/player_handout.md", + Inputs: map[string]config.ScriptoriumInputConfig{ + "transcript": { + Source: "narratio.transcript.trimmed", + Required: true, + }, + }, + } + + result, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.RunRequests) != 2 { + t.Fatalf("run requests = %d, want 2", len(fake.RunRequests)) + } + if fake.RunRequests[0].PromptID != "dnd.player_handout" { + t.Fatalf("first prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID) + } + if fake.RunRequests[1].PromptID != "dnd.session_recap" { + t.Fatalf("second prompt id = %q, want dnd.session_recap", fake.RunRequests[1].PromptID) + } + + selected, ok := result.Metadata["selected_artifacts"].([]string) + if !ok { + t.Fatalf("selected_artifacts = %#v, want []string", result.Metadata["selected_artifacts"]) + } + if len(selected) != 2 || selected[0] != "player_handout" || selected[1] != "session_recap" { + t.Fatalf("selected_artifacts = %#v, want [player_handout session_recap]", selected) + } +} + +func TestAnalyzeRunsDependenciesBeforeDependents(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: true, + DependsOn: []string{"session_recap"}, + PromptID: "dnd.player_handout", + ProfileID: "local-quality", + OutputPath: "artifacts/player_handout.md", + Inputs: map[string]config.ScriptoriumInputConfig{ + "recap": { + Source: "narratio.artifact.session_recap", + Required: true, + }, + "transcript": { + Source: "narratio.transcript.trimmed", + Required: true, + }, + }, + } + + _, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.RunRequests) != 2 { + t.Fatalf("run requests = %d, want 2", len(fake.RunRequests)) + } + if fake.RunRequests[0].PromptID != "dnd.session_recap" { + t.Fatalf("first prompt id = %q, want dnd.session_recap", fake.RunRequests[0].PromptID) + } + if fake.RunRequests[1].PromptID != "dnd.player_handout" { + t.Fatalf("second prompt id = %q, want dnd.player_handout", fake.RunRequests[1].PromptID) + } + if got := fake.RunRequests[1].InputPaths["recap"]; got != filepath.Join(paths.ArtifactsDir, "session_recap.md") { + t.Fatalf("dependent recap path = %q, want %q", got, filepath.Join(paths.ArtifactsDir, "session_recap.md")) + } +} + +func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: true, + PromptID: "dnd.player_handout", + ProfileID: "local-quality", + OutputPath: "artifacts/player_handout.md", + Inputs: map[string]config.ScriptoriumInputConfig{ + "transcript": { + Source: "narratio.transcript.trimmed", + Required: true, + }, + }, + } + env.SelectedAnalyzeArtifacts = []string{"player_handout"} + + result, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.RunRequests) != 1 { + t.Fatalf("run requests = %d, want 1", len(fake.RunRequests)) + } + if fake.RunRequests[0].PromptID != "dnd.player_handout" { + t.Fatalf("prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID) + } + if len(result.Outputs) != 1 || result.Outputs[0].Kind != "player_handout" { + t.Fatalf("outputs = %#v, want only player_handout", result.Outputs) + } +} + func TestAnalyzeFailsWhenRequiredConfiguredArtifactMissing(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID)