Enable correction-aware semantic reconciliation

This commit is contained in:
2026-08-27 00:08:05 +00:00
parent a26d6ed042
commit 04ba87e174
14 changed files with 195 additions and 51 deletions

View File

@@ -49,6 +49,7 @@ type Request struct {
ProfileID string
StructuredOutputRepairAttempts *int
SessionID string
Correction *contracts.SemanticCorrection
}
// ResultDisposition classifies a provider-neutral reconciliation outcome.
@@ -69,6 +70,7 @@ type Result struct {
issues []Issue
discardedGroupCount int
candidateMappings []CandidateMapping
modelCandidate *contracts.ModelCandidate
}
// Disposition returns the classified outcome.
@@ -88,6 +90,16 @@ func (result Result) CandidateMappings() []CandidateMapping {
return append([]CandidateMapping(nil), result.candidateMappings...)
}
// ModelCandidate returns an owned copy of the proposal response when a model
// completion produced this result.
func (result Result) ModelCandidate() *contracts.ModelCandidate {
candidate, err := contracts.CloneModelCandidate(result.modelCandidate)
if err != nil {
return nil
}
return candidate
}
func (result Result) planCopy() Plan {
return Plan{groups: result.plan.Groups()}
}
@@ -162,8 +174,12 @@ func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, e
if err := ctx.Err(); err != nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: context error before completion: %w", request.StageName, err)
}
correction, err := contracts.CloneSemanticCorrection(request.Correction)
if err != nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: clone correction: %w", request.StageName, err)
}
var response ProposalResponse
_, err = engine.client.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
completion, err := engine.client.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: request.StageName,
PromptID: engine.prompt.ID,
PromptVersion: engine.prompt.Version,
@@ -171,6 +187,7 @@ func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, e
SessionID: request.SessionID,
StructuredOutputRepairAttempts: request.StructuredOutputRepairAttempts,
Inputs: preparation.Materials(),
Correction: correction,
}, &response)
if err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
@@ -179,6 +196,11 @@ func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, e
}
return Result{}, fmt.Errorf("semantic reconciliation %q: complete structured output: %w", request.StageName, err)
}
candidate, err := contracts.NewModelCandidate(completion.Content, contracts.CorrectionProtocolSingleResponseV1)
if err != nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: own model candidate: %w", request.StageName, err)
}
result.modelCandidate = candidate
assessment := preparation.Assess(response)
result.plan = assessment.Plan()

View File

@@ -56,6 +56,11 @@ func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
request := readyEngineRequest()
request.ProfileID = " profile-as-resolved "
request.SessionID = " session-as-supplied "
correction, err := contracts.NewSemanticCorrection([]byte(`{"duplicate_groups":[]}`), "retain distinct candidates")
if err != nil {
t.Fatal(err)
}
request.Correction = correction
result, err := engine.Reconcile(context.Background(), request)
if err != nil {
@@ -75,24 +80,32 @@ func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
if got.StageName != request.StageName || got.PromptID != engine.prompt.ID || got.PromptVersion != engine.prompt.Version || got.ProfileID != request.ProfileID || got.SessionID != request.SessionID {
t.Fatalf("structured request = %#v, want exact routing values", got)
}
if !reflect.DeepEqual(got.Correction, correction) {
t.Fatalf("structured request correction = %#v, want %#v", got.Correction, correction)
}
if len(got.Inputs) != 2 || got.Inputs["candidates"].Name != "candidates" || got.Inputs["transcript"].Name != "transcript" || len(got.Vars) != 0 {
t.Fatalf("structured request inputs = %#v vars = %#v, want only candidate and transcript materials", got.Inputs, got.Vars)
}
candidate := result.ModelCandidate()
if candidate == nil || candidate.Protocol != contracts.CorrectionProtocolSingleResponseV1 || string(candidate.Response) != `{"duplicate_groups":[]}` {
t.Fatalf("model candidate = %#v, want exact owned completion response", candidate)
}
}
func TestEngineClassifiesSemanticAndTransportOutcomes(t *testing.T) {
transportErr := errors.New("provider unavailable")
tests := []struct {
name string
response ProposalResponse
completion error
want ResultDisposition
wantDiscard int
wantIssues bool
wantError error
name string
response ProposalResponse
completion error
want ResultDisposition
wantDiscard int
wantIssues bool
wantCandidate bool
wantError error
}{
{name: "empty groups complete", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{}}, want: Complete},
{name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true},
{name: "empty groups complete", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{}}, want: Complete, wantCandidate: true},
{name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true, wantCandidate: true},
{name: "invalid structured output retryable", completion: fmt.Errorf("decode response: %w", contracts.ErrInvalidStructuredOutput), want: RetryableInvalidStructuredOutput},
{name: "transport failure", completion: transportErr, wantError: transportErr},
}
@@ -112,6 +125,9 @@ func TestEngineClassifiesSemanticAndTransportOutcomes(t *testing.T) {
if result.Disposition() != test.want || result.DiscardedGroupCount() != test.wantDiscard || (len(result.Issues()) > 0) != test.wantIssues {
t.Fatalf("result = disposition %v discarded %d issues %#v", result.Disposition(), result.DiscardedGroupCount(), result.Issues())
}
if (result.ModelCandidate() != nil) != test.wantCandidate {
t.Fatalf("model candidate = %#v, want presence %t", result.ModelCandidate(), test.wantCandidate)
}
if len(client.requests) != 1 {
t.Fatalf("completion calls = %d, want one", len(client.requests))
}
@@ -141,6 +157,9 @@ func TestEngineSkipsDeterministicOutcomesWithoutCompletion(t *testing.T) {
if result.Disposition() != test.want || len(result.CandidateMappings()) != test.mappingLen {
t.Fatalf("result disposition = %v mappings = %#v", result.Disposition(), result.CandidateMappings())
}
if result.ModelCandidate() != nil {
t.Fatalf("model candidate = %#v, want nil for no-call outcome", result.ModelCandidate())
}
if len(client.requests) != 0 {
t.Fatalf("completion calls = %d, want zero", len(client.requests))
}
@@ -209,7 +228,9 @@ func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) {
firstIssues[0].Category = "changed"
firstMappings := first.CandidateMappings()
firstMappings[0].CandidatePosition = 99
if first.Plan().Groups()[0].MemberPositions()[0] != 0 || first.Issues()[0].Category == "changed" || first.CandidateMappings()[0].CandidatePosition != 0 {
firstCandidate := first.ModelCandidate()
firstCandidate.Response[0] = 'x'
if first.Plan().Groups()[0].MemberPositions()[0] != 0 || first.Issues()[0].Category == "changed" || first.CandidateMappings()[0].CandidatePosition != 0 || string(first.ModelCandidate().Response) != `{"duplicate_groups":[]}` {
t.Fatal("result accessors exposed retained state")
}
@@ -217,7 +238,7 @@ func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if second.Disposition() != Complete || len(second.Plan().Groups()) != 0 || len(second.Issues()) != 0 || second.DiscardedGroupCount() != 0 || len(second.CandidateMappings()) != 2 {
if second.Disposition() != Complete || len(second.Plan().Groups()) != 0 || len(second.Issues()) != 0 || second.DiscardedGroupCount() != 0 || len(second.CandidateMappings()) != 2 || second.ModelCandidate() == nil {
t.Fatalf("second result retained prior call state: disposition %v plan %#v issues %#v discarded %d mappings %#v", second.Disposition(), second.Plan().Groups(), second.Issues(), second.DiscardedGroupCount(), second.CandidateMappings())
}
}
@@ -226,6 +247,7 @@ type recordingReconciliationClient struct {
requests []contracts.StructuredCompletionRequest
responses []ProposalResponse
errors []error
content []byte
}
func (client *recordingReconciliationClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) {
@@ -247,7 +269,11 @@ func (client *recordingReconciliationClient) CompleteStructured(_ context.Contex
return contracts.StructuredCompletionResponse{}, fmt.Errorf("output type = %T", output)
}
*target = response
return contracts.StructuredCompletionResponse{}, nil
content := append([]byte(nil), client.content...)
if len(content) == 0 {
content = []byte(`{"duplicate_groups":[]}`)
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func cloneProposalResponse(response ProposalResponse) ProposalResponse {