Implement raw module output contracts
This commit is contained in:
@@ -15,7 +15,6 @@ import (
|
||||
"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/validate"
|
||||
)
|
||||
|
||||
type Registries struct {
|
||||
@@ -51,11 +50,11 @@ type RunInput struct {
|
||||
}
|
||||
|
||||
type RunOutput struct {
|
||||
Manifest artifacts.RunManifest `json:"manifest"`
|
||||
Approved []artifacts.Artifact `json:"approved,omitempty"`
|
||||
Rejected []artifacts.RejectedArtifact `json:"rejected,omitempty"`
|
||||
Warnings []contracts.Warning `json:"warnings,omitempty"`
|
||||
OutputFiles []contracts.OutputFile `json:"-"`
|
||||
Manifest artifacts.RunManifest `json:"manifest"`
|
||||
NormalizeOutputs []contracts.NormalizeOutput `json:"normalize_outputs,omitempty"`
|
||||
Rejected []contracts.RejectedOutput `json:"rejected,omitempty"`
|
||||
Warnings []contracts.Warning `json:"warnings,omitempty"`
|
||||
OutputFiles []contracts.OutputFile `json:"-"`
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err error) {
|
||||
@@ -126,9 +125,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
return failOutput(output), fmt.Errorf("validate chunks from chunker %q: %w", chunker.Key(), err)
|
||||
}
|
||||
|
||||
nextCandidateIndex := 0
|
||||
for _, lane := range input.Pipeline.ArtifactLanes {
|
||||
if err := r.runLane(ctx, input, doc, sourceInput, sessionID, canonicalChunks, lane, &output, &nextCandidateIndex); err != nil {
|
||||
if err := r.runLane(ctx, input, doc, sourceInput, sessionID, canonicalChunks, lane, &output); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
}
|
||||
@@ -146,13 +144,13 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
}
|
||||
attachModuleManifestMetadata(&output, "output", encoder)
|
||||
encoded, err := encoder.Encode(ctx, contracts.OutputRequest{
|
||||
Manifest: output.Manifest,
|
||||
Approved: output.Approved,
|
||||
Rejected: output.Rejected,
|
||||
Warnings: output.Warnings,
|
||||
LLMProfile: input.Pipeline.Output.LLMProfile,
|
||||
Options: cloneOptions(input.Pipeline.Output.Options),
|
||||
Metadata: input.Metadata,
|
||||
Manifest: output.Manifest,
|
||||
NormalizeOutputs: cloneNormalizeOutputs(output.NormalizeOutputs),
|
||||
Rejected: cloneRejectedOutputs(output.Rejected),
|
||||
Warnings: output.Warnings,
|
||||
LLMProfile: input.Pipeline.Output.LLMProfile,
|
||||
Options: cloneOptions(input.Pipeline.Output.Options),
|
||||
Metadata: input.Metadata,
|
||||
})
|
||||
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
||||
if err != nil {
|
||||
@@ -167,7 +165,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (r *Runner) runLane(ctx context.Context, input RunInput, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []contracts.SourceChunk, lane ResolvedArtifactLane, output *RunOutput, nextCandidateIndex *int) error {
|
||||
func (r *Runner) runLane(ctx context.Context, input RunInput, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []contracts.SourceChunk, lane ResolvedArtifactLane, output *RunOutput) error {
|
||||
extractor, err := r.registries.Extractors.Build(lane.Extract.Module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build extractor %q for lane %q: %w", lane.Extract.Module, lane.ID, err)
|
||||
@@ -182,19 +180,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, doc *source.Source
|
||||
}
|
||||
setLaneManifestMetadata(output, lane.ID, extractor, merger, normalizer)
|
||||
|
||||
var validators []validatorExecution
|
||||
if len(lane.Validators) > 0 {
|
||||
validators, err = r.buildConfiguredValidators(lane)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
for _, validator := range extractor.Validators() {
|
||||
validators = append(validators, validatorExecution{validator: validator})
|
||||
}
|
||||
}
|
||||
|
||||
chunkArtifacts := make([]contracts.ChunkArtifacts, 0, len(chunks))
|
||||
extractOutputs := make([]contracts.ExtractOutput, 0, len(chunks))
|
||||
for index := range chunks {
|
||||
chunk := chunks[index]
|
||||
result, err := extractor.Extract(ctx, contracts.ExtractionRequest{
|
||||
@@ -212,21 +198,20 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, doc *source.Source
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract lane %q chunk %q with extractor %q: %w", lane.ID, chunk.ID, extractor.Key(), err)
|
||||
}
|
||||
|
||||
candidates, err := normalizeCandidates(extractor, result.Candidates, nextCandidateIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chunkArtifacts = append(chunkArtifacts, contracts.ChunkArtifacts{
|
||||
Chunk: chunk,
|
||||
Candidates: candidates,
|
||||
})
|
||||
extractOutput := result.Output
|
||||
extractOutput.LaneID = lane.ID
|
||||
extractOutput.ExtractorKey = extractor.Key()
|
||||
extractOutput.SourceID = doc.ID
|
||||
extractOutput.ChunkID = chunk.ID
|
||||
extractOutput.ChunkIndex = chunk.Index
|
||||
extractOutput.Payload.Warnings = append(extractOutput.Payload.Warnings, result.Warnings...)
|
||||
extractOutputs = append(extractOutputs, cloneExtractOutput(extractOutput))
|
||||
}
|
||||
|
||||
mergeResult, err := merger.Merge(ctx, contracts.MergeRequest{
|
||||
Source: doc,
|
||||
LaneID: lane.ID,
|
||||
ChunkArtifacts: chunkArtifacts,
|
||||
ExtractOutputs: cloneExtractOutputs(extractOutputs),
|
||||
LLMProfile: lane.Merge.LLMProfile,
|
||||
Options: cloneOptions(lane.Merge.Options),
|
||||
Metadata: input.Metadata,
|
||||
@@ -239,7 +224,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, doc *source.Source
|
||||
normalizeResult, err := normalizer.Normalize(ctx, contracts.NormalizeRequest{
|
||||
Source: doc,
|
||||
LaneID: lane.ID,
|
||||
Candidates: mergeResult.Candidates,
|
||||
MergeOutput: cloneMergeOutput(mergeResult.Output),
|
||||
SourceInput: sourceInput.Clone(),
|
||||
SessionID: sessionID,
|
||||
References: CloneReferenceSet(lane.NormalizeReferences.ReferenceSet),
|
||||
@@ -252,21 +237,12 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, doc *source.Source
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize lane %q with normalizer %q: %w", lane.ID, normalizer.Key(), err)
|
||||
}
|
||||
|
||||
if err := validateCandidateEnvelope(extractor, normalizeResult.Candidates); err != nil {
|
||||
return fmt.Errorf("validate normalized candidates for lane %q: %w", lane.ID, err)
|
||||
}
|
||||
|
||||
approved, rejected, warnings, err := runValidators(ctx, extractor.Key(), validators, doc, normalizeResult.Candidates, input.Metadata)
|
||||
output.Warnings = append(output.Warnings, warnings...)
|
||||
output.Rejected = append(output.Rejected, rejected...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, candidate := range approved {
|
||||
output.Approved = append(output.Approved, artifacts.ArtifactFromCandidate(candidate))
|
||||
}
|
||||
normalizeOutput := normalizeResult.Output
|
||||
normalizeOutput.LaneID = lane.ID
|
||||
normalizeOutput.NormalizerKey = normalizer.Key()
|
||||
normalizeOutput.SourceID = doc.ID
|
||||
normalizeOutput.Payload.Warnings = append(normalizeOutput.Payload.Warnings, normalizeResult.Warnings...)
|
||||
output.NormalizeOutputs = append(output.NormalizeOutputs, cloneNormalizeOutput(normalizeOutput))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -628,6 +604,59 @@ func cloneWarnings(warnings []contracts.Warning) []contracts.Warning {
|
||||
return append([]contracts.Warning(nil), warnings...)
|
||||
}
|
||||
|
||||
func cloneRawPayload(payload contracts.RawPayload) contracts.RawPayload {
|
||||
return contracts.RawPayload{
|
||||
Content: append([]byte(nil), payload.Content...),
|
||||
MediaType: payload.MediaType,
|
||||
Metadata: cloneMetadata(payload.Metadata),
|
||||
Warnings: cloneWarnings(payload.Warnings),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExtractOutput(output contracts.ExtractOutput) contracts.ExtractOutput {
|
||||
output.Payload = cloneRawPayload(output.Payload)
|
||||
return output
|
||||
}
|
||||
|
||||
func cloneExtractOutputs(outputs []contracts.ExtractOutput) []contracts.ExtractOutput {
|
||||
if len(outputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]contracts.ExtractOutput, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
out = append(out, cloneExtractOutput(output))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMergeOutput(output contracts.MergeOutput) contracts.MergeOutput {
|
||||
output.Payload = cloneRawPayload(output.Payload)
|
||||
return output
|
||||
}
|
||||
|
||||
func cloneNormalizeOutput(output contracts.NormalizeOutput) contracts.NormalizeOutput {
|
||||
output.Payload = cloneRawPayload(output.Payload)
|
||||
return output
|
||||
}
|
||||
|
||||
func cloneNormalizeOutputs(outputs []contracts.NormalizeOutput) []contracts.NormalizeOutput {
|
||||
if len(outputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]contracts.NormalizeOutput, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
out = append(out, cloneNormalizeOutput(output))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRejectedOutputs(rejected []contracts.RejectedOutput) []contracts.RejectedOutput {
|
||||
if len(rejected) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]contracts.RejectedOutput(nil), rejected...)
|
||||
}
|
||||
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
@@ -640,115 +669,3 @@ func pipelineUsesConfiguredValidators(pipeline ResolvedPipeline) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeCandidates(extractor contracts.Extractor, candidates []artifacts.ArtifactCandidate, nextIndex *int) ([]artifacts.ArtifactCandidate, error) {
|
||||
normalized := make([]artifacts.ArtifactCandidate, 0, len(candidates))
|
||||
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
|
||||
}
|
||||
|
||||
func validateCandidateEnvelope(extractor contracts.Extractor, candidates []artifacts.ArtifactCandidate) error {
|
||||
seen := make(map[int]struct{}, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if _, ok := seen[candidate.Index]; ok {
|
||||
return fmt.Errorf("candidate index %d is duplicated", candidate.Index)
|
||||
}
|
||||
seen[candidate.Index] = struct{}{}
|
||||
|
||||
if candidate.ExtractorKey == "" {
|
||||
return fmt.Errorf("candidate index %d extractor_key must not be empty", candidate.Index)
|
||||
}
|
||||
if candidate.ExtractorKey != extractor.Key() {
|
||||
return fmt.Errorf("candidate index %d extractor_key %q does not match extractor %q", candidate.Index, candidate.ExtractorKey, extractor.Key())
|
||||
}
|
||||
if candidate.ArtifactType == "" {
|
||||
return fmt.Errorf("candidate index %d artifact_type must not be empty", candidate.Index)
|
||||
}
|
||||
if candidate.ArtifactType != extractor.ArtifactType() {
|
||||
return fmt.Errorf("candidate index %d artifact_type %q does not match extractor %q artifact type %q", candidate.Index, candidate.ArtifactType, extractor.Key(), extractor.ArtifactType())
|
||||
}
|
||||
if candidate.SchemaVersion == "" {
|
||||
return fmt.Errorf("candidate index %d schema_version must not be empty", candidate.Index)
|
||||
}
|
||||
if candidate.SchemaVersion != extractor.SchemaVersion() {
|
||||
return fmt.Errorf("candidate index %d schema_version %q does not match extractor %q schema version %q", candidate.Index, candidate.SchemaVersion, extractor.Key(), extractor.SchemaVersion())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runValidators(ctx context.Context, extractorKey string, validators []validatorExecution, doc *source.SourceDocument, candidates []artifacts.ArtifactCandidate, metadata map[string]any) ([]artifacts.ArtifactCandidate, []artifacts.RejectedArtifact, []contracts.Warning, error) {
|
||||
eligible := candidates
|
||||
var rejected []artifacts.RejectedArtifact
|
||||
var warnings []contracts.Warning
|
||||
|
||||
for validatorIndex, execution := range validators {
|
||||
validator := execution.validator
|
||||
if validator == nil {
|
||||
return nil, rejected, warnings, fmt.Errorf("extractor %q validator[%d] must not be nil", extractorKey, validatorIndex)
|
||||
}
|
||||
result, err := validator.Validate(ctx, contracts.ValidationRequest{
|
||||
Source: doc,
|
||||
Candidates: eligible,
|
||||
LLMProfile: execution.binding.LLMProfile,
|
||||
Options: cloneOptions(execution.binding.Options),
|
||||
Metadata: metadata,
|
||||
})
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
if err != nil {
|
||||
return nil, rejected, warnings, fmt.Errorf("validate extractor %q with validator %q: %w", extractorKey, 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 := validate.EnforceDecisionCardinality(eligible, result.Decisions); err != nil {
|
||||
return nil, rejected, warnings, fmt.Errorf("validate extractor %q with validator %q: %w", extractorKey, validator.Name(), err)
|
||||
}
|
||||
|
||||
decisions := make(map[int]contracts.ValidationDecision, len(result.Decisions))
|
||||
for _, decision := range result.Decisions {
|
||||
decisions[decision.CandidateIndex] = decision
|
||||
}
|
||||
|
||||
nextEligible := make([]artifacts.ArtifactCandidate, 0, len(eligible))
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user