Files

194 lines
5.0 KiB
Go
Raw Permalink Normal View History

2026-07-28 04:33:30 +00:00
package usecase
import (
"context"
2026-08-25 09:46:56 +00:00
"encoding/json"
2026-07-28 04:33:30 +00:00
"errors"
"fmt"
"strings"
2026-08-25 09:46:56 +00:00
"unicode/utf8"
2026-07-28 04:33:30 +00:00
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
)
2026-08-25 09:46:56 +00:00
const (
maxRepairDiagnosticBytes = 64 * 1024
omittedRepairDiagnostics = "additional validation diagnostics were omitted"
)
// OutputRepairer generates a corrected candidate after validation fails.
2026-07-28 04:33:30 +00:00
type OutputRepairer interface {
Repair(ctx context.Context, req RepairRequest) (*domain.GenerateResponse, error)
}
2026-08-25 09:46:56 +00:00
// RepairRequest contains the immutable execution state needed for one correction.
2026-07-28 04:33:30 +00:00
type RepairRequest struct {
2026-08-25 09:46:56 +00:00
OriginalMessages []domain.RenderedMessage
2026-07-28 04:33:30 +00:00
PreviousOutput string
ValidationErrors []string
SessionID string
2026-07-28 04:33:30 +00:00
Target domain.ExecutionTarget
TargetPresence domain.ExecutionTargetPresence
2026-07-28 04:33:30 +00:00
StructuredOutput *domain.StructuredOutputSpec
TransientRetries uint64
2026-07-28 04:33:30 +00:00
Attempt int
MaxAttempts int
Mode domain.ValidationMode
}
type defaultOutputRepairer struct {
llm llm.Client
}
2026-08-25 09:46:56 +00:00
// NewDefaultOutputRepairer constructs the standard internal output repairer.
2026-07-28 04:33:30 +00:00
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")
}
2026-08-25 09:46:56 +00:00
guidance, err := repairGuidance(req.Mode)
if err != nil {
return nil, err
}
hasPreviousOutput := strings.TrimSpace(req.PreviousOutput) != ""
suffix := make([]domain.RenderedMessage, 0, 2)
if hasPreviousOutput {
suffix = append(suffix, domain.RenderedMessage{
2026-08-26 02:07:15 +00:00
Role: domain.RoleAssistant,
2026-08-25 09:46:56 +00:00
Content: req.PreviousOutput,
})
2026-07-28 04:33:30 +00:00
}
2026-08-25 09:46:56 +00:00
previousResponse := "The previous response was empty."
if hasPreviousOutput {
2026-08-25 09:46:56 +00:00
previousResponse = "The previous response is included immediately before this instruction."
}
suffix = append(suffix, domain.RenderedMessage{
2026-08-26 02:07:15 +00:00
Role: domain.RoleUser,
2026-08-25 09:46:56 +00:00
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),
),
})
messages := domain.ConcatRenderedMessages(req.OriginalMessages, suffix)
2026-07-28 04:33:30 +00:00
resp, err := r.llm.Generate(ctx, newGenerationRequest(
2026-08-25 09:46:56 +00:00
domain.RenderedPrompt{Messages: messages},
req.SessionID,
req.Target,
req.TargetPresence,
req.StructuredOutput,
req.TransientRetries,
uint64(req.Attempt)+1,
))
2026-07-28 04:33:30 +00:00
if err != nil {
return nil, err
}
if resp == nil {
return nil, errors.New("repair llm returned nil response")
}
return resp, nil
}
2026-08-25 09:46:56 +00:00
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 {
2026-08-25 09:46:56 +00:00
if separator != 0 {
encoded = append(encoded, ',')
}
encoded = append(encoded, entry...)
continue
}
if available < len(`""`) {
break
}
2026-08-25 09:46:56 +00:00
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
2026-08-25 09:46:56 +00:00
}
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
2026-08-25 09:46:56 +00:00
best := []byte(`""`)
for low <= high {
mid := low + (high-low)/2
candidate, _ := json.Marshal(value[:boundaries[mid]])
2026-08-25 09:46:56 +00:00
if len(candidate) <= maxBytes {
best = candidate
low = mid + 1
continue
}
high = mid - 1
}
return best
}