diff --git a/internal/framework/proposals/policy.go b/internal/framework/proposals/policy.go new file mode 100644 index 0000000..39e2c7a --- /dev/null +++ b/internal/framework/proposals/policy.go @@ -0,0 +1,47 @@ +package proposals + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ReplacementPolicy controls how original_text spans are replaced in a target segment. +type ReplacementPolicy string + +const ( + ReplacementPolicyRequireUnique ReplacementPolicy = "require_unique" + ReplacementPolicyReplaceAll ReplacementPolicy = "replace_all" +) + +func (p ReplacementPolicy) IsValid() bool { + switch p { + case ReplacementPolicyRequireUnique, ReplacementPolicyReplaceAll: + return true + default: + return false + } +} + +func ParseReplacementPolicy(raw string) (ReplacementPolicy, error) { + parsed := ReplacementPolicy(strings.TrimSpace(raw)) + if !parsed.IsValid() { + return "", fmt.Errorf("replacement policy must be one of: require_unique, replace_all") + } + return parsed, nil +} + +func (p *ReplacementPolicy) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("replacement policy must be a JSON string: %w", err) + } + + parsed, err := ParseReplacementPolicy(raw) + if err != nil { + return err + } + + *p = parsed + return nil +} diff --git a/internal/framework/proposals/proposal.go b/internal/framework/proposals/proposal.go new file mode 100644 index 0000000..fce80df --- /dev/null +++ b/internal/framework/proposals/proposal.go @@ -0,0 +1,49 @@ +package proposals + +import ( + "fmt" + "strings" +) + +// CorrectionProposal is the base proposal emitted by correction modules. +type CorrectionProposal struct { + TargetSegmentID int `json:"id"` + OriginalText string `json:"original_text"` + CorrectedText string `json:"corrected_text"` + Confidence float64 `json:"confidence"` +} + +// ProposalMetadata is framework-enriched execution metadata for a proposal. +type ProposalMetadata struct { + ProposalIndex int `json:"proposal_index"` + ModuleKey string `json:"module_key"` + ModuleInstance string `json:"module_instance"` + SectionIndex *int `json:"section_index,omitempty"` +} + +// EnrichedCorrectionProposal combines module-emitted proposal data with +// framework execution metadata. +type EnrichedCorrectionProposal struct { + CorrectionProposal + ProposalMetadata +} + +// Validate checks only basic structural integrity of the proposal. +// Semantic checks (staleness, span presence, replacement behavior) are handled +// by later proposal preview/application and validator phases. +func (p CorrectionProposal) Validate() error { + if p.TargetSegmentID <= 0 { + return fmt.Errorf("proposal id must be positive") + } + if strings.TrimSpace(p.OriginalText) == "" { + return fmt.Errorf("proposal original_text must not be empty") + } + if strings.TrimSpace(p.CorrectedText) == "" { + return fmt.Errorf("proposal corrected_text must not be empty") + } + if p.Confidence < 0.0 || p.Confidence > 1.0 { + return fmt.Errorf("proposal confidence must be between 0.0 and 1.0") + } + + return nil +} diff --git a/internal/framework/proposals/proposal_test.go b/internal/framework/proposals/proposal_test.go new file mode 100644 index 0000000..93e27b8 --- /dev/null +++ b/internal/framework/proposals/proposal_test.go @@ -0,0 +1,166 @@ +package proposals + +import ( + "encoding/json" + "testing" +) + +func TestCorrectionProposalValidate_Valid(t *testing.T) { + proposal := CorrectionProposal{ + TargetSegmentID: 42, + OriginalText: "gestures", + CorrectedText: "Jesters", + Confidence: 0.95, + } + + if err := proposal.Validate(); err != nil { + t.Fatalf("expected valid proposal, got error: %v", err) + } +} + +func TestCorrectionProposalValidate_InvalidEmptyOriginalText(t *testing.T) { + proposal := CorrectionProposal{ + TargetSegmentID: 42, + OriginalText: "", + CorrectedText: "Jesters", + Confidence: 0.95, + } + + err := proposal.Validate() + if err == nil { + t.Fatal("expected validation error, got nil") + } + if err.Error() != "proposal original_text must not be empty" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCorrectionProposalValidate_InvalidEmptyCorrectedText(t *testing.T) { + proposal := CorrectionProposal{ + TargetSegmentID: 42, + OriginalText: "gestures", + CorrectedText: "", + Confidence: 0.95, + } + + err := proposal.Validate() + if err == nil { + t.Fatal("expected validation error, got nil") + } + if err.Error() != "proposal corrected_text must not be empty" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCorrectionProposalValidate_InvalidConfidence(t *testing.T) { + proposal := CorrectionProposal{ + TargetSegmentID: 42, + OriginalText: "gestures", + CorrectedText: "Jesters", + Confidence: 1.1, + } + + err := proposal.Validate() + if err == nil { + t.Fatal("expected validation error, got nil") + } + if err.Error() != "proposal confidence must be between 0.0 and 1.0" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCorrectionProposalValidate_InvalidSegmentID(t *testing.T) { + proposal := CorrectionProposal{ + TargetSegmentID: 0, + OriginalText: "gestures", + CorrectedText: "Jesters", + Confidence: 0.95, + } + + err := proposal.Validate() + if err == nil { + t.Fatal("expected validation error, got nil") + } + if err.Error() != "proposal id must be positive" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseReplacementPolicy(t *testing.T) { + policy, err := ParseReplacementPolicy("require_unique") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if policy != ReplacementPolicyRequireUnique { + t.Fatalf("unexpected policy: %q", policy) + } + + policy, err = ParseReplacementPolicy("replace_all") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if policy != ReplacementPolicyReplaceAll { + t.Fatalf("unexpected policy: %q", policy) + } + + _, err = ParseReplacementPolicy("unknown") + if err == nil { + t.Fatal("expected parse error for unknown policy") + } +} + +func TestReplacementPolicyJSON(t *testing.T) { + type payload struct { + Policy ReplacementPolicy `json:"replacement_policy"` + } + + raw := []byte(`{"replacement_policy":"replace_all"}`) + var p payload + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if p.Policy != ReplacementPolicyReplaceAll { + t.Fatalf("unexpected policy: %q", p.Policy) + } + + encoded, err := json.Marshal(p) + if err != nil { + t.Fatalf("unexpected marshal error: %v", err) + } + if string(encoded) != `{"replacement_policy":"replace_all"}` { + t.Fatalf("unexpected json: %s", string(encoded)) + } + + invalidRaw := []byte(`{"replacement_policy":"invalid"}`) + if err := json.Unmarshal(invalidRaw, &p); err == nil { + t.Fatal("expected unmarshal error for invalid replacement policy") + } +} + +func TestEnrichedCorrectionProposalJSONIncludesMetadata(t *testing.T) { + sectionIndex := 3 + proposal := EnrichedCorrectionProposal{ + CorrectionProposal: CorrectionProposal{ + TargetSegmentID: 7, + OriginalText: "teh", + CorrectedText: "the", + Confidence: 0.8, + }, + ProposalMetadata: ProposalMetadata{ + ProposalIndex: 1, + ModuleKey: "grammar", + ModuleInstance: "grammar_1", + SectionIndex: §ionIndex, + }, + } + + b, err := json.Marshal(proposal) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + const expected = `{"id":7,"original_text":"teh","corrected_text":"the","confidence":0.8,"proposal_index":1,"module_key":"grammar","module_instance":"grammar_1","section_index":3}` + if string(b) != expected { + t.Fatalf("unexpected json\nexpected: %s\nactual: %s", expected, string(b)) + } +}