Add correction proposal models
This commit is contained in:
47
internal/framework/proposals/policy.go
Normal file
47
internal/framework/proposals/policy.go
Normal file
@@ -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
|
||||
}
|
||||
49
internal/framework/proposals/proposal.go
Normal file
49
internal/framework/proposals/proposal.go
Normal file
@@ -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
|
||||
}
|
||||
166
internal/framework/proposals/proposal_test.go
Normal file
166
internal/framework/proposals/proposal_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user