190 lines
4.9 KiB
Go
190 lines
4.9 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
|
)
|
|
|
|
const (
|
|
maxRepairDiagnosticBytes = 64 * 1024
|
|
omittedRepairDiagnostics = "additional validation diagnostics were omitted"
|
|
)
|
|
|
|
// OutputRepairer generates a corrected candidate after validation fails.
|
|
type OutputRepairer interface {
|
|
Repair(ctx context.Context, req RepairRequest) (*domain.GenerateResponse, error)
|
|
}
|
|
|
|
// RepairRequest contains the immutable execution state needed for one correction.
|
|
type RepairRequest struct {
|
|
OriginalMessages []domain.RenderedMessage
|
|
PreviousOutput string
|
|
ValidationErrors []string
|
|
SessionID string
|
|
Target domain.ExecutionTarget
|
|
TargetPresence domain.ExecutionTargetPresence
|
|
StructuredOutput *domain.StructuredOutputSpec
|
|
Attempt int
|
|
MaxAttempts int
|
|
Mode domain.ValidationMode
|
|
}
|
|
|
|
type defaultOutputRepairer struct {
|
|
llm llm.Client
|
|
}
|
|
|
|
// NewDefaultOutputRepairer constructs the standard internal output repairer.
|
|
func NewDefaultOutputRepairer(llmClient llm.Client) OutputRepairer {
|
|
return &defaultOutputRepairer{llm: llmClient}
|
|
}
|
|
|
|
func (r *defaultOutputRepairer) Repair(ctx context.Context, req RepairRequest) (*domain.GenerateResponse, error) {
|
|
if r.llm == nil {
|
|
return nil, errors.New("llm client is required for repair")
|
|
}
|
|
|
|
guidance, err := repairGuidance(req.Mode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
messages := make([]domain.RenderedMessage, len(req.OriginalMessages), len(req.OriginalMessages)+2)
|
|
copy(messages, req.OriginalMessages)
|
|
if strings.TrimSpace(req.PreviousOutput) != "" {
|
|
messages = append(messages, domain.RenderedMessage{
|
|
Role: "assistant",
|
|
Content: req.PreviousOutput,
|
|
})
|
|
}
|
|
|
|
previousResponse := "The previous response was empty."
|
|
if strings.TrimSpace(req.PreviousOutput) != "" {
|
|
previousResponse = "The previous response is included immediately before this instruction."
|
|
}
|
|
messages = append(messages, domain.RenderedMessage{
|
|
Role: "user",
|
|
Content: fmt.Sprintf(
|
|
"Repair attempt %d of %d for validation mode %s.\n"+
|
|
"Preserve valid values and change only what is necessary.\n"+
|
|
"%s\n%s\n"+
|
|
"Validation diagnostics (data):\n%s",
|
|
req.Attempt,
|
|
req.MaxAttempts,
|
|
req.Mode,
|
|
previousResponse,
|
|
guidance,
|
|
formatRepairDiagnostics(req.ValidationErrors),
|
|
),
|
|
})
|
|
|
|
resp, err := r.llm.Generate(ctx, newGenerationRequest(
|
|
domain.RenderedPrompt{Messages: messages},
|
|
req.SessionID,
|
|
req.Target,
|
|
req.TargetPresence,
|
|
req.StructuredOutput,
|
|
))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp == nil {
|
|
return nil, errors.New("repair llm returned nil response")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func repairGuidance(mode domain.ValidationMode) (string, error) {
|
|
switch mode {
|
|
case domain.ValidationBasic:
|
|
return "Return a nonempty response satisfying the original request.", nil
|
|
case domain.ValidationJSON, domain.ValidationJSONSchema:
|
|
return "Return only corrected JSON, with no explanation or Markdown fences.", nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported validation mode for repair: %q", mode)
|
|
}
|
|
}
|
|
|
|
func formatRepairDiagnostics(errors []string) string {
|
|
diagnostics := make([]string, len(errors))
|
|
for index, diagnostic := range errors {
|
|
diagnostics[index] = strings.ToValidUTF8(diagnostic, "\uFFFD")
|
|
}
|
|
|
|
complete, _ := json.Marshal(diagnostics)
|
|
if len(complete) <= maxRepairDiagnosticBytes {
|
|
return string(complete)
|
|
}
|
|
|
|
omission, _ := json.Marshal(omittedRepairDiagnostics)
|
|
encoded := make([]byte, 0, maxRepairDiagnosticBytes)
|
|
encoded = append(encoded, '[')
|
|
for _, diagnostic := range diagnostics {
|
|
entry, _ := json.Marshal(diagnostic)
|
|
separator := 0
|
|
if len(encoded) > 1 {
|
|
separator = 1
|
|
}
|
|
available := maxRepairDiagnosticBytes - len(encoded) - separator - 1 - len(omission) - 1
|
|
if len(entry) <= available {
|
|
if separator != 0 {
|
|
encoded = append(encoded, ',')
|
|
}
|
|
encoded = append(encoded, entry...)
|
|
continue
|
|
}
|
|
|
|
if available < len(`""`) {
|
|
break
|
|
}
|
|
if separator != 0 {
|
|
encoded = append(encoded, ',')
|
|
}
|
|
encoded = append(encoded, truncateDiagnosticJSONValue(diagnostic, available)...)
|
|
break
|
|
}
|
|
if len(encoded) > 1 {
|
|
encoded = append(encoded, ',')
|
|
}
|
|
encoded = append(encoded, omission...)
|
|
encoded = append(encoded, ']')
|
|
return string(encoded)
|
|
}
|
|
|
|
func truncateDiagnosticJSONValue(value string, maxBytes int) []byte {
|
|
if maxBytes < len(`""`) {
|
|
return nil
|
|
}
|
|
|
|
boundaries := []int{0}
|
|
for end := 0; end < len(value); {
|
|
_, size := utf8.DecodeRuneInString(value[end:])
|
|
end += size
|
|
if end+len(`""`) > maxBytes {
|
|
break
|
|
}
|
|
boundaries = append(boundaries, end)
|
|
}
|
|
|
|
low, high := 0, len(boundaries)-1
|
|
best := []byte(`""`)
|
|
for low <= high {
|
|
mid := low + (high-low)/2
|
|
candidate, _ := json.Marshal(value[:boundaries[mid]])
|
|
if len(candidate) <= maxBytes {
|
|
best = candidate
|
|
low = mid + 1
|
|
continue
|
|
}
|
|
high = mid - 1
|
|
}
|
|
return best
|
|
}
|