From 79eb5cd9b1a54bdf3b137fba21c25dd2f5d9b00c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 8 May 2026 17:01:18 +0000 Subject: [PATCH] Add transcript bounds validation helpers --- internal/contracts/bounds.go | 170 +++++++++++++++++++++++++ internal/contracts/bounds_test.go | 199 ++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 internal/contracts/bounds.go create mode 100644 internal/contracts/bounds_test.go diff --git a/internal/contracts/bounds.go b/internal/contracts/bounds.go new file mode 100644 index 0000000..c54d515 --- /dev/null +++ b/internal/contracts/bounds.go @@ -0,0 +1,170 @@ +package contracts + +import ( + "encoding/json" + "fmt" + "math" + "os" + "strconv" + "strings" +) + +const ( + // BoundsTrimActionTrim keeps only a selected segment range. + BoundsTrimActionTrim = "trim" + // BoundsTrimActionNone indicates no trimming should be applied. + BoundsTrimActionNone = "none" + // BoundsTrimActionCopy indicates the transcript should be copied unchanged. + BoundsTrimActionCopy = "copy" +) + +// SessionBounds is the expected bounds-output contract from Scriptorium. +// Unknown JSON fields are tolerated to align with existing transcript/output parsing policy. +type SessionBounds struct { + Confidence string `json:"confidence"` + TrimAction string `json:"trim_action"` + StartSegmentID *int `json:"start_segment_id"` + EndSegmentID *int `json:"end_segment_id"` + StartBoundaryReason string `json:"start_boundary_reason"` + EndBoundaryReason string `json:"end_boundary_reason"` + ExcludedPrefixSummary string `json:"excluded_prefix_summary"` + ExcludedSuffixSummary string `json:"excluded_suffix_summary"` + Warnings []any `json:"warnings"` + StartEvidence []any `json:"start_evidence"` + EndEvidence []any `json:"end_evidence"` +} + +// ParseSessionBoundsFile reads and parses one Scriptorium bounds output JSON file. +func ParseSessionBoundsFile(path string) (SessionBounds, error) { + data, err := os.ReadFile(path) + if err != nil { + return SessionBounds{}, fmt.Errorf("read bounds file: %w", err) + } + var bounds SessionBounds + if err := json.Unmarshal(data, &bounds); err != nil { + return SessionBounds{}, fmt.Errorf("parse bounds json: %w", err) + } + return bounds, nil +} + +// ValidateSessionBoundsAgainstTranscript validates bounds against transcript segment ID space. +func ValidateSessionBoundsAgainstTranscript(bounds SessionBounds, transcriptPath string) error { + action, err := normalizeBoundsTrimAction(bounds.TrimAction) + if err != nil { + return err + } + + segmentIDs, err := loadTranscriptSegmentIDs(transcriptPath) + if err != nil { + return err + } + + if action == BoundsTrimActionTrim { + if bounds.StartSegmentID == nil { + return fmt.Errorf("bounds.start_segment_id is required for trim action") + } + if bounds.EndSegmentID == nil { + return fmt.Errorf("bounds.end_segment_id is required for trim action") + } + if *bounds.StartSegmentID > *bounds.EndSegmentID { + return fmt.Errorf("bounds.start_segment_id must be <= bounds.end_segment_id") + } + if _, ok := segmentIDs[*bounds.StartSegmentID]; !ok { + return fmt.Errorf("bounds.start_segment_id %d does not exist in transcript segments", *bounds.StartSegmentID) + } + if _, ok := segmentIDs[*bounds.EndSegmentID]; !ok { + return fmt.Errorf("bounds.end_segment_id %d does not exist in transcript segments", *bounds.EndSegmentID) + } + } + + return nil +} + +// BuildSeriatimKeepSelector converts validated bounds into Seriatim keep selector semantics. +// It returns selector="", copyUnchanged=true for no-trim actions. +func BuildSeriatimKeepSelector(bounds SessionBounds) (selector string, copyUnchanged bool, err error) { + action, err := normalizeBoundsTrimAction(bounds.TrimAction) + if err != nil { + return "", false, err + } + + if action == BoundsTrimActionTrim { + if bounds.StartSegmentID == nil { + return "", false, fmt.Errorf("bounds.start_segment_id is required for trim action") + } + if bounds.EndSegmentID == nil { + return "", false, fmt.Errorf("bounds.end_segment_id is required for trim action") + } + if *bounds.StartSegmentID > *bounds.EndSegmentID { + return "", false, fmt.Errorf("bounds.start_segment_id must be <= bounds.end_segment_id") + } + return strconv.Itoa(*bounds.StartSegmentID) + "-" + strconv.Itoa(*bounds.EndSegmentID), false, nil + } + + return "", true, nil +} + +func normalizeBoundsTrimAction(raw string) (string, error) { + action := strings.ToLower(strings.TrimSpace(raw)) + switch action { + case "", BoundsTrimActionTrim: + return BoundsTrimActionTrim, nil + case BoundsTrimActionNone: + return BoundsTrimActionNone, nil + case BoundsTrimActionCopy: + return BoundsTrimActionCopy, nil + default: + return "", fmt.Errorf("unsupported bounds.trim_action %q", raw) + } +} + +func loadTranscriptSegmentIDs(path string) (map[int]struct{}, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read transcript file: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("transcript json parse failed: %w", err) + } + + segmentsAny, ok := payload["segments"] + if !ok { + return nil, fmt.Errorf("transcript top-level segments array is required") + } + segments, ok := segmentsAny.([]any) + if !ok { + return nil, fmt.Errorf("transcript top-level segments must be an array") + } + + ids := make(map[int]struct{}, len(segments)) + for i, segmentAny := range segments { + segment, ok := segmentAny.(map[string]any) + if !ok { + return nil, fmt.Errorf("transcript segments[%d] must be an object", i) + } + idAny, exists := segment["id"] + if !exists { + continue + } + id, err := parseJSONSegmentID(idAny) + if err != nil { + return nil, fmt.Errorf("transcript segments[%d].id invalid: %w", i, err) + } + ids[id] = struct{}{} + } + + return ids, nil +} + +func parseJSONSegmentID(v any) (int, error) { + n, ok := v.(float64) + if !ok { + return 0, fmt.Errorf("must be numeric") + } + if math.Trunc(n) != n { + return 0, fmt.Errorf("must be an integer") + } + return int(n), nil +} diff --git a/internal/contracts/bounds_test.go b/internal/contracts/bounds_test.go new file mode 100644 index 0000000..5edc729 --- /dev/null +++ b/internal/contracts/bounds_test.go @@ -0,0 +1,199 @@ +package contracts + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestValidBoundsOutputProducesKeepSelector(t *testing.T) { + dir := t.TempDir() + boundsPath := filepath.Join(dir, "bounds.json") + transcriptPath := filepath.Join(dir, "processed.json") + + writeBoundsTestFile(t, boundsPath, `{ + "confidence":"high", + "trim_action":"trim", + "start_segment_id":10, + "end_segment_id":868, + "warnings":[] +}`) + writeBoundsTestFile(t, transcriptPath, `{ + "segments":[ + {"id":10,"text":"start"}, + {"id":868,"text":"end"} + ] +}`) + + bounds, err := ParseSessionBoundsFile(boundsPath) + if err != nil { + t.Fatalf("ParseSessionBoundsFile() error = %v", err) + } + if err := ValidateSessionBoundsAgainstTranscript(bounds, transcriptPath); err != nil { + t.Fatalf("ValidateSessionBoundsAgainstTranscript() error = %v", err) + } + selector, copyUnchanged, err := BuildSeriatimKeepSelector(bounds) + if err != nil { + t.Fatalf("BuildSeriatimKeepSelector() error = %v", err) + } + if copyUnchanged { + t.Fatal("copyUnchanged = true, want false for trim action") + } + if selector != "10-868" { + t.Fatalf("selector = %q, want %q", selector, "10-868") + } +} + +func TestBoundsMissingStartSegmentIDFails(t *testing.T) { + bounds := SessionBounds{TrimAction: "trim", EndSegmentID: intPtr(5)} + err := ValidateSessionBoundsAgainstTranscript(bounds, writeTranscriptWithIDs(t, 1, 5)) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "start_segment_id") { + t.Fatalf("error = %q, want start_segment_id context", err.Error()) + } +} + +func TestBoundsMissingEndSegmentIDFails(t *testing.T) { + bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(1)} + err := ValidateSessionBoundsAgainstTranscript(bounds, writeTranscriptWithIDs(t, 1, 5)) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "end_segment_id") { + t.Fatalf("error = %q, want end_segment_id context", err.Error()) + } +} + +func TestBoundsDescendingRangeFails(t *testing.T) { + bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(9), EndSegmentID: intPtr(5)} + err := ValidateSessionBoundsAgainstTranscript(bounds, writeTranscriptWithIDs(t, 5, 9)) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "<=") { + t.Fatalf("error = %q, want range ordering context", err.Error()) + } +} + +func TestBoundsNonExistentStartIDFails(t *testing.T) { + bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(3), EndSegmentID: intPtr(9)} + err := ValidateSessionBoundsAgainstTranscript(bounds, writeTranscriptWithIDs(t, 4, 9)) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "start_segment_id 3 does not exist") { + t.Fatalf("error = %q, want missing start id context", err.Error()) + } +} + +func TestBoundsNonExistentEndIDFails(t *testing.T) { + bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(3), EndSegmentID: intPtr(9)} + err := ValidateSessionBoundsAgainstTranscript(bounds, writeTranscriptWithIDs(t, 3, 8)) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "end_segment_id 9 does not exist") { + t.Fatalf("error = %q, want missing end id context", err.Error()) + } +} + +func TestInvalidTranscriptJSONFails(t *testing.T) { + bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(1), EndSegmentID: intPtr(2)} + path := filepath.Join(t.TempDir(), "processed.json") + writeBoundsTestFile(t, path, "not-json") + + err := ValidateSessionBoundsAgainstTranscript(bounds, path) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "transcript json parse failed") { + t.Fatalf("error = %q, want transcript json parse context", err.Error()) + } +} + +func TestTranscriptWithoutSegmentsFails(t *testing.T) { + bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(1), EndSegmentID: intPtr(2)} + path := filepath.Join(t.TempDir(), "processed.json") + writeBoundsTestFile(t, path, `{"schema":"audita.processed.v1"}`) + + err := ValidateSessionBoundsAgainstTranscript(bounds, path) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "segments array is required") { + t.Fatalf("error = %q, want segments required context", err.Error()) + } +} + +func TestWarningsArePreservedInParsedOutput(t *testing.T) { + path := filepath.Join(t.TempDir(), "bounds.json") + writeBoundsTestFile(t, path, `{ + "confidence":"medium", + "trim_action":"trim", + "start_segment_id":10, + "end_segment_id":12, + "warnings":["weak opening marker","possible flashback"] +}`) + + bounds, err := ParseSessionBoundsFile(path) + if err != nil { + t.Fatalf("ParseSessionBoundsFile() error = %v", err) + } + if len(bounds.Warnings) != 2 { + t.Fatalf("warnings len = %d, want 2", len(bounds.Warnings)) + } + if bounds.Warnings[0] != "weak opening marker" || bounds.Warnings[1] != "possible flashback" { + t.Fatalf("warnings = %#v, want preserved warnings", bounds.Warnings) + } +} + +func TestNoTrimActionCopyIsSupported(t *testing.T) { + bounds := SessionBounds{TrimAction: "copy"} + if err := ValidateSessionBoundsAgainstTranscript(bounds, writeTranscriptWithIDs(t, 1)); err != nil { + t.Fatalf("ValidateSessionBoundsAgainstTranscript() error = %v", err) + } + selector, copyUnchanged, err := BuildSeriatimKeepSelector(bounds) + if err != nil { + t.Fatalf("BuildSeriatimKeepSelector() error = %v", err) + } + if selector != "" { + t.Fatalf("selector = %q, want empty for copy action", selector) + } + if !copyUnchanged { + t.Fatal("copyUnchanged = false, want true for copy action") + } +} + +func writeTranscriptWithIDs(t *testing.T, ids ...int) string { + t.Helper() + path := filepath.Join(t.TempDir(), "processed.json") + if len(ids) == 0 { + writeBoundsTestFile(t, path, `{"segments":[]}`) + return path + } + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, `{"id":`+strconv.Itoa(id)+`}`) + } + writeBoundsTestFile(t, path, `{"segments":[`+strings.Join(parts, ",")+`]}`) + return path +} + +func writeBoundsTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", path, err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } +} + +func intPtr(v int) *int { + value := v + return &value +}