Refactor the analyze stage to actually produce the configured artifacts
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user