Test chunking with proposal application
This commit is contained in:
304
internal/framework/contracts/chunk_proposal_composition_test.go
Normal file
304
internal/framework/contracts/chunk_proposal_composition_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
)
|
||||
|
||||
// testChunkProposalHarness is a test-only helper that composes existing
|
||||
// deterministic chunking and proposal-application primitives.
|
||||
// Production runner orchestration is implemented in later phases.
|
||||
type testChunkProposalHarness struct {
|
||||
module TranscriptModule
|
||||
}
|
||||
|
||||
func (h testChunkProposalHarness) collectEnrichedProposals(
|
||||
ctx context.Context,
|
||||
transcript *schema.Transcript,
|
||||
sections []chunking.Section,
|
||||
) ([]proposals.EnrichedCorrectionProposal, error) {
|
||||
runSpecs, err := ResolveModuleRunSpecs([]string{h.module.Key()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runSpec := runSpecs[0]
|
||||
runSpec.ReplacementPolicy = h.module.ReplacementPolicy()
|
||||
|
||||
out := make([]proposals.EnrichedCorrectionProposal, 0)
|
||||
nextIndex := 0
|
||||
|
||||
for _, section := range sections {
|
||||
sectionMeta := SectionMetadataFromSection(section)
|
||||
base, err := h.module.Propose(ctx, ProposalRequest{
|
||||
ExecutionContext: ExecutionContext{
|
||||
WorkingTranscript: transcript,
|
||||
Section: §ionMeta,
|
||||
},
|
||||
RunSpec: runSpec,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, proposal := range base {
|
||||
sectionIndex := section.Index
|
||||
out = append(out, proposals.EnrichedCorrectionProposal{
|
||||
CorrectionProposal: proposal,
|
||||
ProposalMetadata: proposals.ProposalMetadata{
|
||||
ProposalIndex: nextIndex,
|
||||
ModuleKey: runSpec.ModuleKey,
|
||||
ModuleInstance: runSpec.InstanceName,
|
||||
SectionIndex: §ionIndex,
|
||||
},
|
||||
})
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type deterministicReplacementRule struct {
|
||||
OriginalText string
|
||||
CorrectedText string
|
||||
Confidence float64
|
||||
}
|
||||
|
||||
type deterministicFakeModule struct {
|
||||
key string
|
||||
replacementPolicy proposals.ReplacementPolicy
|
||||
rules []deterministicReplacementRule
|
||||
}
|
||||
|
||||
func (m deterministicFakeModule) Key() string { return m.key }
|
||||
|
||||
func (m deterministicFakeModule) ReplacementPolicy() proposals.ReplacementPolicy {
|
||||
return m.replacementPolicy
|
||||
}
|
||||
|
||||
func (m deterministicFakeModule) Validators() []Validator { return nil }
|
||||
|
||||
func (m deterministicFakeModule) Propose(ctx context.Context, req ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
_ = ctx
|
||||
|
||||
if req.WorkingTranscript == nil || req.Section == nil {
|
||||
return []proposals.CorrectionProposal{}, nil
|
||||
}
|
||||
|
||||
out := make([]proposals.CorrectionProposal, 0)
|
||||
for _, seg := range req.WorkingTranscript.Segments {
|
||||
if seg.ID < req.Section.StartSegmentID || seg.ID > req.Section.EndSegmentID {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rule := range m.rules {
|
||||
if strings.Contains(seg.Text, rule.OriginalText) {
|
||||
out = append(out, proposals.CorrectionProposal{
|
||||
TargetSegmentID: seg.ID,
|
||||
OriginalText: rule.OriginalText,
|
||||
CorrectedText: rule.CorrectedText,
|
||||
Confidence: rule.Confidence,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestChunkProposalMetadataAssociation(t *testing.T) {
|
||||
transcript := integrationTranscriptFixture()
|
||||
sections := mustChunkFixtureTranscript(t, transcript)
|
||||
|
||||
module := deterministicFakeModule{
|
||||
key: "fake_rewriter",
|
||||
replacementPolicy: proposals.ReplacementPolicyRequireUnique,
|
||||
rules: []deterministicReplacementRule{
|
||||
{OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9},
|
||||
{OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
harness := testChunkProposalHarness{module: module}
|
||||
enriched, err := harness.collectEnrichedProposals(context.Background(), transcript, sections)
|
||||
if err != nil {
|
||||
t.Fatalf("collectEnrichedProposals failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("expected 2 sections, got %d", len(sections))
|
||||
}
|
||||
if len(enriched) != 2 {
|
||||
t.Fatalf("expected 2 enriched proposals, got %d", len(enriched))
|
||||
}
|
||||
|
||||
if enriched[0].SectionIndex == nil || *enriched[0].SectionIndex != 0 {
|
||||
t.Fatalf("expected first proposal section_index=0, got %v", enriched[0].SectionIndex)
|
||||
}
|
||||
if enriched[1].SectionIndex == nil || *enriched[1].SectionIndex != 1 {
|
||||
t.Fatalf("expected second proposal section_index=1, got %v", enriched[1].SectionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicProposalIndexAssignment(t *testing.T) {
|
||||
transcript := integrationTranscriptFixture()
|
||||
sections := mustChunkFixtureTranscript(t, transcript)
|
||||
module := deterministicFakeModule{
|
||||
key: "fake_rewriter",
|
||||
replacementPolicy: proposals.ReplacementPolicyRequireUnique,
|
||||
rules: []deterministicReplacementRule{
|
||||
{OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9},
|
||||
{OriginalText: "alpha one", CorrectedText: "ALPHA-ONE", Confidence: 0.9},
|
||||
{OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
harness := testChunkProposalHarness{module: module}
|
||||
first, err := harness.collectEnrichedProposals(context.Background(), transcript, sections)
|
||||
if err != nil {
|
||||
t.Fatalf("first collect failed: %v", err)
|
||||
}
|
||||
second, err := harness.collectEnrichedProposals(context.Background(), transcript, sections)
|
||||
if err != nil {
|
||||
t.Fatalf("second collect failed: %v", err)
|
||||
}
|
||||
|
||||
if len(first) != len(second) {
|
||||
t.Fatalf("proposal length mismatch: %d vs %d", len(first), len(second))
|
||||
}
|
||||
|
||||
for i := range first {
|
||||
if first[i].ProposalIndex != i {
|
||||
t.Fatalf("expected first pass proposal index %d at slot %d, got %d", i, i, first[i].ProposalIndex)
|
||||
}
|
||||
if second[i].ProposalIndex != i {
|
||||
t.Fatalf("expected second pass proposal index %d at slot %d, got %d", i, i, second[i].ProposalIndex)
|
||||
}
|
||||
if first[i].TargetSegmentID != second[i].TargetSegmentID ||
|
||||
first[i].OriginalText != second[i].OriginalText ||
|
||||
first[i].CorrectedText != second[i].CorrectedText {
|
||||
t.Fatalf("proposal mismatch at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFakeProposalsAcrossMultipleChunksWithStaleSkip(t *testing.T) {
|
||||
transcript := integrationTranscriptFixture()
|
||||
sections := mustChunkFixtureTranscript(t, transcript)
|
||||
|
||||
module := deterministicFakeModule{
|
||||
key: "fake_rewriter",
|
||||
replacementPolicy: proposals.ReplacementPolicyRequireUnique,
|
||||
rules: []deterministicReplacementRule{
|
||||
{OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9},
|
||||
{OriginalText: "alpha one", CorrectedText: "ALPHA-ONE", Confidence: 0.9},
|
||||
{OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
harness := testChunkProposalHarness{module: module}
|
||||
enriched, err := harness.collectEnrichedProposals(context.Background(), transcript, sections)
|
||||
if err != nil {
|
||||
t.Fatalf("collectEnrichedProposals failed: %v", err)
|
||||
}
|
||||
|
||||
result := proposals.ApplyProposals(transcript, enriched, module.ReplacementPolicy())
|
||||
|
||||
if len(result.Applied) != 2 {
|
||||
t.Fatalf("expected 2 applied changes, got %d", len(result.Applied))
|
||||
}
|
||||
if len(result.Skipped) != 1 {
|
||||
t.Fatalf("expected 1 skipped change, got %d", len(result.Skipped))
|
||||
}
|
||||
if result.Skipped[0].SkipReason != proposals.SkipReasonMissingOriginalText {
|
||||
t.Fatalf("expected stale skip reason %q, got %q", proposals.SkipReasonMissingOriginalText, result.Skipped[0].SkipReason)
|
||||
}
|
||||
|
||||
if got := result.Transcript.Segments[0].Text; got != "ALPHA one" {
|
||||
t.Fatalf("unexpected segment 1 text: %q", got)
|
||||
}
|
||||
if got := result.Transcript.Segments[2].Text; got != "CHARLIE three" {
|
||||
t.Fatalf("unexpected segment 3 text: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkProposalCompositionDoesNotMutateNormalizedTranscript(t *testing.T) {
|
||||
transcript := integrationTranscriptFixture()
|
||||
before := cloneTranscriptForComparison(transcript)
|
||||
sections := mustChunkFixtureTranscript(t, transcript)
|
||||
|
||||
module := deterministicFakeModule{
|
||||
key: "fake_rewriter",
|
||||
replacementPolicy: proposals.ReplacementPolicyRequireUnique,
|
||||
rules: []deterministicReplacementRule{
|
||||
{OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9},
|
||||
{OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
harness := testChunkProposalHarness{module: module}
|
||||
enriched, err := harness.collectEnrichedProposals(context.Background(), transcript, sections)
|
||||
if err != nil {
|
||||
t.Fatalf("collectEnrichedProposals failed: %v", err)
|
||||
}
|
||||
|
||||
_ = proposals.ApplyProposals(transcript, enriched, module.ReplacementPolicy())
|
||||
|
||||
if !reflect.DeepEqual(transcript, before) {
|
||||
t.Fatalf("normalized transcript mutated\nbefore=%+v\nafter=%+v", before, transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func integrationTranscriptFixture() *schema.Transcript {
|
||||
return &schema.Transcript{
|
||||
Segments: []schema.Segment{
|
||||
{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "alpha one"},
|
||||
{ID: 2, Speaker: "A", Start: 1, End: 2, Text: "bravo two"},
|
||||
{ID: 3, Speaker: "B", Start: 2, End: 3, Text: "charlie three"},
|
||||
{ID: 4, Speaker: "B", Start: 3, End: 4, Text: "delta four"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mustChunkFixtureTranscript(t *testing.T, transcript *schema.Transcript) []chunking.Section {
|
||||
t.Helper()
|
||||
chunker := chunking.NewChunkerWithEstimator(
|
||||
chunking.ChunkingConfig{MaxSectionTokens: 2, MinSectionTokens: 0},
|
||||
&chunking.ConstTokenEstimator{Tokens: 1},
|
||||
)
|
||||
|
||||
sections, err := chunker.ChunkTranscript(transcript)
|
||||
if err != nil {
|
||||
t.Fatalf("chunk transcript failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("expected fixture chunking to produce 2 sections, got %d", len(sections))
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
func cloneTranscriptForComparison(in *schema.Transcript) *schema.Transcript {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := &schema.Transcript{Segments: make([]schema.Segment, len(in.Segments))}
|
||||
for i, seg := range in.Segments {
|
||||
cloned := seg
|
||||
if seg.Categories != nil {
|
||||
cloned.Categories = append([]string(nil), seg.Categories...)
|
||||
}
|
||||
out.Segments[i] = cloned
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user