2026-07-03 12:50:09 +00:00
package runner
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/validators"
)
type ExtractorFactory interface {
Build ( key string ) ( contracts . Extractor , error )
}
type Runner struct {
extractors ExtractorFactory
}
func New ( extractors ExtractorFactory ) * Runner {
return & Runner { extractors : extractors }
}
type RunInput struct {
Source * source . SourceDocument
ExtractorKeys [ ] string
LLMClient contracts . StructuredLLMClient
Metadata map [ string ] any
}
type RunOutput struct {
Approved [ ] artifacts . Artifact ` json:"approved,omitempty" `
Rejected [ ] artifacts . RejectedArtifact ` json:"rejected,omitempty" `
Warnings [ ] contracts . Warning ` json:"warnings,omitempty" `
}
func ( r * Runner ) Run ( ctx context . Context , input RunInput ) ( RunOutput , error ) {
var output RunOutput
if r == nil {
return output , fmt . Errorf ( "runner must not be nil" )
}
if r . extractors == nil {
return output , fmt . Errorf ( "runner extractor factory must not be nil" )
}
if err := source . ValidateDocument ( input . Source ) ; err != nil {
return output , fmt . Errorf ( "validate source document: %w" , err )
}
if len ( input . ExtractorKeys ) == 0 {
return output , fmt . Errorf ( "extractor keys must not be empty" )
}
nextCandidateIndex := 0
for _ , extractorKey := range input . ExtractorKeys {
extractor , err := r . extractors . Build ( extractorKey )
if err != nil {
return output , fmt . Errorf ( "build extractor %q: %w" , extractorKey , err )
}
2026-07-03 08:26:51 -05:00
if extractor == nil {
return output , fmt . Errorf ( "build extractor %q: returned nil extractor" , extractorKey )
}
2026-07-03 12:50:09 +00:00
result , err := extractor . Extract ( ctx , contracts . ExtractionRequest {
Source : input . Source ,
LLMClient : input . LLMClient ,
Metadata : input . Metadata ,
} )
output . Warnings = append ( output . Warnings , result . Warnings ... )
if err != nil {
return output , fmt . Errorf ( "extract with extractor %q: %w" , extractor . Key ( ) , err )
}
candidates , err := normalizeCandidates ( extractor , result . Candidates , & nextCandidateIndex )
if err != nil {
return output , err
}
approved , rejected , warnings , err := runValidators ( ctx , extractor , input . Source , candidates , input . Metadata )
output . Warnings = append ( output . Warnings , warnings ... )
output . Rejected = append ( output . Rejected , rejected ... )
if err != nil {
return output , err
}
for _ , candidate := range approved {
output . Approved = append ( output . Approved , artifacts . ArtifactFromCandidate ( candidate ) )
}
}
return output , nil
}
2026-07-03 09:03:45 -05:00
func normalizeCandidates ( extractor contracts . Extractor , candidates [ ] artifacts . ArtifactCandidate , nextIndex * int ) ( [ ] artifacts . ArtifactCandidate , error ) {
normalized := make ( [ ] artifacts . ArtifactCandidate , 0 , len ( candidates ) )
2026-07-03 12:50:09 +00:00
for _ , candidate := range candidates {
candidate . Index = * nextIndex
* nextIndex = * nextIndex + 1
if candidate . ExtractorKey == "" {
candidate . ExtractorKey = extractor . Key ( )
} else if candidate . ExtractorKey != extractor . Key ( ) {
return nil , fmt . Errorf ( "candidate extractor_key %q does not match extractor %q" , candidate . ExtractorKey , extractor . Key ( ) )
}
if candidate . ArtifactType == "" {
candidate . ArtifactType = extractor . ArtifactType ( )
} else if candidate . ArtifactType != extractor . ArtifactType ( ) {
return nil , fmt . Errorf ( "candidate artifact_type %q does not match extractor %q artifact type %q" , candidate . ArtifactType , extractor . Key ( ) , extractor . ArtifactType ( ) )
}
if candidate . SchemaVersion == "" {
candidate . SchemaVersion = extractor . SchemaVersion ( )
} else if candidate . SchemaVersion != extractor . SchemaVersion ( ) {
return nil , fmt . Errorf ( "candidate schema_version %q does not match extractor %q schema version %q" , candidate . SchemaVersion , extractor . Key ( ) , extractor . SchemaVersion ( ) )
}
normalized = append ( normalized , candidate )
}
return normalized , nil
}
2026-07-03 09:03:45 -05:00
func runValidators ( ctx context . Context , extractor contracts . Extractor , doc * source . SourceDocument , candidates [ ] artifacts . ArtifactCandidate , metadata map [ string ] any ) ( [ ] artifacts . ArtifactCandidate , [ ] artifacts . RejectedArtifact , [ ] contracts . Warning , error ) {
2026-07-03 12:50:09 +00:00
eligible := candidates
var rejected [ ] artifacts . RejectedArtifact
var warnings [ ] contracts . Warning
2026-07-03 08:26:51 -05:00
for validatorIndex , validator := range extractor . Validators ( ) {
if validator == nil {
return nil , rejected , warnings , fmt . Errorf ( "extractor %q validator[%d] must not be nil" , extractor . Key ( ) , validatorIndex )
}
2026-07-03 12:50:09 +00:00
result , err := validator . Validate ( ctx , contracts . ValidationRequest {
Source : doc ,
Candidates : eligible ,
Metadata : metadata ,
} )
warnings = append ( warnings , result . Warnings ... )
if err != nil {
return nil , rejected , warnings , fmt . Errorf ( "validate extractor %q with validator %q: %w" , extractor . Key ( ) , validator . Name ( ) , err )
}
if result . ValidatorName != validator . Name ( ) {
return nil , rejected , warnings , fmt . Errorf ( "validator %q returned result for %q" , validator . Name ( ) , result . ValidatorName )
}
if err := validators . EnforceDecisionCardinality ( eligible , result . Decisions ) ; err != nil {
return nil , rejected , warnings , fmt . Errorf ( "validate extractor %q with validator %q: %w" , extractor . Key ( ) , validator . Name ( ) , err )
}
decisions := make ( map [ int ] contracts . ValidationDecision , len ( result . Decisions ) )
for _ , decision := range result . Decisions {
decisions [ decision . CandidateIndex ] = decision
}
2026-07-03 09:03:45 -05:00
nextEligible := make ( [ ] artifacts . ArtifactCandidate , 0 , len ( eligible ) )
2026-07-03 12:50:09 +00:00
for _ , candidate := range eligible {
decision := decisions [ candidate . Index ]
if decision . Approved {
nextEligible = append ( nextEligible , candidate )
continue
}
rejected = append ( rejected , artifacts . RejectedArtifact {
Candidate : candidate ,
ValidatorName : result . ValidatorName ,
ReasonCode : decision . ReasonCode ,
Message : decision . Message ,
} )
}
eligible = nextEligible
}
return eligible , rejected , warnings , nil
}