Accept span deletions in structured responses

This commit is contained in:
2026-09-25 02:50:46 +00:00
parent 640f663ace
commit 93f62c9095
12 changed files with 115 additions and 16 deletions

View File

@@ -185,6 +185,59 @@ func TestPreparedAdapterRepairsAndClassifies(t *testing.T) {
}
}
func TestCorrectionSchemaReplacementValuesThroughAdapter(t *testing.T) {
for _, tc := range []struct {
name, original, replacement string
valid bool
repairs int
}{
{name: "empty replacement", original: "uh ", replacement: `"corrected_text":""`, valid: true, repairs: 2},
{name: "spaces replacement", original: "hello", replacement: `"corrected_text":" "`, valid: true, repairs: 2},
{name: "ordinary replacement", original: " ", replacement: `"corrected_text":" "`, valid: true, repairs: 2},
{name: "missing replacement", original: "uh ", replacement: "", valid: false},
{name: "null replacement", original: "uh ", replacement: `"corrected_text":null`, valid: false},
{name: "numeric replacement", original: "uh ", replacement: `"corrected_text":1`, valid: false},
{name: "empty original", original: "", replacement: `"corrected_text":"x"`, valid: false},
} {
t.Run(tc.name, func(t *testing.T) {
fields := []string{`"id":1`, `"original_text":` + fmt.Sprintf("%q", tc.original), `"confidence":0.9`}
if tc.replacement != "" {
fields = append(fields, tc.replacement)
}
payload := `{"corrections":[{` + strings.Join(fields, ",") + `}]}`
runtime, count, _ := adapterFixture(t, []reply{{200, payload}}, 0, tc.repairs, nil)
client, err := runtime.NewRoleClient("proposal", CallHooks{})
if err != nil {
t.Fatal(err)
}
var out map[string]any
response, err := client.CompleteStructured(context.Background(), adapterRequest(responseschema.CorrectionSetKey), &out)
if got := count.Load(); got != 1 {
t.Fatalf("expected one provider generation, got %d", got)
}
if response.Execution.StructuralRepairs != 0 || response.Execution.ProviderAttempts != 1 {
t.Fatalf("unexpected repair/generation counts: %+v", response.Execution)
}
if tc.valid {
if err != nil {
t.Fatal(err)
}
corrections := out["corrections"].([]any)
item := corrections[0].(map[string]any)
var expected string
if err := json.Unmarshal([]byte(strings.TrimPrefix(tc.replacement, `"corrected_text":`)), &expected); err != nil {
t.Fatal(err)
}
if item["corrected_text"] != expected || item["original_text"] != tc.original {
t.Fatalf("span values changed: %+v", item)
}
} else if !structuredoutput.IsMalformedError(err) {
t.Fatalf("expected structural rejection, got %v", err)
}
})
}
}
func TestPreparedAdapterMetadataAndTargetGuard(t *testing.T) {
requestSeen := false
var posted struct {

View File

@@ -26,8 +26,7 @@
"minLength": 1
},
"corrected_text": {
"type": "string",
"minLength": 1
"type": "string"
},
"confidence": {
"type": "number",

View File

@@ -30,11 +30,6 @@ func TestPromptKitSchemaPathsUseCanonicalAssets(t *testing.T) {
}
}
const (
expectedCorrectionSetSHA256 = "b86a2dde38d7f440d26470fa8830167512bb0aa1b35e5fee5547057be583c388"
expectedValidatorDecisionSetSHA256 = "2fe90d450e2595b57885aba91c8cc5cedf783dd36758ab430309e3eace401f54"
)
func TestLookupKnownSchemas(t *testing.T) {
correction, ok := Lookup(CorrectionSetKey)
if !ok {
@@ -86,20 +81,13 @@ func TestRegisteredSchemasLoadReadableEmbeddedJSON(t *testing.T) {
}
func TestSchemaHashesMatchRegisteredJSON(t *testing.T) {
expectedByKey := map[Key]string{
CorrectionSetKey: expectedCorrectionSetSHA256,
ValidatorDecisionSetKey: expectedValidatorDecisionSetSHA256,
}
for key, expectedHash := range expectedByKey {
for _, key := range []Key{CorrectionSetKey, ValidatorDecisionSetKey} {
schema, ok := Lookup(key)
if !ok {
t.Fatalf("missing schema %q", key)
}
sum := sha256.Sum256(schema.JSONSchema)
expected := hex.EncodeToString(sum[:])
if schema.SHA256 != expectedHash {
t.Fatalf("unexpected stable hash for %q: got %q want %q", key, schema.SHA256, expectedHash)
}
if schema.SHA256 != expected {
t.Fatalf("unexpected hash for %q: got %q want %q", key, schema.SHA256, expected)
}

View File

@@ -2,6 +2,7 @@ package validators
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -75,6 +76,43 @@ func mk(index int, orig, corr string) proposals.EnrichedCorrectionProposal {
}
}
func TestBuildLLMValidationRequestPreservesLiteralDeletionAndSpacing(t *testing.T) {
for _, tc := range []struct {
name, segment, original, replacement, result string
}{
{name: "deletion", segment: "uh hello", original: "uh ", replacement: "", result: "hello"},
{name: "spacing", segment: "hello world", original: " ", replacement: " ", result: "hello world"},
} {
t.Run(tc.name, func(t *testing.T) {
req := makeReq([]proposals.EnrichedCorrectionProposal{mk(7, tc.original, tc.replacement)})
req.WorkingTranscript.Segments[0].Text = tc.segment
payload, immediate := BuildLLMValidationRequest("editorial_review", LLMValidatorTypeEditorialReview, req)
if len(immediate) != 0 || len(payload.Items) != 1 {
t.Fatalf("expected previewable request: %+v, %+v", payload, immediate)
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded struct {
ValidatorName string `json:"validator_name"`
Corrections []map[string]any `json:"corrections"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.ValidatorName != "editorial_review" || len(decoded.Corrections) != 1 {
t.Fatalf("wrong validator identity or item count: %s", raw)
}
item := decoded.Corrections[0]
value, present := item["corrected_text"]
if !present || value != tc.replacement || item["original_text"] != tc.original || item["original_segment_text"] != tc.segment || item["corrected_segment_text"] != tc.result || item["correction_index"] != float64(7) {
t.Fatalf("literal spans or full segment text lost: %s", raw)
}
})
}
}
func TestChunkLLMValidationItemsOneSmallBatch(t *testing.T) {
items := []LLMValidationItem{{CorrectionIndex: 0, OriginalText: "a", CorrectedText: "b", OriginalSegmentText: "x", CorrectedSegmentText: "y"}}
batches, err := ChunkLLMValidationItems(items, 1000, chunking.NewSimpleTokenEstimator())