diff --git a/README.md b/README.md index c093afb..50e14fe 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,10 @@ Analyze-stage session recap behavior: - session recap should use gameplay-only transcript input (`source: trimmed_transcript`) - Narratio resolves `trimmed_transcript` from trim manifest output (`transcript_trimmed`) or fallback `transcripts/trimmed.json` +- Narratio resolves `normalized_transcript` from normalize manifest output (`transcript_normalized`) or fallback `transcripts/normalized.json` - missing trimmed transcript fails clearly and advises running trim stage first -- full polished transcript input (`source: processed_transcript`) remains supported for future table/meta-analysis artifacts +- `normalized_transcript` is the preferred full-transcript source for future table/meta-analysis artifacts +- `processed_transcript` remains supported for advanced/debug use cases - optionally includes `previous_recap` when configured and resolvable - omits optional previous recap when unavailable - fails if required inputs are missing diff --git a/architecture.md b/architecture.md index 9b538cc..d968af7 100644 --- a/architecture.md +++ b/architecture.md @@ -227,8 +227,10 @@ Behavior: - if enabled artifacts exist but `session_recap` is not enabled, analyze fails clearly - `session_recap` should use `trimmed_transcript` input (`transcripts/trimmed.json`) for in-universe recap generation - `trimmed_transcript` input is resolved from manifest (`trim` output kind `transcript_trimmed`) when available, otherwise fallback path `work//transcripts/trimmed.json` +- `normalized_transcript` input is resolved from manifest (`normalize` output kind `transcript_normalized`) when available, otherwise fallback path `work//transcripts/normalized.json` - `processed_transcript` input is resolved from manifest (`polish` output kind `transcript_processed`) when available, otherwise fallback path `work//transcripts/processed.json` -- `processed_transcript` remains available for future table/meta-analysis artifacts +- `normalized_transcript` is the preferred full-transcript source for future table/meta-analysis artifacts +- `processed_transcript` remains available for advanced/debug use cases - transcript inputs are validated as JSON with top-level `segments` array - configured inputs are resolved by source - optional `previous_recap` is omitted when unavailable diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index f9b4462..344dd91 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -24,6 +24,7 @@ func (analyzeStage) Declares() IODecl { return IODecl{ Inputs: []artifacts.Ref{ {Kind: "transcript_processed", Category: "transcripts", RelativePath: "transcripts/processed.json"}, + {Kind: "transcript_normalized", Category: "transcripts", RelativePath: "transcripts/normalized.json"}, {Kind: "transcript_trimmed", Category: "transcripts", RelativePath: "transcripts/trimmed.json"}, }, Outputs: []artifacts.Ref{ @@ -86,16 +87,22 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S if err != nil { return nil, fmt.Errorf("analyze: resolve processed transcript: %w", err) } + normalizedTranscriptPath, normalizedSource, err := discoverNormalizedTranscript(m, paths) + if err != nil { + return nil, fmt.Errorf("analyze: resolve normalized transcript: %w", err) + } trimmedTranscriptPath, trimmedSource, err := discoverTrimmedTranscript(m, paths) if err != nil { return nil, fmt.Errorf("analyze: resolve trimmed transcript: %w", err) } transcriptInputs := analyzeTranscriptInputs{ - ProcessedPath: processedTranscriptPath, - ProcessedSource: processedSource, - TrimmedPath: trimmedTranscriptPath, - TrimmedSource: trimmedSource, + ProcessedPath: processedTranscriptPath, + ProcessedSource: processedSource, + NormalizedPath: normalizedTranscriptPath, + NormalizedSource: normalizedSource, + TrimmedPath: trimmedTranscriptPath, + TrimmedSource: trimmedSource, } inputPaths := map[string]string{} @@ -139,22 +146,24 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S logPaths := []string{} generatedConfigs := []string{} meta := map[string]any{ - "stage": "analyze", - "artifact_name": artifactName, - "prompt_id": artifactCfg.PromptID, - "profile_id": artifactCfg.ProfileID, - "binary": env.Config.Pipeline.Scriptorium.Binary, - "config_path": env.Config.Pipeline.Scriptorium.ConfigPath, - "input_paths": inputPaths, - "input_names": sortedMapKeys(inputPaths), - "omitted_optional_inputs": omittedOptionalInputs, - "vars": vars, - "timeout": timeout.String(), - "processed_transcript_path": processedTranscriptPath, - "processed_transcript_source": processedSource, - "trimmed_transcript_path": trimmedTranscriptPath, - "trimmed_transcript_source": trimmedSource, - "render_debug_enabled": resolveRenderDebugEnabled(env.Config.Pipeline.Scriptorium.RenderDebug, artifactCfg.RenderDebug), + "stage": "analyze", + "artifact_name": artifactName, + "prompt_id": artifactCfg.PromptID, + "profile_id": artifactCfg.ProfileID, + "binary": env.Config.Pipeline.Scriptorium.Binary, + "config_path": env.Config.Pipeline.Scriptorium.ConfigPath, + "input_paths": inputPaths, + "input_names": sortedMapKeys(inputPaths), + "omitted_optional_inputs": omittedOptionalInputs, + "vars": vars, + "timeout": timeout.String(), + "processed_transcript_path": processedTranscriptPath, + "processed_transcript_source": processedSource, + "normalized_transcript_path": normalizedTranscriptPath, + "normalized_transcript_source": normalizedSource, + "trimmed_transcript_path": trimmedTranscriptPath, + "trimmed_transcript_source": trimmedSource, + "render_debug_enabled": resolveRenderDebugEnabled(env.Config.Pipeline.Scriptorium.RenderDebug, artifactCfg.RenderDebug), } if meta["render_debug_enabled"] == true { @@ -376,10 +385,12 @@ func discoverTrimmedTranscript(m *manifest.Manifest, paths artifacts.SessionPath } type analyzeTranscriptInputs struct { - ProcessedPath string - ProcessedSource string - TrimmedPath string - TrimmedSource string + ProcessedPath string + ProcessedSource string + NormalizedPath string + NormalizedSource string + TrimmedPath string + TrimmedSource string } func resolveScriptoriumInput( @@ -398,6 +409,14 @@ func resolveScriptoriumInput( return "", false, fmt.Errorf("processed transcript %q invalid: %w", transcriptInputs.ProcessedPath, err) } return transcriptInputs.ProcessedPath, true, nil + case "normalized_transcript": + if strings.TrimSpace(transcriptInputs.NormalizedPath) == "" { + return "", false, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first") + } + if err := validateProcessedTranscriptOutput(transcriptInputs.NormalizedPath); err != nil { + return "", false, fmt.Errorf("normalized transcript %q invalid: %w", transcriptInputs.NormalizedPath, err) + } + return transcriptInputs.NormalizedPath, true, nil case "trimmed_transcript": if strings.TrimSpace(transcriptInputs.TrimmedPath) == "" { return "", false, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first") diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index fd89b62..6bc16b6 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -447,6 +447,85 @@ func TestAnalyzeSupportsProcessedTranscriptSourceWhenConfigured(t *testing.T) { } } +func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := env.ArtifactStore.SessionPaths(m.SessionID) + normalizedPath := filepath.Join(paths.TranscriptsDir, "normalized.json") + writeAnalyzeFile(t, normalizedPath, `{"segments":[{"id":1}]}`) + + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ + Source: "normalized_transcript", + Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + _, 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].InputPaths["transcript"] != normalizedPath { + t.Fatalf("transcript input = %q, want normalized transcript path", fake.RunRequests[0].InputPaths["transcript"]) + } +} + +func TestAnalyzeSupportsNormalizedTranscriptSourceFromManifestOutput(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := env.ArtifactStore.SessionPaths(m.SessionID) + fallbackPath := filepath.Join(paths.TranscriptsDir, "normalized.json") + manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") + writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`) + writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`) + m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ + {Kind: "transcript_normalized", LocalPath: manifestPath}, + }) + + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ + Source: "normalized_transcript", + Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + _, 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].InputPaths["transcript"] != manifestPath { + t.Fatalf("transcript input = %q, want manifest normalized transcript path", fake.RunRequests[0].InputPaths["transcript"]) + } +} + +func TestAnalyzeFailsWhenNormalizedTranscriptMissing(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ + Source: "normalized_transcript", + Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + _, err := (analyzeStage{}).Run(context.Background(), env, m) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "normalized transcript input is unavailable") { + t.Fatalf("error = %q, want missing normalized transcript context", err.Error()) + } + if !strings.Contains(err.Error(), "run normalize stage first") { + t.Fatalf("error = %q, want guidance to run normalize stage first", err.Error()) + } + if len(fake.RunRequests) != 0 { + t.Fatalf("run requests = %d, want 0 on missing normalized transcript", len(fake.RunRequests)) + } +} + func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) paths := env.ArtifactStore.SessionPaths(m.SessionID)