From 10377876e49edd3a3e2cc5a5feb33bb3bfc22ad0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 11 May 2026 00:41:29 +0000 Subject: [PATCH] Complete Phase 2 Go normalization foundation --- README.md | 43 +- internal/cli/run.go | 204 ++- internal/cli/run_test.go | 1195 ++++------------- internal/cli/testdata/tiny_glossary.yaml | 9 +- internal/core/diagnostics/run_dir.go | 48 +- internal/core/normalization/normalize.go | 36 +- internal/core/normalization/normalize_test.go | 131 +- .../transcript_same_speaker_merge.golden.json | 4 +- internal/core/schema/glossary_test.go | 20 +- internal/core/schema/transcript.go | 7 +- internal/core/schema/transcript_test.go | 24 +- 11 files changed, 480 insertions(+), 1241 deletions(-) diff --git a/README.md b/README.md index 2dcf7e0..d088bc9 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,29 @@ # Audita (Go Rewrite) -Audita is a transcript polishing CLI. The Go implementation at the repository root is the canonical implementation going forward, with a compatibility-first rewrite strategy. +Audita is a transcript polishing CLI. The Go implementation at the repository root is canonical going forward, and is being delivered compatibility-first against the frozen Python reference in `python/`. -## Current Status: Phase 1 Skeleton +## Current Status: Phase 2 Foundation -The current Go `audita process` command is a passthrough skeleton: +The current `audita process` implementation now includes deterministic input handling and normalization foundations: -- loads transcript and glossary files; -- validates transcript as well-formed JSON; -- writes transcript JSON back unchanged to `--output` or stdout; -- writes an early minimal `--report-json` report when requested; -- preserves subprocess-safe stdout/stderr behavior. +- typed transcript parsing for both accepted top-level forms (`[]` and `{ "segments": [...] }`); +- typed glossary YAML parsing; +- transcript and glossary schema validation with actionable errors; +- deterministic same-speaker normalization; +- sequential normalized segment IDs starting at `1`; +- normalized transcript JSON output; +- minimal structured `--report-json` output including normalization summary fields; +- minimal normalization diagnostics artifacts in per-run work directories; +- subprocess-safe stdout/stderr behavior. -Not implemented yet in Phase 1: +Still not implemented in Phase 2: -- transcript/glossary schema validation; -- normalization; -- chunking/proposals; -- validators; -- LLM calls; -- module execution; -- full diagnostics retention/report model. +- transcript-polishing correction modules; +- chunking/proposal generation and application; +- deterministic and LLM validators; +- structured LLM client execution; +- module pipeline execution; +- full long-term diagnostics retention/reporting model. ## Quick Start (Go) @@ -37,17 +40,17 @@ go run ./cmd/audita --help go run ./cmd/audita process --help ``` -Run minimal passthrough: +Run Phase 2 normalization flow: ```sh go run ./cmd/audita process transcript.json --glossary glossary.yaml --output corrected.json ``` -Without `--output`, corrected transcript JSON is written to stdout. +Without `--output`, normalized transcript JSON is written to stdout. ## Repository Notes -- `python/` contains the frozen Python implementation retained as behavioral reference during the port. -- Go architecture and rewrite guidance: +- `python/` contains the frozen Python implementation used as behavioral reference during the Go port. +- Architecture and rewrite guidance: - `docs/architecture.md` - `docs/rewrite-notes.md` diff --git a/internal/cli/run.go b/internal/cli/run.go index 742655e..3d9c6ad 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -10,7 +10,7 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/diagnostics" - "gitea.maximumdirect.net/eric/audita/internal/core/io" + coreio "gitea.maximumdirect.net/eric/audita/internal/core/io" "gitea.maximumdirect.net/eric/audita/internal/core/normalization" "gitea.maximumdirect.net/eric/audita/internal/core/reporting" "gitea.maximumdirect.net/eric/audita/internal/core/schema" @@ -24,63 +24,84 @@ type processInvocation struct { Config config.Config } -var processRunner = func(inv processInvocation, stdout io.Writer) (*normalization.NormalizationSummary, error) { - // Create run directory early to capture artifacts +var processRunner = func(inv processInvocation, stdout io.Writer) (*normalization.NormalizationSummary, *diagnostics.RunDirectory, error) { runDir, err := diagnostics.NewRunDirectory(inv.Config.WorkDir, string(inv.Config.WorkDirRetention)) if err != nil { - return nil, fmt.Errorf("run_dir_creation: %w", err) + return nil, nil, fmt.Errorf("run_dir_creation: %w", err) } - defer func() { - // Apply retention policy - failed runs are always kept - // For now, we consider any run that saved source as successful - if runDir != nil { - _ = runDir.ApplyRetention() - } - }() - transcriptBytes, err := io.ReadRequiredFile(inv.TranscriptPath, "transcript") + fail := func(phase string, err error) (*normalization.NormalizationSummary, *diagnostics.RunDirectory, error) { + _ = runDir.WriteErrorLog(fmt.Sprintf("%s: %v", phase, err)) + return nil, runDir, fmt.Errorf("%s: %w", phase, err) + } + + transcriptBytes, err := coreio.ReadRequiredFile(inv.TranscriptPath, "transcript") if err != nil { - // Best effort to write error log on failure - _ = runDir.WriteErrorLog(fmt.Sprintf("transcript_read: %v", err)) - return nil, fmt.Errorf("transcript_read: %w", err) + return fail("transcript_read", err) } - glossaryBytes, err := io.ReadRequiredFile(inv.GlossaryPath, "glossary") + glossaryBytes, err := coreio.ReadRequiredFile(inv.GlossaryPath, "glossary") if err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("glossary_read: %v", err)) - return nil, fmt.Errorf("glossary_read: %w", err) + return fail("glossary_read", err) } - // Parse and validate transcript using typed schema - transcript, err := schema.ParseSourceTranscriptJSON(transcriptBytes) + sourceTranscript, err := schema.ParseSourceTranscriptJSON(transcriptBytes) if err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("transcript_schema: %v", err)) - return nil, fmt.Errorf("transcript_schema: %w", err) + return fail("transcript_schema", err) } - // Write source transcript artifacts - if err := runDir.WriteSourceTranscript(transcript, transcriptBytes); err != nil { + if _, err := schema.ParseGlossaryYAML(glossaryBytes); err != nil { + return fail("glossary_schema", err) + } + + if err := runDir.WriteSourceTranscript(sourceTranscript, transcriptBytes); err != nil { _ = runDir.WriteErrorLog(fmt.Sprintf("source_artifact: %v", err)) - // Continue without failing the whole process } - // Parse and validate glossary using typed schema - glossary, err := schema.ParseGlossaryYAML(glossaryBytes) + canonical := sourceToCanonicalTranscript(sourceTranscript) + normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{ + MaxSegmentGap: inv.Config.Normalization.MaxSegmentGap, + EllipsisGap: inv.Config.Normalization.EllipsisGap, + MaxSegmentDuration: inv.Config.Normalization.MaxSegmentDuration, + MaxSegmentTokens: inv.Config.Normalization.MaxSegmentTokens, + }) + + normalizedTranscript, summary := normalizer.Normalize(canonical) + + if err := runDir.WriteNormalizedTranscript(normalizedTranscript); err != nil { + _ = runDir.WriteErrorLog(fmt.Sprintf("normalized_artifact: %v", err)) + } + if err := runDir.WriteNormalizationSummary(summary); err != nil { + _ = runDir.WriteErrorLog(fmt.Sprintf("normalization_summary: %v", err)) + } + + outputBytes, err := schema.TranscriptToJSON(normalizedTranscript) if err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("glossary_schema: %v", err)) - return nil, fmt.Errorf("glossary_schema: %w", err) + return fail("serialization", err) } - // Convert to canonical transcript format for normalization - normalizedTranscript := &schema.Transcript{ - Segments: make([]schema.Segment, len(transcript.Segments)), + if strings.TrimSpace(inv.OutputPath) != "" { + if err := coreio.WriteFile(inv.OutputPath, outputBytes); err != nil { + return fail("output_write", err) + } + return summary, runDir, nil } - for i, s := range transcript.Segments { + + if _, err := stdout.Write(outputBytes); err != nil { + return fail("stdout_write", err) + } + + return summary, runDir, nil +} + +func sourceToCanonicalTranscript(source *schema.SourceTranscript) *schema.Transcript { + segments := make([]schema.Segment, len(source.Segments)) + for i, s := range source.Segments { id := i + 1 if s.ID != nil { id = *s.ID } - normalizedTranscript.Segments[i] = schema.Segment{ + segments[i] = schema.Segment{ ID: id, Speaker: s.Speaker, Start: s.Start, @@ -89,49 +110,7 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio Categories: s.Categories, } } - - // Apply deterministic normalization - normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{ - MaxSegmentGap: inv.Config.Normalization.MaxSegmentGap, - EllipsisGap: inv.Config.Normalization.EllipsisGap, - MaxSegmentDuration: inv.Config.Normalization.MaxSegmentDuration, - MaxSegmentTokens: inv.Config.Normalization.MaxSegmentTokens, - }) - - normalizedTranscript, normalizationSummary := normalizer.Normalize(normalizedTranscript) - - // Write normalized transcript artifact - if err := runDir.WriteNormalizedTranscript(normalizedTranscript); err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("normalized_artifact: %v", err)) - // Continue without failing the whole process - } - - // Write normalization summary artifact - if err := runDir.WriteNormalizationSummary(normalizationSummary); err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("normalization_summary: %v", err)) - // Continue without failing the whole process - } - - // Serialize normalized transcript to JSON - outputBytes, err := schema.TranscriptToJSON(normalizedTranscript) - if err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("serialization: %v", err)) - return nil, fmt.Errorf("serialization: %w", err) - } - - if strings.TrimSpace(inv.OutputPath) != "" { - if err := io.WriteFile(inv.OutputPath, outputBytes); err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("output_write: %v", err)) - return nil, err - } - return normalizationSummary, nil - } - - if _, err := stdout.Write(outputBytes); err != nil { - _ = runDir.WriteErrorLog(fmt.Sprintf("stdout_write: %v", err)) - return nil, fmt.Errorf("stdout_write: %w", err) - } - return normalizationSummary, nil + return &schema.Transcript{Segments: segments} } // Run executes the Audita CLI with the provided arguments and streams. @@ -273,53 +252,45 @@ func runProcess(args []string, stdout, stderr io.Writer) int { Config: cfg, } - if normalizationSummary, err := processRunner(inv, stdout); err != nil { - completedAt := time.Now().UTC() - - // Extract error phase from error message if present - errorPhase := "" - errorMsg := err.Error() - if strings.Contains(errorMsg, ": ") { - parts := strings.SplitN(errorMsg, ": ", 2) - if len(parts) == 2 { - errorPhase = parts[0] - errorMsg = parts[1] - } - } - + summary, runDir, runErr := processRunner(inv, stdout) + completedAt := time.Now().UTC() + + if runErr != nil { + errorPhase, errorMessage := extractErrorPhase(runErr) + report := buildProcessReport("failed", inv, startedAt, completedAt, errorMessage, errorPhase, nil) + if strings.TrimSpace(inv.ReportJSONPath) != "" { - report := buildProcessReport("failed", inv, startedAt, completedAt, errorMsg, errorPhase, nil) - if reportErr := reporting.WriteProcessReport(inv.ReportJSONPath, report); reportErr != nil { - fmt.Fprintf(stderr, "audita process: %v\n", reportErr) - return 1 + if err := reporting.WriteProcessReport(inv.ReportJSONPath, report); err != nil { + fmt.Fprintf(stderr, "audita process: %v\n", err) } } - fmt.Fprintf(stderr, "audita process: %v\n", err) - return 1 - } + + if runDir != nil { + _ = runDir.WriteReport(report) + _ = runDir.ApplyRetention(false) } - fmt.Fprintf(stderr, "audita process: %v\n", err) + + fmt.Fprintf(stderr, "audita process: %v\n", runErr) return 1 } - completedAt := time.Now().UTC() + report := buildProcessReport("success", inv, startedAt, completedAt, "", "", summary) + if strings.TrimSpace(inv.ReportJSONPath) != "" { - report := buildProcessReport("success", inv, startedAt, completedAt, "", "", normalizationSummary) if err := reporting.WriteProcessReport(inv.ReportJSONPath, report); err != nil { + if runDir != nil { + _ = runDir.WriteErrorLog(fmt.Sprintf("report_write: %v", err)) + _ = runDir.ApplyRetention(false) + } fmt.Fprintf(stderr, "audita process: %v\n", err) return 1 } } - } - fmt.Fprintf(stderr, "audita process: %v\n", err) - return 1 - } - if strings.TrimSpace(inv.ReportJSONPath) != "" { - completedAt := time.Now().UTC() - report := buildProcessReport("success", inv, startedAt, completedAt, "") - if err := reporting.WriteProcessReport(inv.ReportJSONPath, report); err != nil { - fmt.Fprintf(stderr, "audita process: %v\n", err) + if runDir != nil { + _ = runDir.WriteReport(report) + if err := runDir.ApplyRetention(true); err != nil { + fmt.Fprintf(stderr, "audita process: failed to apply work-dir retention: %v\n", err) return 1 } } @@ -327,9 +298,20 @@ func runProcess(args []string, stdout, stderr io.Writer) int { return 0 } +func extractErrorPhase(err error) (phase string, message string) { + msg := err.Error() + if strings.Contains(msg, ": ") { + parts := strings.SplitN(msg, ": ", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + } + return "", msg +} + func buildProcessReport(status string, inv processInvocation, startedAt, completedAt time.Time, errorMessage string, errorPhase string, normalizationSummary *normalization.NormalizationSummary) reporting.ProcessReport { report := reporting.ProcessReport{ - Phase: "phase2-normalization", + Phase: "phase2-foundation", Status: status, Operation: "process", TranscriptPath: inv.TranscriptPath, diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index e27e026..4032487 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -3,12 +3,14 @@ package cli import ( "bytes" "encoding/json" - "io" "os" "path/filepath" - "reflect" "strings" "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/normalization" + "gitea.maximumdirect.net/eric/audita/internal/core/reporting" + "gitea.maximumdirect.net/eric/audita/internal/core/schema" ) func TestRunRootHelp(t *testing.T) { @@ -22,9 +24,6 @@ func TestRunRootHelp(t *testing.T) { if !strings.Contains(stdout.String(), "audita [options]") { t.Fatalf("expected root usage in stdout, got %q", stdout.String()) } - if !strings.Contains(stdout.String(), "process") { - t.Fatalf("expected process command in root help, got %q", stdout.String()) - } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) } @@ -71,31 +70,14 @@ func TestRunProcessHelpListsExpectedFlags(t *testing.T) { "--work-dir-retention", } { if !strings.Contains(stdout.String(), expectedFlag) { - t.Fatalf("expected process help to include %q, got %q", expectedFlag, stdout.String()) + t.Fatalf("expected process help to include %q", expectedFlag) } } - if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) } } -func TestRunUnknownCommand(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - exitCode := Run([]string{"unknown"}, &stdout, &stderr) - if exitCode != 2 { - t.Fatalf("expected exit code 2, got %d", exitCode) - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout, got %q", stdout.String()) - } - if !strings.Contains(stderr.String(), "unknown command") { - t.Fatalf("expected unknown command error in stderr, got %q", stderr.String()) - } -} - func TestRunProcessMissingTranscriptPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -128,96 +110,144 @@ func TestRunProcessMissingGlossary(t *testing.T) { } } -func TestRunProcessUnreadableTranscript(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer +func TestRunProcessAcceptsBothTranscriptTopLevelForms(t *testing.T) { + testCases := []string{ + schemaFixturePath("transcript_bare_array.json"), + schemaFixturePath("transcript_object.json"), + } - missingTranscript := filepath.Join(t.TempDir(), "missing.json") - exitCode := Run([]string{"process", missingTranscript, "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) - if exitCode == 0 { - t.Fatalf("expected nonzero exit code") - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout, got %q", stdout.String()) - } - if !strings.Contains(stderr.String(), "failed to read transcript file") { - t.Fatalf("expected unreadable transcript error, got %q", stderr.String()) + for _, transcriptPath := range testCases { + t.Run(filepath.Base(transcriptPath), func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr on success, got %q", stderr.String()) + } + + parsed, err := schema.ParseTranscriptJSON(stdout.Bytes()) + if err != nil { + t.Fatalf("expected normalized transcript JSON output, got %v", err) + } + if len(parsed.Segments) == 0 { + t.Fatalf("expected at least one output segment") + } + }) } } -func TestRunProcessMalformedTranscriptJSON(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - exitCode := Run([]string{"process", fixturePath("malformed_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) - if exitCode == 0 { - t.Fatalf("expected nonzero exit code") - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout, got %q", stdout.String()) - } - if !strings.Contains(stderr.String(), "is not valid JSON") { - t.Fatalf("expected malformed transcript error, got %q", stderr.String()) - } -} - -func TestRunProcessOutputToFile(t *testing.T) { +func TestRunProcessOutputToFileLeavesStdoutEmpty(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := fixturePath("tiny_transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") - outputPath := filepath.Join(t.TempDir(), "corrected.json") + outputPath := filepath.Join(t.TempDir(), "normalized.json") exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { - t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) + t.Fatalf("expected empty stdout with --output, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } - inputBytes := readFile(t, transcriptPath) outputBytes := readFile(t, outputPath) - assertJSONSemanticallyEqual(t, inputBytes, outputBytes) + if !json.Valid(outputBytes) { + t.Fatalf("expected valid JSON output file, got %q", string(outputBytes)) + } } -func TestRunProcessOutputToStdout(t *testing.T) { +func TestRunProcessInvalidTranscriptSchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - transcriptPath := fixturePath("tiny_transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + exitCode := Run([]string{"process", schemaFixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code for invalid transcript schema") } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "transcript_schema") { + t.Fatalf("expected transcript schema phase in stderr, got %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "speaker") { + t.Fatalf("expected speaker validation details, got %q", stderr.String()) } - - inputBytes := readFile(t, transcriptPath) - assertJSONSemanticallyEqual(t, inputBytes, stdout.Bytes()) } -func TestRunProcessReportJSONSuccess(t *testing.T) { +func TestRunProcessInvalidGlossarySchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - transcriptPath := fixturePath("tiny_transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - outputPath := filepath.Join(t.TempDir(), "corrected.json") - reportPath := filepath.Join(t.TempDir(), "report.json") + exitCode := Run([]string{"process", fixturePath("tiny_transcript.json"), "--glossary", schemaFixturePath("glossary_missing_fields.yaml")}, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code for invalid glossary schema") + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "glossary_schema") { + t.Fatalf("expected glossary schema phase in stderr, got %q", stderr.String()) + } +} + +func TestRunProcessCLIOverridesEnvironment(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + transcriptPath := writeFile(t, "transcript.json", `[ + {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}, + {"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"} +]`) + + t.Setenv("AUDITA_NORMALIZE_MAX_SEGMENT_GAP", "0") exitCode := Run([]string{ "process", transcriptPath, "--glossary", - glossaryPath, + fixturePath("tiny_glossary.yaml"), + "--normalize-max-segment-gap", + "2.0", + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + + transcript, err := schema.ParseTranscriptJSON(stdout.Bytes()) + if err != nil { + t.Fatalf("expected valid transcript JSON output: %v", err) + } + if len(transcript.Segments) != 1 { + t.Fatalf("expected merged output from CLI override, got %d segments", len(transcript.Segments)) + } +} + +func TestRunProcessReportJSONSuccessIncludesNormalizationSummary(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + transcriptPath := writeFile(t, "transcript.json", `[ + {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}, + {"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"} +]`) + reportPath := filepath.Join(t.TempDir(), "report.json") + outputPath := filepath.Join(t.TempDir(), "normalized.json") + + exitCode := Run([]string{ + "process", + transcriptPath, + "--glossary", + fixturePath("tiny_glossary.yaml"), "--output", outputPath, "--report-json", @@ -227,88 +257,24 @@ func TestRunProcessReportJSONSuccess(t *testing.T) { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { - t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) + t.Fatalf("expected empty stdout with --output, got %q", stdout.String()) } report := readProcessReport(t, reportPath) if report.Status != "success" { - t.Fatalf("expected success report status, got %q", report.Status) + t.Fatalf("expected success status, got %q", report.Status) } if report.Operation != "process" { t.Fatalf("expected operation process, got %q", report.Operation) } - if report.TranscriptPath != transcriptPath { - t.Fatalf("unexpected transcript path in report: %q", report.TranscriptPath) + if report.InputSegmentCount == nil || *report.InputSegmentCount != 2 { + t.Fatalf("expected input segment count 2, got %v", report.InputSegmentCount) } - if report.GlossaryPath != glossaryPath { - t.Fatalf("unexpected glossary path in report: %q", report.GlossaryPath) + if report.NormalizedSegmentCount == nil || *report.NormalizedSegmentCount != 1 { + t.Fatalf("expected normalized segment count 1, got %v", report.NormalizedSegmentCount) } - if report.OutputPath != outputPath { - t.Fatalf("unexpected output path in report: %q", report.OutputPath) - } - if len(report.Modules) == 0 { - t.Fatalf("expected configured module sequence in report") - } - if report.StartedAt == "" || report.CompletedAt == "" { - t.Fatalf("expected started_at and completed_at timestamps in report") - } - if report.ErrorMessage != "" { - t.Fatalf("did not expect error message in success report, got %q", report.ErrorMessage) - } -} - -func TestRunProcessReportJSONNoStdoutPollution(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptPath := fixturePath("tiny_transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - reportPath := filepath.Join(t.TempDir(), "report.json") - - exitCode := Run([]string{ - "process", - transcriptPath, - "--glossary", - glossaryPath, - "--report-json", - reportPath, - }, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - assertJSONSemanticallyEqual(t, readFile(t, transcriptPath), stdout.Bytes()) - - reportBytes := readFile(t, reportPath) - if bytes.Contains(stdout.Bytes(), reportBytes) { - t.Fatalf("stdout was polluted with report JSON") - } -} - -func TestRunProcessReportJSONRedactsSecrets(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - secretPrimary := "top-secret-primary-key" - secretValidation := "top-secret-validation-key" - t.Setenv("AUDITA_LLM_API_KEY", secretPrimary) - t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secretValidation) - - reportPath := filepath.Join(t.TempDir(), "report.json") - exitCode := Run([]string{ - "process", - fixturePath("tiny_transcript.json"), - "--glossary", - fixturePath("tiny_glossary.yaml"), - "--report-json", - reportPath, - }, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - reportBytes := readFile(t, reportPath) - if strings.Contains(string(reportBytes), secretPrimary) || strings.Contains(string(reportBytes), secretValidation) { - t.Fatalf("report JSON leaked API key material: %q", string(reportBytes)) + if report.NormalizationMerges == nil || *report.NormalizationMerges != 1 { + t.Fatalf("expected 1 merge, got %v", report.NormalizationMerges) } } @@ -326,86 +292,124 @@ func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) { reportPath, }, &stdout, &stderr) if exitCode == 0 { - t.Fatalf("expected nonzero exit code for malformed transcript") + t.Fatalf("expected nonzero exit code") + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) } report := readProcessReport(t, reportPath) if report.Status != "failed" { - t.Fatalf("expected failed report status, got %q", report.Status) + t.Fatalf("expected failed status, got %q", report.Status) } - if report.ErrorMessage == "" { - t.Fatalf("expected error message in failed report") + if report.ErrorPhase != "transcript_schema" { + t.Fatalf("expected transcript_schema error phase, got %q", report.ErrorPhase) } if !strings.Contains(report.ErrorMessage, "not valid JSON") { - t.Fatalf("expected malformed JSON failure in report error, got %q", report.ErrorMessage) + t.Fatalf("expected parse error message, got %q", report.ErrorMessage) } } -func TestRunProcessInvalidTranscriptSchema(t *testing.T) { +func TestRunProcessReportJSONDoesNotLeakAPIKeys(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - exitCode := Run([]string{"process", fixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) - if exitCode == 0 { - t.Fatalf("expected nonzero exit code for invalid transcript schema") - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout, got %q", stdout.String()) - } - if !strings.Contains(stderr.String(), "invalid transcript:") { - t.Fatalf("expected invalid transcript error, got %q", stderr.String()) - } - if !strings.Contains(stderr.String(), "speaker") { - t.Fatalf("expected speaker validation error, got %q", stderr.String()) - } -} + primaryKey := "top-secret-primary" + validationKey := "top-secret-validation" + t.Setenv("AUDITA_LLM_API_KEY", primaryKey) + t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", validationKey) -func TestRunProcessValidBareArrayTranscript(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) + reportPath := filepath.Join(t.TempDir(), "report.json") + exitCode := Run([]string{ + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--report-json", + reportPath, + }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) - } - // Should produce valid JSON output - if !json.Valid(stdout.Bytes()) { - t.Fatalf("expected valid JSON output, got %q", stdout.String()) - } - - // Parse the output to verify it's a proper transcript - var output any - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("failed to unmarshal output JSON: %v", err) - } - - // Should be an array at the top level (canonical object form would be {"segments": [...]}) - outputArray, ok := output.([]any) - if !ok { - t.Fatalf("expected output to be JSON array, got %T", output) - } - if len(outputArray) != 2 { - t.Fatalf("expected 2 segments in output, got %d", len(outputArray)) + reportBytes := readFile(t, reportPath) + if strings.Contains(string(reportBytes), primaryKey) || strings.Contains(string(reportBytes), validationKey) { + t.Fatalf("report leaked API key material: %s", string(reportBytes)) } } -func TestRunProcessInvalidGlossarySchema(t *testing.T) { +func TestRunProcessWritesNormalizationDiagnosticsArtifacts(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - exitCode := Run([]string{"process", fixturePath("tiny_transcript.json"), "--glossary", "../core/schema/testdata/glossary_missing_fields.yaml"}, &stdout, &stderr) + workDir := t.TempDir() + transcriptPath := writeFile(t, "transcript.json", `[ + {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}, + {"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"} +]`) + + exitCode := Run([]string{ + "process", + transcriptPath, + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--work-dir", + workDir, + "--work-dir-retention", + "always", + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + + runPath := onlyRunDir(t, workDir) + for _, artifact := range []string{ + "source-transcript.json", + "source-transcript-parsed.json", + "normalized-transcript.json", + "normalization-summary.json", + "report.json", + } { + if _, err := os.Stat(filepath.Join(runPath, artifact)); err != nil { + t.Fatalf("expected artifact %s: %v", artifact, err) + } + } + + summaryBytes := readFile(t, filepath.Join(runPath, "normalization-summary.json")) + var summary normalization.NormalizationSummary + if err := json.Unmarshal(summaryBytes, &summary); err != nil { + t.Fatalf("failed to parse normalization summary: %v", err) + } + if summary.OutputSegmentCount != 1 { + t.Fatalf("expected merged output in normalization summary, got %d", summary.OutputSegmentCount) + } +} + +func TestRunProcessFailedRunRetainedWithNeverRetention(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + workDir := t.TempDir() + exitCode := Run([]string{ + "process", + schemaFixturePath("transcript_empty_speaker.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--work-dir", + workDir, + "--work-dir-retention", + "never", + }, &stdout, &stderr) if exitCode == 0 { - t.Fatalf("expected nonzero exit code for invalid glossary schema") + t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { - t.Fatalf("expected empty stdout, got %q", stdout.String()) + t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) } - if !strings.Contains(stderr.String(), "invalid glossary:") { - t.Fatalf("expected invalid glossary error, got %q", stderr.String()) + + runPath := onlyRunDir(t, workDir) + if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil { + t.Fatalf("expected error.log in retained failed run: %v", err) } } @@ -413,6 +417,19 @@ func fixturePath(name string) string { return filepath.Join("testdata", name) } +func schemaFixturePath(name string) string { + return filepath.Join("..", "core", "schema", "testdata", name) +} + +func writeFile(t *testing.T, name string, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write test file %q: %v", path, err) + } + return path +} + func readFile(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) @@ -422,782 +439,24 @@ func readFile(t *testing.T, path string) []byte { return data } -func assertJSONSemanticallyEqual(t *testing.T, expected []byte, actual []byte) { - t.Helper() - if !json.Valid(actual) { - t.Fatalf("actual output is not valid JSON: %q", string(actual)) - } - - var expectedValue any - var actualValue any - if err := json.Unmarshal(expected, &expectedValue); err != nil { - t.Fatalf("failed to unmarshal expected JSON: %v", err) - } - if err := json.Unmarshal(actual, &actualValue); err != nil { - t.Fatalf("failed to unmarshal actual JSON: %v", err) - } - if !reflect.DeepEqual(expectedValue, actualValue) { - t.Fatalf("JSON content mismatch: expected %q got %q", string(expected), string(actual)) - } -} - -type minimalProcessReport struct { - Status string `json:"status"` - Operation string `json:"operation"` - TranscriptPath string `json:"transcript_path"` - GlossaryPath string `json:"glossary_path"` - OutputPath string `json:"output_path"` - Modules []string `json:"modules"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at"` - ErrorMessage string `json:"error_message"` -} - -func readProcessReport(t *testing.T, path string) minimalProcessReport { +func readProcessReport(t *testing.T, path string) reporting.ProcessReport { t.Helper() raw := readFile(t, path) - var report minimalProcessReport + var report reporting.ProcessReport if err := json.Unmarshal(raw, &report); err != nil { - t.Fatalf("failed to unmarshal process report JSON: %v (raw: %q)", err, string(raw)) - } -func TestRunProcessStdoutBehaviorWithoutOutput(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) - } - - // Should have transcript JSON in stdout - if stdout.Len() == 0 { - t.Fatalf("expected transcript JSON in stdout, got empty stdout") - } - if !json.Valid(stdout.Bytes()) { - t.Fatalf("expected valid JSON in stdout, got %q", stdout.String()) + t.Fatalf("failed to parse process report JSON: %v (raw=%s)", err, string(raw)) } + return report } -func TestRunProcessEmptyStdoutWithOutput(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptPath := "../core/schema/testdata/transcript_bare_array.json" - glossaryPath := fixturePath("tiny_glossary.yaml") - outputPath := filepath.Join(t.TempDir(), "corrected.json") - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) - } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) - } - - // Verify output file was created and contains valid JSON - outputBytes := readFile(t, outputPath) - if !json.Valid(outputBytes) { - t.Fatalf("expected valid JSON in output file, got %q", string(outputBytes)) - } -} - -func TestRunProcessNormalizationMergeInOutput(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - // Create a test transcript with segments that should be merged - transcriptJSON := `[ - {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, - {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - outputPath := filepath.Join(t.TempDir(), "corrected.json") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) - } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) - } - - // Read and parse the output - outputBytes := readFile(t, outputPath) - var outputTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(outputBytes); err != nil { - t.Fatalf("failed to parse output transcript: %v (output: %s)", err, string(outputBytes)) - } - - // Should have merged the two segments into one - if len(outputTranscript.Segments) != 1 { - t.Fatalf("expected 1 merged segment, got %d segments", len(outputTranscript.Segments)) - } - - // Should have sequential ID starting at 1 - if outputTranscript.Segments[0].ID != 1 { - t.Fatalf("expected segment ID 1, got %d", outputTranscript.Segments[0].ID) - } - - // Should contain merged text - if !strings.Contains(outputTranscript.Segments[0].Text, "Hello") || !strings.Contains(outputTranscript.Segments[0].Text, "world") { - t.Fatalf("expected merged text to contain both parts, got %q", outputTranscript.Segments[0].Text) - } -} - -func TestRunProcessNormalizedOutputToStdout(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[ - {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, - {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) - } - - // Should have normalized output in stdout - if stdout.Len() == 0 { - t.Fatalf("expected normalized transcript in stdout, got empty stdout") - } - - // Should be valid JSON - if !json.Valid(stdout.Bytes()) { - t.Fatalf("expected valid JSON in stdout, got %q", stdout.String()) - } - - // Parse and verify it's normalized (merged) - var outputTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil { - t.Fatalf("failed to parse stdout transcript: %v", err) - } - - if len(outputTranscript.Segments) != 1 { - t.Fatalf("expected 1 merged segment in stdout, got %d", len(outputTranscript.Segments)) - } -} - -func TestRunProcessSequentialIDsInOutput(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[ - {"id": 5, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, - {"id": 10, "speaker": "Bob", "start": 2.0, "end": 3.0, "text": "Hi"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - // Parse output and verify sequential IDs - var outputTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil { - t.Fatalf("failed to parse output transcript: %v", err) - } - - // Should have sequential IDs starting at 1 - for i, segment := range outputTranscript.Segments { - expectedID := i + 1 - if segment.ID != expectedID { - t.Fatalf("expected segment %d to have ID %d, got %d", i, expectedID, segment.ID) - } - } -} - -func TestRunProcessNoStdoutWithOutput(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[ - {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - outputPath := filepath.Join(t.TempDir(), "corrected.json") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) - } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) - } - - // Verify output file exists and is valid - if _, err := os.Stat(outputPath); err != nil { - t.Fatalf("expected output file to be created: %v", err) - } -} - -func TestRunProcessReportIncludesNormalizationSummary(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - // Create a test transcript with segments that will be merged - transcriptJSON := `[ - {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, - {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - reportPath := filepath.Join(t.TempDir(), "report.json") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--report-json", reportPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout when --output is not used but --report-json is provided, got %q", stdout.String()) - } - if stderr.Len() != 0 { - t.Fatalf("expected empty stderr on success, got %q", stderr.String()) - } - - // Read and parse the report - reportBytes := readFile(t, reportPath) - var report reporting.ProcessReport - if err := json.Unmarshal(reportBytes, &report); err != nil { - t.Fatalf("failed to parse report JSON: %v (raw: %s)", err, string(reportBytes)) - } - - // Verify report includes normalization summary - if report.Status != "success" { - t.Fatalf("expected success status, got %q", report.Status) - } - if report.InputSegmentCount == nil || *report.InputSegmentCount != 2 { - t.Fatalf("expected input segment count 2, got %v", report.InputSegmentCount) - } - if report.NormalizedSegmentCount == nil || *report.NormalizedSegmentCount != 1 { - t.Fatalf("expected normalized segment count 1, got %v", report.NormalizedSegmentCount) - } - if report.NormalizationMerges == nil || *report.NormalizationMerges != 1 { - t.Fatalf("expected 1 merge, got %v", report.NormalizationMerges) - } - if report.NormalizationIDReassignments == nil || *report.NormalizationIDReassignments != 0 { - t.Fatalf("expected 0 ID reassignments (already sequential), got %v", report.NormalizationIDReassignments) - } -} - -func TestRunProcessReportFailedTranscriptSchema(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - reportPath := filepath.Join(t.TempDir(), "report.json") - exitCode := Run([]string{ - "process", - fixturePath("transcript_empty_speaker.json"), - "--glossary", - fixturePath("tiny_glossary.yaml"), - "--report-json", - reportPath, - }, &stdout, &stderr) - if exitCode == 0 { - t.Fatalf("expected nonzero exit code for invalid transcript") - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) - } - - // Read and parse the report - reportBytes := readFile(t, reportPath) - var report reporting.ProcessReport - if err := json.Unmarshal(reportBytes, &report); err != nil { - t.Fatalf("failed to parse report JSON: %v (raw: %s)", err, string(reportBytes)) - } - - // Verify failed report structure - if report.Status != "failed" { - t.Fatalf("expected failed status, got %q", report.Status) - } - if report.ErrorPhase != "transcript_schema" { - t.Fatalf("expected error phase 'transcript_schema', got %q", report.ErrorPhase) - } - if report.ErrorMessage == "" { - t.Fatalf("expected error message in failed report") - } - if !strings.Contains(report.ErrorMessage, "speaker") { - t.Fatalf("expected speaker validation error, got %q", report.ErrorMessage) - } -} - -func TestRunProcessReportFailedGlossarySchema(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - reportPath := filepath.Join(t.TempDir(), "report.json") - exitCode := Run([]string{ - "process", - fixturePath("tiny_transcript.json"), - "--glossary", - "../core/schema/testdata/glossary_missing_fields.yaml", - "--report-json", - reportPath, - }, &stdout, &stderr) - if exitCode == 0 { - t.Fatalf("expected nonzero exit code for invalid glossary") - } - if stdout.Len() != 0 { - t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) - } - - // Read and parse the report - reportBytes := readFile(t, reportPath) - var report reporting.ProcessReport - if err := json.Unmarshal(reportBytes, &report); err != nil { - t.Fatalf("failed to parse report JSON: %v (raw: %s)", err, string(reportBytes)) - } - - // Verify failed report structure - if report.Status != "failed" { - t.Fatalf("expected failed status, got %q", report.Status) - } - if report.ErrorPhase != "glossary_schema" { - t.Fatalf("expected error phase 'glossary_schema', got %q", report.ErrorPhase) - } - if report.ErrorMessage == "" { - t.Fatalf("expected error message in failed report") - } -} - -func TestRunProcessRunDirectoryCreation(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[{"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - workDir := filepath.Join(t.TempDir(), "work") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - // Check that run directory was created - runDirs, err := os.ReadDir(workDir) - if err != nil { - t.Fatalf("failed to read work directory: %v", err) - } - if len(runDirs) != 1 { - t.Fatalf("expected 1 run directory, got %d", len(runDirs)) - } - - runPath := filepath.Join(workDir, runDirs[0].Name()) - - // Check that source transcript artifact exists - if _, err := os.Stat(filepath.Join(runPath, "source-transcript.json")); err != nil { - t.Fatalf("expected source transcript artifact: %v", err) - } - - // Check that normalized transcript artifact exists - if _, err := os.Stat(filepath.Join(runPath, "normalized-transcript.json")); err != nil { - t.Fatalf("expected normalized transcript artifact: %v", err) - } - - // Check that normalization summary artifact exists - if _, err := os.Stat(filepath.Join(runPath, "normalization-summary.json")); err != nil { - t.Fatalf("expected normalization summary artifact: %v", err) - } - - // Check that report artifact exists - if _, err := os.Stat(filepath.Join(runPath, "report.json")); err != nil { - t.Fatalf("expected report artifact: %v", err) - } -} - -func TestRunProcessSourceTranscriptArtifact(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[{"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - workDir := filepath.Join(t.TempDir(), "work") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - // Find the run directory - runDirs, err := os.ReadDir(workDir) - if err != nil { - t.Fatalf("failed to read work directory: %v", err) - } - runPath := filepath.Join(workDir, runDirs[0].Name()) - - // Read and verify source transcript artifact - sourceBytes := readFile(t, filepath.Join(runPath, "source-transcript.json")) - if !bytes.Equal(sourceBytes, []byte(transcriptJSON)) { - t.Fatalf("source transcript artifact doesn't match input") - } -} - -func TestRunProcessNormalizedTranscriptArtifact(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[ - {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, - {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - workDir := filepath.Join(t.TempDir(), "work") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - // Find the run directory - runDirs, err := os.ReadDir(workDir) - if err != nil { - t.Fatalf("failed to read work directory: %v", err) - } - runPath := filepath.Join(workDir, runDirs[0].Name()) - - // Read and verify normalized transcript artifact - normalizedBytes := readFile(t, filepath.Join(runPath, "normalized-transcript.json")) - var normalizedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(normalizedBytes); err != nil { - t.Fatalf("failed to parse normalized transcript artifact: %v", err) - } - - // Should be merged (same speaker, small gap) - if len(normalizedTranscript.Segments) != 1 { - t.Fatalf("expected 1 merged segment in normalized artifact, got %d", len(normalizedTranscript.Segments)) - } -} - -func TestRunProcessNormalizationSummaryArtifact(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[ - {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, - {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - workDir := filepath.Join(t.TempDir(), "work") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - // Find the run directory - runDirs, err := os.ReadDir(workDir) - if err != nil { - t.Fatalf("failed to read work directory: %v", err) - } - runPath := filepath.Join(workDir, runDirs[0].Name()) - - // Read and verify normalization summary artifact - summaryBytes := readFile(t, filepath.Join(runPath, "normalization-summary.json")) - var summary normalization.NormalizationSummary - if err := json.Unmarshal(summaryBytes, &summary); err != nil { - t.Fatalf("failed to parse normalization summary: %v", err) - } - - // Verify summary content - if summary.InputSegmentCount != 2 { - t.Fatalf("expected input count 2, got %d", summary.InputSegmentCount) - } - if summary.OutputSegmentCount != 1 { - t.Fatalf("expected output count 1, got %d", summary.OutputSegmentCount) - } - if summary.MergesPerformed != 1 { - t.Fatalf("expected 1 merge, got %d", summary.MergesPerformed) - } -} - -func TestRunProcessReportArtifact(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[{"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - workDir := filepath.Join(t.TempDir(), "work") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - // Find the run directory - runDirs, err := os.ReadDir(workDir) - if err != nil { - t.Fatalf("failed to read work directory: %v", err) - } - runPath := filepath.Join(workDir, runDirs[0].Name()) - - // Verify report artifact exists and is valid - reportBytes := readFile(t, filepath.Join(runPath, "report.json")) - var report reporting.ProcessReport - if err := json.Unmarshal(reportBytes, &report); err != nil { - t.Fatalf("failed to parse report: %v", err) - } - - if report.Status != "success" { - t.Fatalf("expected success status, got %q", report.Status) - } -} - -func TestRunProcessErrorLogOnFailure(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - workDir := filepath.Join(t.TempDir(), "work") - - exitCode := Run([]string{ - "process", - fixturePath("transcript_empty_speaker.json"), - "--glossary", - fixturePath("tiny_glossary.yaml"), - "--work-dir", - workDir, - }, &stdout, &stderr) - if exitCode == 0 { - t.Fatalf("expected nonzero exit code for invalid transcript") - } - - // Find the run directory - runDirs, err := os.ReadDir(workDir) - if err != nil { - t.Fatalf("failed to read work directory: %v", err) - } - if len(runDirs) != 1 { - t.Fatalf("expected 1 run directory on failure, got %d", len(runDirs)) - } - - runPath := filepath.Join(workDir, runDirs[0].Name()) - - // Verify error.log exists - if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil { - t.Fatalf("expected error.log on failure: %v", err) - } - - // Verify error.log contains useful information - errorBytes := readFile(t, filepath.Join(runPath, "error.log")) - if !strings.Contains(string(errorBytes), "transcript_schema") { - t.Fatalf("expected error phase in error.log, got %q", string(errorBytes)) - } -} - -func TestRunProcessFailedRunsRetained(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - workDir := filepath.Join(t.TempDir(), "work") - - exitCode := Run([]string{ - "process", - fixturePath("transcript_empty_speaker.json"), - "--glossary", - fixturePath("tiny_glossary.yaml"), - "--work-dir", - workDir, - "--work-dir-retention", - "never", // Even with "never", failed runs should be retained - }, &stdout, &stderr) - if exitCode == 0 { - t.Fatalf("expected nonzero exit code for invalid transcript") - } - - // Find the run directory - runDirs, err := os.ReadDir(workDir) - if err != nil { - t.Fatalf("failed to read work directory: %v", err) - } - if len(runDirs) != 1 { - t.Fatalf("expected failed run to be retained, got %d run directories", len(runDirs)) - } - - // Failed runs should always be retained regardless of retention policy - runPath := filepath.Join(workDir, runDirs[0].Name()) - if _, err := os.Stat(runPath); err != nil { - t.Fatalf("failed run directory should be retained: %v", err) - } -} - -func TestRunProcessCLIAndNormalizationAgree(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - transcriptJSON := `[ - {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, - {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} - ]` - - transcriptPath := filepath.Join(t.TempDir(), "transcript.json") - glossaryPath := fixturePath("tiny_glossary.yaml") - - if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { - t.Fatalf("failed to create test transcript: %v", err) - } - - exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) - if exitCode != 0 { - t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) - } - - // Parse CLI output - var cliOutput schema.Transcript - if err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil { - t.Fatalf("failed to parse CLI output: %v", err) - } - - // Parse source transcript - var sourceTranscript schema.SourceTranscript - if err := schema.ParseSourceTranscriptJSON([]byte(transcriptJSON)); err != nil { - t.Fatalf("failed to parse source transcript: %v", err) - } - - // Convert to canonical format - canonical := &schema.Transcript{ - Segments: make([]schema.Segment, len(sourceTranscript.Segments)), - } - for i, s := range sourceTranscript.Segments { - id := i + 1 - if s.ID != nil { - id = *s.ID - } - canonical.Segments[i] = schema.Segment{ - ID: id, - Speaker: s.Speaker, - Start: s.Start, - End: s.End, - Text: s.Text, - Categories: s.Categories, - } - } - - // Apply normalization using pure package - normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, - }) - - packageOutput, _ := normalizer.Normalize(canonical) - - // Compare CLI and package outputs - assertTranscriptsEqual(t, &cliOutput, packageOutput) -} - -func assertTranscriptsEqual(t *testing.T, actual, expected *schema.Transcript) { +func onlyRunDir(t *testing.T, workDir string) string { t.Helper() - - if len(actual.Segments) != len(expected.Segments) { - t.Errorf("segment count mismatch: expected %d, got %d", len(expected.Segments), len(actual.Segments)) - return + entries, err := os.ReadDir(workDir) + if err != nil { + t.Fatalf("failed to read work dir %q: %v", workDir, err) } - - for i := range actual.Segments { - a := actual.Segments[i] - e := expected.Segments[i] - - if a.ID != e.ID { - t.Errorf("segment %d: ID mismatch: expected %d, got %d", i, e.ID, a.ID) - } - if a.Speaker != e.Speaker { - t.Errorf("segment %d: speaker mismatch: expected %q, got %q", i, e.Speaker, a.Speaker) - } - if a.Start != e.Start { - t.Errorf("segment %d: start mismatch: expected %f, got %f", i, e.Start, a.Start) - } - if a.End != e.End { - t.Errorf("segment %d: end mismatch: expected %f, got %f", i, e.End, a.End) - } - if a.Text != e.Text { - t.Errorf("segment %d: text mismatch: expected %q, got %q", i, e.Text, a.Text) - } - if len(a.Categories) != len(e.Categories) { - t.Errorf("segment %d: categories count mismatch: expected %d, got %d", i, len(e.Categories), len(a.Categories)) - continue - } - for j, cat := range a.Categories { - if j >= len(e.Categories) || cat != e.Categories[j] { - t.Errorf("segment %d: category %d mismatch: expected %q, got %q", i, j, e.Categories[j], cat) - } - } + if len(entries) != 1 { + t.Fatalf("expected exactly one run dir in %q, got %d", workDir, len(entries)) } + return filepath.Join(workDir, entries[0].Name()) } diff --git a/internal/cli/testdata/tiny_glossary.yaml b/internal/cli/testdata/tiny_glossary.yaml index 77e6563..c660b40 100644 --- a/internal/cli/testdata/tiny_glossary.yaml +++ b/internal/cli/testdata/tiny_glossary.yaml @@ -1,3 +1,6 @@ -terms: - - source: audita - target: Audita +glossary: + - name: Audita + aliases: + - audita + category: product + summary: The Audita transcript correction CLI. diff --git a/internal/core/diagnostics/run_dir.go b/internal/core/diagnostics/run_dir.go index 277aeb2..3a7e1f7 100644 --- a/internal/core/diagnostics/run_dir.go +++ b/internal/core/diagnostics/run_dir.go @@ -14,10 +14,9 @@ import ( // RunDirectory represents a per-run diagnostics directory type RunDirectory struct { - path string - retention string - createdAt time.Time - sourceSaved bool + path string + retention string + createdAt time.Time } // NewRunDirectory creates a new run directory under the configured work dir @@ -31,9 +30,8 @@ func NewRunDirectory(workDir, retention string) (*RunDirectory, error) { return nil, fmt.Errorf("failed to create work directory %q: %w", workDir, err) } - // Create unique run directory with timestamp - timestamp := time.Now().UTC().Format("2006-01-02T15-04-05Z") - runID := fmt.Sprintf("run-%s", timestamp) + // Create a unique run directory identifier. + runID := fmt.Sprintf("run-%d", time.Now().UTC().UnixNano()) runPath := filepath.Join(workDir, runID) if err := os.Mkdir(runPath, 0o755); err != nil { @@ -71,7 +69,6 @@ func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript return fmt.Errorf("failed to write parsed source transcript: %w", err) } - r.sourceSaved = true return nil } @@ -122,24 +119,21 @@ func (r *RunDirectory) WriteErrorLog(errorMessage string) error { return os.WriteFile(errorPath, []byte(errorMessage+"\n"), 0o644) } -// ApplyRetention applies the retention policy -// TODO: In later phases, implement full retention logic including: -// - auto mode should keep runs with skipped corrections -// - Add proper cleanup of old runs -// - Consider disk space limits -func (r *RunDirectory) ApplyRetention() error { - // For Phase 2, implement minimal retention: - // - always: keep everything (do nothing) - // - never: remove successful runs - // - auto: remove successful runs (same as never for now) - // - failed runs are always kept (handled by caller) - - if r.retention == "never" || r.retention == "auto" { - // Remove successful runs - if r.sourceSaved { // Simple heuristic: if we saved source, it was successful - return os.RemoveAll(r.path) - } +// ApplyRetention applies a minimal phase-2 retention policy. +// TODO: In later phases, implement full "auto" semantics based on skip/report outcomes. +func (r *RunDirectory) ApplyRetention(runSucceeded bool) error { + if !runSucceeded { + // Failed runs are always retained. + return nil + } + + switch r.retention { + case "always": + return nil + case "never", "auto": + return os.RemoveAll(r.path) + default: + // Retain by default for unknown modes; config validation should prevent this. + return nil } - // always: keep everything, or failed runs: keep everything - return nil } diff --git a/internal/core/normalization/normalize.go b/internal/core/normalization/normalize.go index 392b0c5..899866a 100644 --- a/internal/core/normalization/normalize.go +++ b/internal/core/normalization/normalize.go @@ -2,7 +2,6 @@ package normalization import ( "sort" - "strings" "gitea.maximumdirect.net/eric/audita/internal/core/schema" ) @@ -63,13 +62,11 @@ func (n *NormalizeTranscript) Normalize(transcript *schema.Transcript) (*schema. // Merge same-speaker adjacent segments var normalizedSegments []schema.Segment var currentSegment schema.Segment - var sourceIDs []int for i, segment := range sortedSegments { if i == 0 { // Initialize with first segment currentSegment = segment - sourceIDs = []int{segment.ID} continue } @@ -79,7 +76,6 @@ func (n *NormalizeTranscript) Normalize(transcript *schema.Transcript) (*schema. if merge { summary.MergesPerformed++ currentSegment = mergeSegments(¤tSegment, &segment, gap, n.config.EllipsisGap) - sourceIDs = append(sourceIDs, segment.ID) } else { // Track the reason for not merging switch reason { @@ -95,7 +91,6 @@ func (n *NormalizeTranscript) Normalize(transcript *schema.Transcript) (*schema. // Finalize current segment and start new one normalizedSegments = append(normalizedSegments, currentSegment) currentSegment = segment - sourceIDs = []int{segment.ID} } } @@ -155,6 +150,37 @@ func shouldMerge(config NormalizationConfig, estimator *SimpleTokenEstimator, return true } +func shouldMergeWithReason(config NormalizationConfig, estimator *SimpleTokenEstimator, + current, next *schema.Segment, gap float64) (bool, string) { + + if current.Speaker != next.Speaker { + return false, "different_speakers" + } + if gap > config.MaxSegmentGap { + return false, "gap_too_large" + } + + mergedText := current.Text + if gap >= config.EllipsisGap { + mergedText += "... " + } else { + mergedText += " " + } + mergedText += next.Text + + mergedDuration := next.End - current.Start + if mergedDuration > config.MaxSegmentDuration { + return false, "duration_exceeded" + } + + tokenEstimate := estimator.EstimateTokens(mergedText) + if tokenEstimate > config.MaxSegmentTokens { + return false, "token_limit_exceeded" + } + + return true, "" +} + func mergeSegments(current, next *schema.Segment, gap, ellipsisGap float64) schema.Segment { mergedText := current.Text if gap >= ellipsisGap { diff --git a/internal/core/normalization/normalize_test.go b/internal/core/normalization/normalize_test.go index a938db5..8add010 100644 --- a/internal/core/normalization/normalize_test.go +++ b/internal/core/normalization/normalize_test.go @@ -1,6 +1,8 @@ package normalization import ( + "os" + "strings" "testing" "gitea.maximumdirect.net/eric/audita/internal/core/schema" @@ -155,7 +157,7 @@ func TestNormalizationNoMergeWhenTokenLimitExceeded(t *testing.T) { MaxSegmentGap: 2.0, EllipsisGap: 1.0, MaxSegmentDuration: 60.0, - MaxSegmentTokens: 3, // Very small token limit + MaxSegmentTokens: 1, // Very small token limit } normalizer := NewNormalizer(config) @@ -273,10 +275,10 @@ func TestNormalizationCategoryPreservation(t *testing.T) { func TestNormalizationGoldenBareArrayTranscript(t *testing.T) { config := NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, + MaxSegmentGap: 2.0, + EllipsisGap: 1.0, + MaxSegmentDuration: 60.0, + MaxSegmentTokens: 100, } normalizer := NewNormalizer(config) @@ -316,20 +318,20 @@ func TestNormalizationGoldenBareArrayTranscript(t *testing.T) { t.Fatalf("failed to read golden fixture: %v", err) } - var expectedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(expectedRaw); err != nil { + expectedTranscript, err := schema.ParseTranscriptJSON(expectedRaw) + if err != nil { t.Fatalf("failed to parse golden transcript: %v", err) } - assertTranscriptsEqual(t, normalized, &expectedTranscript) + assertTranscriptsEqual(t, normalized, expectedTranscript) } func TestNormalizationGoldenObjectWithSegmentsTranscript(t *testing.T) { config := NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, + MaxSegmentGap: 2.0, + EllipsisGap: 1.0, + MaxSegmentDuration: 60.0, + MaxSegmentTokens: 100, } normalizer := NewNormalizer(config) @@ -369,20 +371,20 @@ func TestNormalizationGoldenObjectWithSegmentsTranscript(t *testing.T) { t.Fatalf("failed to read golden fixture: %v", err) } - var expectedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(expectedRaw); err != nil { + expectedTranscript, err := schema.ParseTranscriptJSON(expectedRaw) + if err != nil { t.Fatalf("failed to parse golden transcript: %v", err) } - assertTranscriptsEqual(t, normalized, &expectedTranscript) + assertTranscriptsEqual(t, normalized, expectedTranscript) } func TestNormalizationGoldenTranscriptWithCategories(t *testing.T) { config := NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, + MaxSegmentGap: 2.0, + EllipsisGap: 1.0, + MaxSegmentDuration: 60.0, + MaxSegmentTokens: 100, } normalizer := NewNormalizer(config) @@ -422,20 +424,20 @@ func TestNormalizationGoldenTranscriptWithCategories(t *testing.T) { t.Fatalf("failed to read golden fixture: %v", err) } - var expectedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(expectedRaw); err != nil { + expectedTranscript, err := schema.ParseTranscriptJSON(expectedRaw) + if err != nil { t.Fatalf("failed to parse golden transcript: %v", err) } - assertTranscriptsEqual(t, normalized, &expectedTranscript) + assertTranscriptsEqual(t, normalized, expectedTranscript) } func TestNormalizationGoldenTranscriptWithOriginalIDs(t *testing.T) { config := NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, + MaxSegmentGap: 2.0, + EllipsisGap: 1.0, + MaxSegmentDuration: 60.0, + MaxSegmentTokens: 100, } normalizer := NewNormalizer(config) @@ -475,20 +477,20 @@ func TestNormalizationGoldenTranscriptWithOriginalIDs(t *testing.T) { t.Fatalf("failed to read golden fixture: %v", err) } - var expectedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(expectedRaw); err != nil { + expectedTranscript, err := schema.ParseTranscriptJSON(expectedRaw) + if err != nil { t.Fatalf("failed to parse golden transcript: %v", err) } - assertTranscriptsEqual(t, normalized, &expectedTranscript) + assertTranscriptsEqual(t, normalized, expectedTranscript) } func TestNormalizationGoldenSameSpeakerMerge(t *testing.T) { config := NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, + MaxSegmentGap: 2.0, + EllipsisGap: 1.0, + MaxSegmentDuration: 60.0, + MaxSegmentTokens: 100, } normalizer := NewNormalizer(config) @@ -528,20 +530,20 @@ func TestNormalizationGoldenSameSpeakerMerge(t *testing.T) { t.Fatalf("failed to read golden fixture: %v", err) } - var expectedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(expectedRaw); err != nil { + expectedTranscript, err := schema.ParseTranscriptJSON(expectedRaw) + if err != nil { t.Fatalf("failed to parse golden transcript: %v", err) } - assertTranscriptsEqual(t, normalized, &expectedTranscript) + assertTranscriptsEqual(t, normalized, expectedTranscript) } func TestNormalizationGoldenEllipsisMerge(t *testing.T) { config := NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, + MaxSegmentGap: 2.0, + EllipsisGap: 1.0, + MaxSegmentDuration: 60.0, + MaxSegmentTokens: 100, } normalizer := NewNormalizer(config) @@ -581,20 +583,20 @@ func TestNormalizationGoldenEllipsisMerge(t *testing.T) { t.Fatalf("failed to read golden fixture: %v", err) } - var expectedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(expectedRaw); err != nil { + expectedTranscript, err := schema.ParseTranscriptJSON(expectedRaw) + if err != nil { t.Fatalf("failed to parse golden transcript: %v", err) } - assertTranscriptsEqual(t, normalized, &expectedTranscript) + assertTranscriptsEqual(t, normalized, expectedTranscript) } func TestNormalizationGoldenDifferentSpeakersNoMerge(t *testing.T) { config := NormalizationConfig{ - MaxSegmentGap: 2.0, - EllipsisGap: 1.0, - MaxSegmentDuration: 60.0, - MaxSegmentTokens: 100, + MaxSegmentGap: 2.0, + EllipsisGap: 1.0, + MaxSegmentDuration: 60.0, + MaxSegmentTokens: 100, } normalizer := NewNormalizer(config) @@ -634,12 +636,12 @@ func TestNormalizationGoldenDifferentSpeakersNoMerge(t *testing.T) { t.Fatalf("failed to read golden fixture: %v", err) } - var expectedTranscript schema.Transcript - if err := schema.ParseTranscriptJSON(expectedRaw); err != nil { + expectedTranscript, err := schema.ParseTranscriptJSON(expectedRaw) + if err != nil { t.Fatalf("failed to parse golden transcript: %v", err) } - assertTranscriptsEqual(t, normalized, &expectedTranscript) + assertTranscriptsEqual(t, normalized, expectedTranscript) } func assertTranscriptsEqual(t *testing.T, actual, expected *schema.Transcript) { @@ -679,33 +681,4 @@ func assertTranscriptsEqual(t *testing.T, actual, expected *schema.Transcript) { } } } -} - normalizer := NewNormalizer(config) - - transcript := &schema.Transcript{ - Segments: []schema.Segment{ - {ID: 1, Speaker: "Alice", Start: 0.0, End: 1.0, Text: "Hello"}, - {ID: 2, Speaker: "Alice", Start: 1.5, End: 2.5, Text: "world"}, - {ID: 3, Speaker: "Bob", Start: 3.0, End: 4.0, Text: "Hi"}, - }, - } - - _, summary := normalizer.Normalize(transcript) - - // Verify summary counts - if summary.InputSegmentCount != 3 { - t.Errorf("expected input count 3, got %d", summary.InputSegmentCount) - } - if summary.OutputSegmentCount != 2 { - t.Errorf("expected output count 2, got %d", summary.OutputSegmentCount) - } - if summary.MergesPerformed != 1 { - t.Errorf("expected 1 merge, got %d", summary.MergesPerformed) - } - if summary.IDsReassigned != 3 { - t.Errorf("expected 3 IDs reassigned, got %d", summary.IDsReassigned) - } - if summary.SkippedMerges.DifferentSpeakers != 1 { - t.Errorf("expected 1 skipped merge for different speakers, got %d", summary.SkippedMerges.DifferentSpeakers) - } } diff --git a/internal/core/normalization/testdata/transcript_same_speaker_merge.golden.json b/internal/core/normalization/testdata/transcript_same_speaker_merge.golden.json index 7991765..b5ce4fa 100644 --- a/internal/core/normalization/testdata/transcript_same_speaker_merge.golden.json +++ b/internal/core/normalization/testdata/transcript_same_speaker_merge.golden.json @@ -3,7 +3,7 @@ "id": 1, "speaker": "Alice", "start": 0.0, - "end": 2.5, + "end": 2.2, "text": "Hello world" } -] \ No newline at end of file +] diff --git a/internal/core/schema/glossary_test.go b/internal/core/schema/glossary_test.go index b94ff3a..bd35100 100644 --- a/internal/core/schema/glossary_test.go +++ b/internal/core/schema/glossary_test.go @@ -54,8 +54,8 @@ func TestParseGlossaryYAML_MalformedYAML(t *testing.T) { t.Fatal("expected error for malformed YAML, got nil") } - var parseErr *ParseError - if parseErr, ok := err.(*ParseError); !ok { + parseErr, ok := err.(*ParseError) + if !ok { t.Errorf("expected *ParseError, got %T", err) } else if parseErr.Message == "" { t.Error("expected non-empty parse error message") @@ -73,8 +73,8 @@ func TestParseGlossaryYAML_MissingRequiredFields(t *testing.T) { t.Fatal("expected error for missing required fields, got nil") } - var valErr *ValidationError - if valErr, ok := err.(*ValidationError); !ok { + valErr, ok := err.(*ValidationError) + if !ok { t.Errorf("expected *ValidationError, got %T", err) } else if valErr.Field == "" { t.Error("expected non-empty validation error field") @@ -89,8 +89,8 @@ func TestParseGlossaryYAML_EmptyGlossary(t *testing.T) { t.Fatal("expected error for empty glossary, got nil") } - var valErr *ValidationError - if valErr, ok := err.(*ValidationError); !ok { + valErr, ok := err.(*ValidationError) + if !ok { t.Errorf("expected *ValidationError, got %T", err) } else if valErr.Message != "must contain at least one entry" { t.Errorf("expected 'must contain at least one entry', got %q", valErr.Message) @@ -110,8 +110,8 @@ glossary: t.Fatal("expected error for empty name, got nil") } - var valErr *ValidationError - if valErr, ok := err.(*ValidationError); !ok { + valErr, ok := err.(*ValidationError) + if !ok { t.Errorf("expected *ValidationError, got %T", err) } else if valErr.Message != "must not be empty" { t.Errorf("expected 'must not be empty', got %q", valErr.Message) @@ -133,8 +133,8 @@ glossary: t.Fatal("expected error for empty alias, got nil") } - var valErr *ValidationError - if valErr, ok := err.(*ValidationError); !ok { + valErr, ok := err.(*ValidationError) + if !ok { t.Errorf("expected *ValidationError, got %T", err) } else if valErr.Message != "must not be empty" { t.Errorf("expected 'must not be empty', got %q", valErr.Message) diff --git a/internal/core/schema/transcript.go b/internal/core/schema/transcript.go index 7c59926..7b7e65c 100644 --- a/internal/core/schema/transcript.go +++ b/internal/core/schema/transcript.go @@ -47,14 +47,13 @@ func ParseSourceTranscriptJSON(raw []byte) (*SourceTranscript, error) { return nil, err } - if len(segmentsRaw) == 0 { - return nil, &ParseError{Message: "transcript must contain at least one segment"} - } - var segments []SourceSegment if err := json.Unmarshal(segmentsRaw, &segments); err != nil { return nil, &ParseError{Message: fmt.Sprintf("failed to parse segments: %v", err)} } + if len(segments) == 0 { + return nil, &ParseError{Message: "transcript must contain at least one segment"} + } if err := validateSourceSegments(segments); err != nil { return nil, err diff --git a/internal/core/schema/transcript_test.go b/internal/core/schema/transcript_test.go index 34745ea..9647e1b 100644 --- a/internal/core/schema/transcript_test.go +++ b/internal/core/schema/transcript_test.go @@ -80,8 +80,8 @@ func TestParseSourceTranscriptJSON_MalformedJSON(t *testing.T) { t.Fatal("expected error for malformed JSON, got nil") } - var parseErr *ParseError - if parseErr, ok := err.(*ParseError); !ok { + parseErr, ok := err.(*ParseError) + if !ok { t.Errorf("expected *ParseError, got %T", err) } else if parseErr.Message != "transcript is not valid JSON" { t.Errorf("expected 'transcript is not valid JSON', got %q", parseErr.Message) @@ -155,8 +155,8 @@ func TestParseSourceTranscriptJSON_EmptyArray(t *testing.T) { t.Fatal("expected error for empty array, got nil") } - var parseErr *ParseError - if parseErr, ok := err.(*ParseError); !ok { + parseErr, ok := err.(*ParseError) + if !ok { t.Errorf("expected *ParseError, got %T", err) } else if parseErr.Message != "transcript must contain at least one segment" { t.Errorf("expected 'transcript must contain at least one segment', got %q", parseErr.Message) @@ -208,8 +208,8 @@ func TestParseSourceTranscriptJSON_UnsupportedTopLevelShape(t *testing.T) { t.Fatal("expected error for unsupported top-level shape, got nil") } - var parseErr *ParseError - if parseErr, ok := err.(*ParseError); !ok { + parseErr, ok := err.(*ParseError) + if !ok { t.Errorf("expected *ParseError, got %T", err) } else if parseErr.Message != "transcript must be a JSON array or an object with a segments array" { t.Errorf("expected unsupported shape message, got %q", parseErr.Message) @@ -224,8 +224,8 @@ func TestParseSourceTranscriptJSON_ObjectWithoutSegments(t *testing.T) { t.Fatal("expected error for object without segments, got nil") } - var parseErr *ParseError - if parseErr, ok := err.(*ParseError); !ok { + parseErr, ok := err.(*ParseError) + if !ok { t.Errorf("expected *ParseError, got %T", err) } else if parseErr.Message != "transcript object must contain a segments array" { t.Errorf("expected 'transcript object must contain a segments array', got %q", parseErr.Message) @@ -265,8 +265,8 @@ func TestTranscriptToJSON(t *testing.T) { func assertValidationError(t *testing.T, err error, fieldContains, messageContains string) { t.Helper() - var valErr *ValidationError - if valErr, ok := err.(*ValidationError); !ok { + valErr, ok := err.(*ValidationError) + if !ok { t.Errorf("expected *ValidationError, got %T", err) return } @@ -282,8 +282,8 @@ func assertValidationError(t *testing.T, err error, fieldContains, messageContai func assertValidationErrorContains(t *testing.T, err error, fieldContains, messageContains string) { t.Helper() - var valErr *ValidationError - if valErr, ok := err.(*ValidationError); !ok { + valErr, ok := err.(*ValidationError) + if !ok { t.Errorf("expected *ValidationError, got %T", err) return }