2026-05-12 01:25:52 +00:00
package validators
import (
"context"
2026-09-24 17:14:33 +00:00
"errors"
2026-05-12 01:25:52 +00:00
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
"gitea.maximumdirect.net/eric/audita/internal/core/config"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
2026-09-24 17:14:33 +00:00
"gitea.maximumdirect.net/eric/audita/internal/framework/outcomes"
2026-05-12 01:25:52 +00:00
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
2026-05-13 02:10:24 +00:00
"gitea.maximumdirect.net/eric/audita/internal/framework/responseschema"
2026-05-23 17:48:37 +00:00
"gitea.maximumdirect.net/eric/audita/internal/framework/stagename"
2026-05-23 18:02:53 +00:00
"gitea.maximumdirect.net/eric/audita/internal/framework/structuredoutput"
2026-05-13 18:49:06 +00:00
"gitea.maximumdirect.net/eric/audita/internal/prompts"
2026-05-12 01:25:52 +00:00
)
type LLMBackedValidator struct {
name string
validatorType LLMValidatorType
estimator chunking . TokenEstimator
}
func ( v * LLMBackedValidator ) Name ( ) string {
return v . name
}
// SetTokenEstimator allows deterministic test control over batching behavior.
func ( v * LLMBackedValidator ) SetTokenEstimator ( estimator chunking . TokenEstimator ) {
if v == nil || estimator == nil {
return
}
v . estimator = estimator
}
2026-09-24 02:07:17 +00:00
func NewLLMBackedValidator ( name string , validatorType LLMValidatorType ) ( * LLMBackedValidator , error ) {
if validatorPromptMetadata ( validatorType ) . PromptID == "" {
return nil , fmt . Errorf ( "unsupported LLM validator type %q" , validatorType )
2026-05-12 01:25:52 +00:00
}
if strings . TrimSpace ( name ) == "" {
return nil , fmt . Errorf ( "validator name must not be empty" )
}
return & LLMBackedValidator {
name : name ,
validatorType : validatorType ,
estimator : chunking . NewSimpleTokenEstimator ( ) ,
} , nil
}
func ( v * LLMBackedValidator ) Validate ( ctx context . Context , req Request ) ( Result , error ) {
if v == nil {
return Result { } , fmt . Errorf ( "validator is nil" )
}
if len ( req . CandidateProposal ) == 0 {
return Result { ValidatorName : v . name , Decisions : nil } , nil
}
2026-09-24 02:18:31 +00:00
if req . LLMClient == nil {
return Result { } , fmt . Errorf ( "LLM-backed validator %q requires a structured LLM client" , v . name )
}
2026-05-12 01:25:52 +00:00
validationReq , immediate := BuildLLMValidationRequest ( v . name , v . validatorType , req )
if len ( validationReq . Items ) == 0 {
all := append ( [ ] Decision ( nil ) , immediate ... )
sort . SliceStable ( all , func ( i , j int ) bool { return all [ i ] . ProposalIndex < all [ j ] . ProposalIndex } )
if err := EnforceDecisionCardinality ( req . CandidateProposal , all ) ; err != nil {
return Result { } , err
}
return Result { ValidatorName : v . name , Decisions : all } , nil
}
maxTokens := config . DefaultValidationMaxPromptTokens
2026-09-24 02:07:17 +00:00
if req . Config != nil && req . Config . Validation . MaxPromptTokens > 0 {
maxTokens = req . Config . Validation . MaxPromptTokens
2026-05-12 01:25:52 +00:00
}
2026-05-23 10:07:06 -05:00
oversized := oversizedValidationDecisions ( validationReq . Items , maxTokens , v . estimator )
2026-09-24 17:14:33 +00:00
operations := make ( [ ] outcomes . Operation , 0 )
sectionIndex := 0
if req . SectionIndex != nil {
sectionIndex = * req . SectionIndex
}
operationRef := func ( kind outcomes . OperationKind , batchIndex * int ) outcomes . Reference {
return outcomes . Reference {
ModuleKey : req . ModuleKey , ModuleInstance : req . ModuleInstance ,
SectionIndex : sectionIndex , Kind : kind , ValidatorName : v . name ,
ValidatorOrder : req . ValidatorOrder , BatchIndex : batchIndex ,
}
}
if len ( oversized ) > 0 {
indices := make ( [ ] int , len ( oversized ) )
for i , decision := range oversized {
indices [ i ] = decision . ProposalIndex
}
operations = append ( operations , outcomes . Operation {
Reference : operationRef ( outcomes . OperationValidationInput , nil ) ,
FailureReason : outcomes . ReasonValidationInputTooLarge ,
ProposalIndices : indices ,
} )
}
2026-05-23 10:07:06 -05:00
itemsForBatching := filterItemsByDecision ( validationReq . Items , oversized )
if len ( itemsForBatching ) == 0 {
all := append ( [ ] Decision ( nil ) , immediate ... )
all = append ( all , oversized ... )
if err := EnforceDecisionCardinality ( req . CandidateProposal , all ) ; err != nil {
2026-09-24 17:14:33 +00:00
return Result { ValidatorName : v . name , Decisions : all , Operations : operations } , err
2026-05-23 10:07:06 -05:00
}
sort . SliceStable ( all , func ( i , j int ) bool { return all [ i ] . ProposalIndex < all [ j ] . ProposalIndex } )
2026-09-24 17:14:33 +00:00
return Result { ValidatorName : v . name , Decisions : all , Operations : operations } , nil
2026-05-23 10:07:06 -05:00
}
batches , err := ChunkLLMValidationItems ( itemsForBatching , maxTokens , v . estimator )
2026-05-12 01:25:52 +00:00
if err != nil {
2026-09-24 17:14:33 +00:00
return Result { ValidatorName : v . name , Decisions : append ( append ( [ ] Decision ( nil ) , immediate ... ) , oversized ... ) , Operations : operations } , err
2026-05-12 01:25:52 +00:00
}
llmDecisions := make ( [ ] Decision , 0 )
for _ , batch := range batches {
2026-09-24 17:14:33 +00:00
if err := ctx . Err ( ) ; err != nil {
return Result { ValidatorName : v . name , Decisions : append ( append ( append ( [ ] Decision ( nil ) , immediate ... ) , oversized ... ) , llmDecisions ... ) , Operations : operations } , err
}
2026-05-13 12:13:32 +00:00
transcriptDescription := ""
if req . Config != nil {
2026-09-24 02:07:17 +00:00
transcriptDescription = req . Config . Context . Description
2026-05-13 12:13:32 +00:00
}
2026-09-24 02:07:17 +00:00
variables , err := BuildValidationVariables ( batch . Items , transcriptDescription )
2026-05-12 01:25:52 +00:00
if err != nil {
2026-09-24 17:14:33 +00:00
return Result { ValidatorName : v . name , Decisions : append ( append ( append ( [ ] Decision ( nil ) , immediate ... ) , oversized ... ) , llmDecisions ... ) , Operations : operations } , err
2026-05-12 01:25:52 +00:00
}
var response LLMValidationResponse
2026-09-24 02:07:17 +00:00
var completion StructuredCompletionResponse
2026-05-13 02:10:24 +00:00
responseSchema := responseschema . MustLookup ( responseschema . ValidatorDecisionSetKey )
2026-09-24 02:07:17 +00:00
promptMetadata := validatorPromptMetadata ( v . validatorType )
2026-09-24 15:32:13 +00:00
stageInstance := req . ModuleInstance
if req . SectionIndex != nil {
stageInstance = fmt . Sprintf ( "%s:section-%04d" , stageInstance , * req . SectionIndex )
}
stage := stagename . ValidatorBatch ( stageInstance , v . name , batch . BatchIndex )
2026-09-24 02:18:31 +00:00
completion , err = req . LLMClient . CompleteStructured ( ctx , StructuredCompletionRequest {
PromptID : promptMetadata . PromptID , PromptVersion : promptMetadata . PromptVersion ,
Variables : variables , Schema : responseschema . ValidatorDecisionSetKey ,
2026-09-24 15:32:13 +00:00
Context : structuredoutput . DiagnosticContext { Role : "validation" , ModuleKey : req . ModuleKey , ModuleInstance : req . ModuleInstance , SectionIndex : req . SectionIndex , BatchIndex : & batch . BatchIndex , ValidatorName : v . name } ,
2026-09-24 02:18:31 +00:00
} , & response )
2026-09-24 17:14:33 +00:00
batchIndex := batch . BatchIndex
operation := outcomes . Operation {
Reference : operationRef ( outcomes . OperationValidation , & batchIndex ) ,
Counts : completion . Execution ,
}
2026-09-24 02:18:31 +00:00
2026-05-12 01:25:52 +00:00
artifacts := InteractionArtifacts { }
if req . DiagnosticsWriter != nil {
2026-09-24 21:48:18 +00:00
var diagnosticErr error
artifacts , diagnosticErr = req . DiagnosticsWriter . WriteInteraction (
2026-05-12 01:25:52 +00:00
stage ,
2026-05-13 02:10:24 +00:00
map [ string ] any {
2026-05-23 17:48:37 +00:00
"validator_name" : v . name ,
"validator_type" : v . validatorType ,
"batch_index" : batch . BatchIndex ,
2026-09-24 02:07:17 +00:00
"prompt" : completion . Prompt ,
2026-05-23 17:48:37 +00:00
"response_schema" : responseSchema . DiagnosticsMap ( ) ,
2026-05-13 02:10:24 +00:00
} ,
2026-09-24 02:07:17 +00:00
completion . Prepared ,
map [ string ] any { "decoded" : response , "final_content" : completion . FinalContent , "model" : completion . Model } ,
2026-05-12 01:25:52 +00:00
errPayload ( err ) ,
)
2026-09-24 21:48:18 +00:00
if diagnosticErr != nil && req . ReportDiagnosticError != nil {
req . ReportDiagnosticError ( fmt . Errorf ( "validation interaction %s: %w" , stage , diagnosticErr ) )
}
2026-05-12 01:25:52 +00:00
}
if err != nil {
2026-09-24 17:14:33 +00:00
var failure * structuredoutput . Failure
if errors . As ( err , & failure ) {
operation . FailureReason = failure . Reason
}
if operation . FailureReason == "" {
operation . FailureReason = outcomes . ReasonInternalFailure
}
operation . ProposalIndices = batchProposalIndices ( batch . Items )
operations = append ( operations , operation )
if structuredoutput . IsRecoverable ( err ) && failure != nil && failure . Reason != "" {
llmDecisions = append ( llmDecisions , rejectBatch ( batch . Items , string ( failure . Reason ) , "model validation was unavailable" ) ... )
2026-05-23 10:07:06 -05:00
continue
}
2026-09-24 17:14:33 +00:00
return Result { ValidatorName : v . name , Decisions : append ( append ( append ( [ ] Decision ( nil ) , immediate ... ) , oversized ... ) , llmDecisions ... ) , Operations : operations } , fmt . Errorf ( "LLM validator %q completion failed: %w" , v . name , err )
2026-05-12 01:25:52 +00:00
}
batchDecisions , err := mapLLMResponseToDecisions ( batch . Items , response )
if err != nil {
2026-09-24 17:14:33 +00:00
operation . FailureReason = outcomes . ReasonValidationDecisionsInvalid
operation . ProposalIndices = batchProposalIndices ( batch . Items )
operations = append ( operations , operation )
llmDecisions = append ( llmDecisions , rejectBatch ( batch . Items , string ( outcomes . ReasonValidationDecisionsInvalid ) , "model validation decisions were unusable" ) ... )
2026-05-23 10:07:06 -05:00
continue
2026-05-12 01:25:52 +00:00
}
2026-09-24 17:14:33 +00:00
operation . Succeeded = true
if completion . Execution . StructuralRepairs > 0 {
operation . RecoveryReasons = append ( operation . RecoveryReasons , outcomes . RecoveryOutputRepaired )
}
if completion . Execution . TransientRetries > 0 {
operation . RecoveryReasons = append ( operation . RecoveryReasons , outcomes . RecoveryServiceRecovered )
}
operations = append ( operations , operation )
2026-05-12 01:25:52 +00:00
for i := range batchDecisions {
batchDecisions [ i ] . DiagnosticArtifactPath = artifacts . ResponsePayloadPath
}
llmDecisions = append ( llmDecisions , batchDecisions ... )
}
all := append ( [ ] Decision ( nil ) , immediate ... )
2026-05-23 10:07:06 -05:00
all = append ( all , oversized ... )
2026-05-12 01:25:52 +00:00
all = append ( all , llmDecisions ... )
if err := EnforceDecisionCardinality ( req . CandidateProposal , all ) ; err != nil {
2026-09-24 17:14:33 +00:00
return Result { ValidatorName : v . name , Decisions : all , Operations : operations } , err
2026-05-12 01:25:52 +00:00
}
sort . SliceStable ( all , func ( i , j int ) bool { return all [ i ] . ProposalIndex < all [ j ] . ProposalIndex } )
2026-09-24 17:14:33 +00:00
return Result { ValidatorName : v . name , Decisions : all , Operations : operations } , nil
2026-05-12 01:25:52 +00:00
}
2026-05-13 18:49:06 +00:00
func validatorPromptMetadata ( validatorType LLMValidatorType ) prompts . Metadata {
switch validatorType {
case LLMValidatorTypeSpokenFormPlausibility :
return prompts . MustLookupMetadata ( prompts . PromptIDValidatorSpokenFormPlausibility )
case LLMValidatorTypeMeaningReversal :
return prompts . MustLookupMetadata ( prompts . PromptIDValidatorMeaningReversalReview )
case LLMValidatorTypeEditorialReview :
return prompts . MustLookupMetadata ( prompts . PromptIDValidatorEditorialReview )
case LLMValidatorTypeGrammarReview :
return prompts . MustLookupMetadata ( prompts . PromptIDValidatorGrammarReview )
case LLMValidatorTypeSpokenWordReview :
return prompts . MustLookupMetadata ( prompts . PromptIDValidatorSpokenWordReview )
default :
return prompts . Metadata { }
}
}
2026-05-12 01:25:52 +00:00
func errPayload ( err error ) any {
if err == nil {
return nil
}
return map [ string ] any { "error" : err . Error ( ) }
}
func BuildLLMValidationRequest ( validatorName string , validatorType LLMValidatorType , req Request ) ( LLMValidationRequest , [ ] Decision ) {
items := make ( [ ] LLMValidationItem , 0 , len ( req . CandidateProposal ) )
immediate := make ( [ ] Decision , 0 )
segments := make ( map [ int ] schema . Segment )
if req . WorkingTranscript != nil {
segments = make ( map [ int ] schema . Segment , len ( req . WorkingTranscript . Segments ) )
for _ , seg := range req . WorkingTranscript . Segments {
segments [ seg . ID ] = seg
}
}
for _ , p := range req . CandidateProposal {
seg , ok := segments [ p . TargetSegmentID ]
if ! ok {
immediate = append ( immediate , rejection ( p . ProposalIndex , ReasonMissingTargetSegment , "target segment was not found" ) )
continue
}
preview := proposals . PreviewProposalForSegment ( & seg , p . CorrectionProposal , req . ReplacementPolicy )
if ! preview . Applicable {
immediate = append ( immediate , rejection ( p . ProposalIndex , string ( preview . SkipReason ) , "proposal is not previewable for LLM validation" ) )
continue
}
items = append ( items , LLMValidationItem {
CorrectionIndex : p . ProposalIndex ,
SegmentID : p . TargetSegmentID ,
OriginalText : p . OriginalText ,
CorrectedText : p . CorrectedText ,
OriginalSegmentText : seg . Text ,
CorrectedSegmentText : preview . CorrectedSegmentText ,
Categories : append ( [ ] string ( nil ) , seg . Categories ... ) ,
} )
}
return LLMValidationRequest {
ValidatorName : validatorName ,
ValidatorType : validatorType ,
ModuleKey : req . ModuleKey ,
ModuleInstance : req . ModuleInstance ,
ReplacementPolicy : string ( req . ReplacementPolicy ) ,
Glossary : req . Glossary ,
Items : items ,
} , immediate
}
func mapLLMResponseToDecisions ( items [ ] LLMValidationItem , response LLMValidationResponse ) ( [ ] Decision , error ) {
expected := make ( map [ int ] LLMValidationItem , len ( items ) )
for _ , item := range items {
expected [ item . CorrectionIndex ] = item
}
if len ( response . Validations ) == 0 {
return nil , fmt . Errorf ( "missing validations in structured response" )
}
seen := make ( map [ int ] LLMValidationDecision , len ( response . Validations ) )
for _ , d := range response . Validations {
if d . Confidence < 0.0 || d . Confidence > 1.0 {
return nil , fmt . Errorf ( "confidence for correction_index %d must be between 0.0 and 1.0" , d . CorrectionIndex )
}
if _ , ok := expected [ d . CorrectionIndex ] ; ! ok {
return nil , fmt . Errorf ( "unknown correction_index %d" , d . CorrectionIndex )
}
if _ , exists := seen [ d . CorrectionIndex ] ; exists {
return nil , fmt . Errorf ( "duplicate correction_index %d" , d . CorrectionIndex )
}
seen [ d . CorrectionIndex ] = d
}
decisions := make ( [ ] Decision , 0 , len ( items ) )
for _ , item := range items {
d , ok := seen [ item . CorrectionIndex ]
if ! ok {
return nil , fmt . Errorf ( "missing correction_index %d" , item . CorrectionIndex )
}
reasonCode := ReasonApproved
if ! d . Approved {
reasonCode = "llm_rejected"
}
decisions = append ( decisions , Decision {
ProposalIndex : item . CorrectionIndex ,
Approved : d . Approved ,
2026-09-24 17:14:33 +00:00
Disposition : decisionDisposition ( d . Approved ) ,
2026-05-12 01:25:52 +00:00
ReasonCode : reasonCode ,
Message : strings . TrimSpace ( d . Reason ) ,
} )
}
return decisions , nil
}
2026-05-23 10:07:06 -05:00
func oversizedValidationDecisions ( items [ ] LLMValidationItem , maxPromptTokens int , estimator chunking . TokenEstimator ) [ ] Decision {
out := make ( [ ] Decision , 0 )
for _ , item := range items {
singleTokens , err := estimateBatchTokens ( estimator , [ ] LLMValidationItem { item } )
if err != nil || singleTokens <= maxPromptTokens {
continue
}
2026-09-24 17:14:33 +00:00
out = append ( out , unavailable ( item . CorrectionIndex , ReasonValidatorInputTooLarge , "validation input exceeds max prompt tokens" ) )
2026-05-23 10:07:06 -05:00
}
return out
}
func filterItemsByDecision ( items [ ] LLMValidationItem , decisions [ ] Decision ) [ ] LLMValidationItem {
if len ( decisions ) == 0 {
return append ( [ ] LLMValidationItem ( nil ) , items ... )
}
rejected := make ( map [ int ] struct { } , len ( decisions ) )
for _ , decision := range decisions {
rejected [ decision . ProposalIndex ] = struct { } { }
}
out := make ( [ ] LLMValidationItem , 0 , len ( items ) )
for _ , item := range items {
if _ , ok := rejected [ item . CorrectionIndex ] ; ok {
continue
}
out = append ( out , item )
}
return out
}
func rejectBatch ( items [ ] LLMValidationItem , reasonCode string , message string ) [ ] Decision {
out := make ( [ ] Decision , 0 , len ( items ) )
for _ , item := range items {
2026-09-24 17:14:33 +00:00
out = append ( out , unavailable ( item . CorrectionIndex , reasonCode , message ) )
2026-05-23 10:07:06 -05:00
}
return out
}
2026-09-24 17:14:33 +00:00
func batchProposalIndices ( items [ ] LLMValidationItem ) [ ] int {
indices := make ( [ ] int , len ( items ) )
for i , item := range items {
indices [ i ] = item . CorrectionIndex
2026-05-23 10:07:06 -05:00
}
2026-09-24 17:14:33 +00:00
return indices
2026-05-23 10:07:06 -05:00
}
2026-09-24 17:14:33 +00:00
func decisionDisposition ( approved bool ) DecisionDisposition {
if approved {
return DispositionApproved
2026-05-23 10:07:06 -05:00
}
2026-09-24 17:14:33 +00:00
return DispositionRejected
2026-05-23 10:07:06 -05:00
}