diff --git a/internal/framework/proposals/apply_fixtures_test.go b/internal/framework/proposals/apply_fixtures_test.go new file mode 100644 index 0000000..d6568da --- /dev/null +++ b/internal/framework/proposals/apply_fixtures_test.go @@ -0,0 +1,172 @@ +package proposals + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/schema" +) + +type applyFixture struct { + Policy string `json:"policy"` + Transcript schema.Transcript `json:"transcript"` + Proposals []EnrichedCorrectionProposal `json:"proposals"` +} + +type recordsPayload struct { + AppliedChanges []AppliedChange `json:"applied_changes"` + SkippedChanges []SkippedChange `json:"skipped_changes"` +} + +func TestApplyProposalsFixtureTranscriptGoldens(t *testing.T) { + testCases := []string{ + "simple_replacement", + "multiple_replacements_one_segment", + "multiple_proposals_one_segment", + "replace_all_behavior", + "categories_metadata_preservation", + } + + for _, name := range testCases { + t.Run(name, func(t *testing.T) { + fixture := readApplyFixture(t, name+".input.json") + policy, err := ParseReplacementPolicy(fixture.Policy) + if err != nil { + t.Fatalf("failed to parse fixture policy: %v", err) + } + + result := ApplyProposals(&fixture.Transcript, fixture.Proposals, policy) + assertJSONSemanticallyEqualFromGolden(t, result.Transcript, name+".transcript.golden.json") + }) + } +} + +func TestApplyProposalsFixtureSkipCoverage(t *testing.T) { + testCases := []struct { + name string + expectedSkipReason ProposalSkipReason + }{ + {name: "stale_after_earlier_replacement", expectedSkipReason: SkipReasonMissingOriginalText}, + {name: "ambiguous_require_unique", expectedSkipReason: SkipReasonAmbiguousOriginal}, + {name: "missing_segment", expectedSkipReason: SkipReasonMissingSegment}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fixture := readApplyFixture(t, tc.name+".input.json") + policy, err := ParseReplacementPolicy(fixture.Policy) + if err != nil { + t.Fatalf("failed to parse fixture policy: %v", err) + } + + result := ApplyProposals(&fixture.Transcript, fixture.Proposals, policy) + if len(result.Skipped) == 0 { + t.Fatal("expected at least one skipped change") + } + + if got := result.Skipped[0].SkipReason; got != tc.expectedSkipReason { + t.Fatalf("unexpected skip reason: got %q want %q", got, tc.expectedSkipReason) + } + }) + } +} + +func TestApplyProposalsFixtureMixedScenarioRecordGoldens(t *testing.T) { + fixture := readApplyFixture(t, "mixed_scenarios.input.json") + policy, err := ParseReplacementPolicy(fixture.Policy) + if err != nil { + t.Fatalf("failed to parse fixture policy: %v", err) + } + + result := ApplyProposals(&fixture.Transcript, fixture.Proposals, policy) + payload := recordsPayload{ + AppliedChanges: result.Applied, + SkippedChanges: result.Skipped, + } + + // Records must be JSON-serializable for diagnostics/reporting. + if _, err := json.Marshal(payload.AppliedChanges); err != nil { + t.Fatalf("failed to marshal applied changes: %v", err) + } + if _, err := json.Marshal(payload.SkippedChanges); err != nil { + t.Fatalf("failed to marshal skipped changes: %v", err) + } + if _, err := json.Marshal(payload); err != nil { + t.Fatalf("failed to marshal combined records payload: %v", err) + } + + for i, skipped := range payload.SkippedChanges { + if !isStableSkipReason(skipped.SkipReason) { + t.Fatalf("unstable skip reason at skipped[%d]: %q", i, skipped.SkipReason) + } + } + + assertJSONSemanticallyEqualFromGolden(t, payload, "mixed_scenarios.records.golden.json") +} + +func readApplyFixture(t *testing.T, fileName string) applyFixture { + t.Helper() + path := filepath.Join("testdata", fileName) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read fixture %s: %v", path, err) + } + + var fixture applyFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("failed to parse fixture %s: %v", path, err) + } + + return fixture +} + +func assertJSONSemanticallyEqualFromGolden(t *testing.T, actual any, goldenFileName string) { + t.Helper() + goldenPath := filepath.Join("testdata", goldenFileName) + expectedRaw, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("failed to read golden file %s: %v", goldenPath, err) + } + + assertJSONSemanticallyEqual(t, actual, expectedRaw) +} + +func assertJSONSemanticallyEqual(t *testing.T, actual any, expectedJSON []byte) { + t.Helper() + + actualRaw, err := json.Marshal(actual) + if err != nil { + t.Fatalf("failed to marshal actual JSON: %v", err) + } + + var actualValue any + if err := json.Unmarshal(actualRaw, &actualValue); err != nil { + t.Fatalf("failed to parse marshaled actual JSON: %v", err) + } + + var expectedValue any + if err := json.Unmarshal(expectedJSON, &expectedValue); err != nil { + t.Fatalf("failed to parse expected JSON: %v", err) + } + + if !reflect.DeepEqual(actualValue, expectedValue) { + t.Fatalf("semantic JSON mismatch\nactual: %s\nexpected: %s", string(actualRaw), string(expectedJSON)) + } +} + +func isStableSkipReason(reason ProposalSkipReason) bool { + switch reason { + case + SkipReasonMissingSegment, + SkipReasonMissingOriginalText, + SkipReasonAmbiguousOriginal, + SkipReasonNoEffect, + SkipReasonInvalidProposal: + return true + default: + return false + } +} diff --git a/internal/framework/proposals/doc.go b/internal/framework/proposals/doc.go new file mode 100644 index 0000000..9537bb9 --- /dev/null +++ b/internal/framework/proposals/doc.go @@ -0,0 +1,7 @@ +// Package proposals provides deterministic, literal proposal preview and +// application helpers for transcript rewriting. +// +// The package only enforces structural and replacement-safety rules. +// It does not perform semantic validation; LLM-backed and deterministic +// validators are layered later in the pipeline. +package proposals diff --git a/internal/framework/proposals/testdata/ambiguous_require_unique.input.json b/internal/framework/proposals/testdata/ambiguous_require_unique.input.json new file mode 100644 index 0000000..9d4ed15 --- /dev/null +++ b/internal/framework/proposals/testdata/ambiguous_require_unique.input.json @@ -0,0 +1,25 @@ +{ + "policy": "require_unique", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "rank rank" + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "homophones", + "module_instance": "homophones", + "id": 1, + "original_text": "rank", + "corrected_text": "Hrank", + "confidence": 0.9 + } + ] +} diff --git a/internal/framework/proposals/testdata/categories_metadata_preservation.input.json b/internal/framework/proposals/testdata/categories_metadata_preservation.input.json new file mode 100644 index 0000000..c802eeb --- /dev/null +++ b/internal/framework/proposals/testdata/categories_metadata_preservation.input.json @@ -0,0 +1,34 @@ +{ + "policy": "require_unique", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "Narrator", + "start": 0, + "end": 3, + "text": "status", + "categories": ["meta", "state"] + }, + { + "id": 2, + "speaker": "Player", + "start": 3, + "end": 6, + "text": "rank", + "categories": ["combat"] + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "glossary", + "module_instance": "glossary_1", + "id": 2, + "original_text": "rank", + "corrected_text": "Hrank", + "confidence": 0.9 + } + ] +} diff --git a/internal/framework/proposals/testdata/categories_metadata_preservation.transcript.golden.json b/internal/framework/proposals/testdata/categories_metadata_preservation.transcript.golden.json new file mode 100644 index 0000000..f2285bc --- /dev/null +++ b/internal/framework/proposals/testdata/categories_metadata_preservation.transcript.golden.json @@ -0,0 +1,20 @@ +{ + "segments": [ + { + "id": 1, + "speaker": "Narrator", + "start": 0, + "end": 3, + "text": "status", + "categories": ["meta", "state"] + }, + { + "id": 2, + "speaker": "Player", + "start": 3, + "end": 6, + "text": "Hrank", + "categories": ["combat"] + } + ] +} diff --git a/internal/framework/proposals/testdata/missing_segment.input.json b/internal/framework/proposals/testdata/missing_segment.input.json new file mode 100644 index 0000000..0484110 --- /dev/null +++ b/internal/framework/proposals/testdata/missing_segment.input.json @@ -0,0 +1,25 @@ +{ + "policy": "require_unique", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 1, + "text": "hello" + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 99, + "original_text": "hello", + "corrected_text": "hi", + "confidence": 0.9 + } + ] +} diff --git a/internal/framework/proposals/testdata/mixed_scenarios.input.json b/internal/framework/proposals/testdata/mixed_scenarios.input.json new file mode 100644 index 0000000..7f2d89e --- /dev/null +++ b/internal/framework/proposals/testdata/mixed_scenarios.input.json @@ -0,0 +1,68 @@ +{ + "policy": "require_unique", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 4, + "text": "foo bar rank rank" + }, + { + "id": 2, + "speaker": "B", + "start": 4, + "end": 5, + "text": "hello" + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 1, + "original_text": "foo bar", + "corrected_text": "foo-bar", + "confidence": 0.9 + }, + { + "proposal_index": 1, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 1, + "original_text": "foo bar", + "corrected_text": "foobar", + "confidence": 0.9 + }, + { + "proposal_index": 2, + "module_key": "homophones", + "module_instance": "homophones", + "id": 1, + "original_text": "rank", + "corrected_text": "Hrank", + "confidence": 0.9 + }, + { + "proposal_index": 3, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 99, + "original_text": "x", + "corrected_text": "y", + "confidence": 0.9 + }, + { + "proposal_index": 4, + "module_key": "spoken_word", + "module_instance": "spoken_word", + "id": 2, + "original_text": "hello", + "corrected_text": "hello", + "confidence": 0.9 + } + ] +} diff --git a/internal/framework/proposals/testdata/mixed_scenarios.records.golden.json b/internal/framework/proposals/testdata/mixed_scenarios.records.golden.json new file mode 100644 index 0000000..7cc0e59 --- /dev/null +++ b/internal/framework/proposals/testdata/mixed_scenarios.records.golden.json @@ -0,0 +1,55 @@ +{ + "applied_changes": [ + { + "proposal_index": 0, + "module_key": "grammar", + "module_instance": "grammar_1", + "target_segment_id": 1, + "original_text": "foo bar", + "corrected_text": "foo-bar", + "replacement_count": 1 + } + ], + "skipped_changes": [ + { + "proposal_index": 1, + "module_key": "grammar", + "module_instance": "grammar_1", + "target_segment_id": 1, + "original_text": "foo bar", + "corrected_text": "foobar", + "skip_reason": "missing_original_text", + "message": "original_text was not found in current segment text" + }, + { + "proposal_index": 2, + "module_key": "homophones", + "module_instance": "homophones", + "target_segment_id": 1, + "original_text": "rank", + "corrected_text": "Hrank", + "skip_reason": "ambiguous_original_text", + "message": "original_text matched multiple spans under require_unique policy" + }, + { + "proposal_index": 3, + "module_key": "grammar", + "module_instance": "grammar_1", + "target_segment_id": 99, + "original_text": "x", + "corrected_text": "y", + "skip_reason": "missing_segment", + "message": "target segment was not found" + }, + { + "proposal_index": 4, + "module_key": "spoken_word", + "module_instance": "spoken_word", + "target_segment_id": 2, + "original_text": "hello", + "corrected_text": "hello", + "skip_reason": "no_effect", + "message": "original_text and corrected_text are identical" + } + ] +} diff --git a/internal/framework/proposals/testdata/multiple_proposals_one_segment.input.json b/internal/framework/proposals/testdata/multiple_proposals_one_segment.input.json new file mode 100644 index 0000000..c0ab2a0 --- /dev/null +++ b/internal/framework/proposals/testdata/multiple_proposals_one_segment.input.json @@ -0,0 +1,34 @@ +{ + "policy": "require_unique", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "foo bar" + } + ] + }, + "proposals": [ + { + "proposal_index": 2, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 1, + "original_text": "bar", + "corrected_text": "BAR", + "confidence": 0.9 + }, + { + "proposal_index": 1, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 1, + "original_text": "foo", + "corrected_text": "FOO", + "confidence": 0.9 + } + ] +} diff --git a/internal/framework/proposals/testdata/multiple_proposals_one_segment.transcript.golden.json b/internal/framework/proposals/testdata/multiple_proposals_one_segment.transcript.golden.json new file mode 100644 index 0000000..f278e71 --- /dev/null +++ b/internal/framework/proposals/testdata/multiple_proposals_one_segment.transcript.golden.json @@ -0,0 +1,11 @@ +{ + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "FOO BAR" + } + ] +} diff --git a/internal/framework/proposals/testdata/multiple_replacements_one_segment.input.json b/internal/framework/proposals/testdata/multiple_replacements_one_segment.input.json new file mode 100644 index 0000000..20af343 --- /dev/null +++ b/internal/framework/proposals/testdata/multiple_replacements_one_segment.input.json @@ -0,0 +1,25 @@ +{ + "policy": "replace_all", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "uh uh uh" + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "spoken_word", + "module_instance": "spoken_word", + "id": 1, + "original_text": "uh", + "corrected_text": "um", + "confidence": 0.9 + } + ] +} diff --git a/internal/framework/proposals/testdata/multiple_replacements_one_segment.transcript.golden.json b/internal/framework/proposals/testdata/multiple_replacements_one_segment.transcript.golden.json new file mode 100644 index 0000000..6a78f59 --- /dev/null +++ b/internal/framework/proposals/testdata/multiple_replacements_one_segment.transcript.golden.json @@ -0,0 +1,11 @@ +{ + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "um um um" + } + ] +} diff --git a/internal/framework/proposals/testdata/replace_all_behavior.input.json b/internal/framework/proposals/testdata/replace_all_behavior.input.json new file mode 100644 index 0000000..9fb82ae --- /dev/null +++ b/internal/framework/proposals/testdata/replace_all_behavior.input.json @@ -0,0 +1,25 @@ +{ + "policy": "replace_all", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "ha-ha-ha" + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "spoken_word", + "module_instance": "spoken_word", + "id": 1, + "original_text": "ha", + "corrected_text": "ho", + "confidence": 0.9 + } + ] +} diff --git a/internal/framework/proposals/testdata/replace_all_behavior.transcript.golden.json b/internal/framework/proposals/testdata/replace_all_behavior.transcript.golden.json new file mode 100644 index 0000000..935d6da --- /dev/null +++ b/internal/framework/proposals/testdata/replace_all_behavior.transcript.golden.json @@ -0,0 +1,11 @@ +{ + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "ho-ho-ho" + } + ] +} diff --git a/internal/framework/proposals/testdata/simple_replacement.input.json b/internal/framework/proposals/testdata/simple_replacement.input.json new file mode 100644 index 0000000..85e0c20 --- /dev/null +++ b/internal/framework/proposals/testdata/simple_replacement.input.json @@ -0,0 +1,25 @@ +{ + "policy": "require_unique", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 1.25, + "text": "gestures" + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "glossary", + "module_instance": "glossary_1", + "id": 1, + "original_text": "gestures", + "corrected_text": "Jesters", + "confidence": 0.95 + } + ] +} diff --git a/internal/framework/proposals/testdata/simple_replacement.transcript.golden.json b/internal/framework/proposals/testdata/simple_replacement.transcript.golden.json new file mode 100644 index 0000000..eb7720f --- /dev/null +++ b/internal/framework/proposals/testdata/simple_replacement.transcript.golden.json @@ -0,0 +1,11 @@ +{ + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 1.25, + "text": "Jesters" + } + ] +} diff --git a/internal/framework/proposals/testdata/stale_after_earlier_replacement.input.json b/internal/framework/proposals/testdata/stale_after_earlier_replacement.input.json new file mode 100644 index 0000000..f41081d --- /dev/null +++ b/internal/framework/proposals/testdata/stale_after_earlier_replacement.input.json @@ -0,0 +1,34 @@ +{ + "policy": "require_unique", + "transcript": { + "segments": [ + { + "id": 1, + "speaker": "A", + "start": 0, + "end": 2, + "text": "foo bar" + } + ] + }, + "proposals": [ + { + "proposal_index": 0, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 1, + "original_text": "foo bar", + "corrected_text": "foo-bar", + "confidence": 0.9 + }, + { + "proposal_index": 1, + "module_key": "grammar", + "module_instance": "grammar_1", + "id": 1, + "original_text": "foo bar", + "corrected_text": "foobar", + "confidence": 0.9 + } + ] +}