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"
2026-10-11 01:40:38 +00:00
"gitea.maximumdirect.net/eric/audita/v3/internal/core/chunking"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/config"
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/responseschema"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/stagename"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/structuredoutput"
"gitea.maximumdirect.net/eric/audita/v3/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-09-25 12:23:10 +00:00
for i := range validationReq . Items {
validationReq . Items [ i ] = fitValidationContext ( validationReq . Items [ i ] , maxTokens , v . estimator )
}
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 )
2026-09-25 13:26:51 +00:00
llmDecisions := make ( [ ] Decision , 0 )
partialResult := func ( ) Result {
decisions := append ( [ ] Decision ( nil ) , immediate ... )
decisions = append ( decisions , oversized ... )
decisions = append ( decisions , llmDecisions ... )
return Result { ValidatorName : v . name , Decisions : decisions , Operations : operations }
}
2026-09-24 17:14:33 +00:00
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 {
2026-09-25 13:26:51 +00:00
result := partialResult ( )
all := result . Decisions
2026-05-23 10:07:06 -05:00
if err := EnforceDecisionCardinality ( req . CandidateProposal , all ) ; err != nil {
2026-09-25 13:26:51 +00:00
return result , 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-25 13:26:51 +00:00
return result , 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-25 13:26:51 +00:00
return partialResult ( ) , err
2026-05-12 01:25:52 +00:00
}
for _ , batch := range batches {
2026-09-24 17:14:33 +00:00
if err := ctx . Err ( ) ; err != nil {
2026-09-25 13:26:51 +00:00
return partialResult ( ) , err
2026-09-24 17:14:33 +00:00
}
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-25 13:26:51 +00:00
return partialResult ( ) , 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-09-25 11:25:22 +00:00
writeInteraction := func ( responsePayload any , callErr error ) InteractionArtifacts {
if req . DiagnosticsWriter == nil {
return InteractionArtifacts { }
}
2026-09-24 21:48:18 +00:00
var diagnosticErr error
2026-09-25 11:25:22 +00:00
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-09-25 12:38:23 +00:00
"validator_name" : v . name ,
"validator_type" : v . validatorType ,
"batch_index" : batch . BatchIndex ,
"proposal_indices" : batchProposalIndices ( batch . Items ) ,
"prompt" : completion . Prompt ,
"response_schema" : responseSchema . DiagnosticsMap ( ) ,
2026-05-13 02:10:24 +00:00
} ,
2026-09-24 02:07:17 +00:00
completion . Prepared ,
2026-09-25 11:25:22 +00:00
responsePayload ,
errPayload ( callErr ) ,
2026-05-12 01:25:52 +00:00
)
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-09-25 11:25:22 +00:00
return artifacts
2026-05-12 01:25:52 +00:00
}
if err != nil {
2026-09-25 11:25:22 +00:00
writeInteraction ( map [ string ] any { "decoded" : response , "final_content" : completion . FinalContent , "model" : completion . Model } , err )
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-25 13:26:51 +00:00
return partialResult ( ) , fmt . Errorf ( "LLM validator %q completion failed: %w" , v . name , err )
2026-05-12 01:25:52 +00:00
}
2026-09-25 11:25:22 +00:00
reconciled := reconcileLLMResponse ( batch . Items , response )
artifacts := writeInteraction ( map [ string ] any { "decoded" : response , "final_content" : completion . FinalContent , "model" : completion . Model , "reconciliation" : reconciled } , nil )
if len ( reconciled . UnavailableIndices ) > 0 {
2026-09-24 17:14:33 +00:00
operation . FailureReason = outcomes . ReasonValidationDecisionsInvalid
2026-09-25 11:25:22 +00:00
operation . ProposalIndices = reconciled . UnavailableIndices
} else {
operation . Succeeded = true
2026-09-25 13:26:51 +00:00
operation . RecoveryReasons = outcomes . ExecutionRecoveries ( completion . Execution )
2026-09-25 11:25:22 +00:00
if len ( reconciled . IgnoredIndices ) > 0 {
operation . RecoveryReasons = append ( operation . RecoveryReasons , outcomes . RecoveryValidationDecisionsFiltered )
}
2026-09-24 17:14:33 +00:00
}
operations = append ( operations , operation )
2026-09-25 11:25:22 +00:00
for i := range reconciled . Decisions {
reconciled . Decisions [ i ] . DiagnosticArtifactPath = artifacts . ResponsePayloadPath
2026-05-12 01:25:52 +00:00
}
2026-09-25 11:25:22 +00:00
llmDecisions = append ( llmDecisions , reconciled . Decisions ... )
2026-05-12 01:25:52 +00:00
}
2026-09-25 13:26:51 +00:00
result := partialResult ( )
all := result . Decisions
2026-05-12 01:25:52 +00:00
if err := EnforceDecisionCardinality ( req . CandidateProposal , all ) ; err != nil {
2026-09-25 13:26:51 +00:00
return result , 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-25 13:26:51 +00:00
return result , 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 )
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 )
2026-09-25 13:26:51 +00:00
var segments [ ] schema . Segment
positions := make ( map [ int ] int )
vocabulary := NewProtectedVocabulary ( req . Glossary )
2026-05-12 01:25:52 +00:00
if req . WorkingTranscript != nil {
2026-09-25 13:26:51 +00:00
segments = req . WorkingTranscript . Segments
for i , seg := range segments {
positions [ seg . ID ] = i
2026-05-12 01:25:52 +00:00
}
}
for _ , p := range req . CandidateProposal {
2026-09-25 13:26:51 +00:00
position , ok := positions [ p . TargetSegmentID ]
2026-05-12 01:25:52 +00:00
if ! ok {
immediate = append ( immediate , rejection ( p . ProposalIndex , ReasonMissingTargetSegment , "target segment was not found" ) )
continue
}
2026-09-25 13:26:51 +00:00
seg := segments [ position ]
2026-05-12 01:25:52 +00:00
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
}
2026-09-25 13:26:51 +00:00
neighbors , glossaryEntries := validationContext ( segments , position , req . Glossary , vocabulary , preview . CorrectedSegmentText )
2026-05-12 01:25:52 +00:00
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 ... ) ,
2026-09-25 12:23:10 +00:00
NeighboringSegments : neighbors ,
GlossaryEntries : glossaryEntries ,
2026-05-12 01:25:52 +00:00
} )
}
return LLMValidationRequest {
ValidatorName : validatorName ,
ValidatorType : validatorType ,
ModuleKey : req . ModuleKey ,
ModuleInstance : req . ModuleInstance ,
ReplacementPolicy : string ( req . ReplacementPolicy ) ,
Glossary : req . Glossary ,
Items : items ,
} , immediate
}
2026-09-25 11:25:22 +00:00
type validationReconciliation struct {
Decisions [ ] Decision ` json:"decisions" `
UnavailableIndices [ ] int ` json:"unavailable_indices,omitempty" `
IgnoredIndices [ ] int ` json:"ignored_indices,omitempty" `
}
func reconcileLLMResponse ( items [ ] LLMValidationItem , response LLMValidationResponse ) validationReconciliation {
result := validationReconciliation { Decisions : make ( [ ] Decision , 0 , len ( items ) ) }
seen := make ( map [ int ] [ ] LLMValidationDecision , len ( items ) )
2026-05-12 01:25:52 +00:00
for _ , d := range response . Validations {
2026-09-25 12:38:23 +00:00
if d . CorrectionIndex < 1 || d . CorrectionIndex > len ( items ) {
2026-09-25 11:25:22 +00:00
result . IgnoredIndices = append ( result . IgnoredIndices , d . CorrectionIndex )
continue
2026-05-12 01:25:52 +00:00
}
2026-09-25 11:25:22 +00:00
seen [ d . CorrectionIndex ] = append ( seen [ d . CorrectionIndex ] , d )
2026-05-12 01:25:52 +00:00
}
2026-09-25 12:38:23 +00:00
for i , item := range items {
entries := seen [ i + 1 ]
2026-09-25 11:25:22 +00:00
if len ( entries ) == 0 {
result . Decisions = append ( result . Decisions , unavailable ( item . CorrectionIndex , ReasonMissingValidationDecision , "required model validation decision was missing" ) )
result . UnavailableIndices = append ( result . UnavailableIndices , item . CorrectionIndex )
continue
}
if len ( entries ) > 1 {
result . Decisions = append ( result . Decisions , unavailable ( item . CorrectionIndex , ReasonDuplicateValidationDecision , "required model validation decision appeared more than once" ) )
result . UnavailableIndices = append ( result . UnavailableIndices , item . CorrectionIndex )
continue
}
d := entries [ 0 ]
if d . Confidence < 0.0 || d . Confidence > 1.0 {
result . Decisions = append ( result . Decisions , unavailable ( item . CorrectionIndex , ReasonInvalidValidationDecision , "required model validation decision had invalid confidence" ) )
result . UnavailableIndices = append ( result . UnavailableIndices , item . CorrectionIndex )
continue
2026-05-12 01:25:52 +00:00
}
reasonCode := ReasonApproved
if ! d . Approved {
reasonCode = "llm_rejected"
}
2026-09-25 11:25:22 +00:00
result . Decisions = append ( result . Decisions , Decision {
2026-05-12 01:25:52 +00:00
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 ) ,
} )
}
2026-09-25 11:25:22 +00:00
return result
2026-05-12 01:25:52 +00:00
}
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
}