Add shared semantic reconciliation engine
This commit is contained in:
210
internal/framework/semanticreconcile/engine.go
Normal file
210
internal/framework/semanticreconcile/engine.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package semanticreconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
)
|
||||
|
||||
// PromptSpec identifies the exact prompt selected by a reconciliation owner.
|
||||
type PromptSpec struct {
|
||||
ID string
|
||||
Version string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// Validate rejects incomplete prompt identity and non-canonical digests.
|
||||
func (spec PromptSpec) Validate() error {
|
||||
if strings.TrimSpace(spec.ID) == "" {
|
||||
return fmt.Errorf("semantic reconciliation prompt ID must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(spec.Version) == "" {
|
||||
return fmt.Errorf("semantic reconciliation prompt version must not be empty")
|
||||
}
|
||||
if err := validateSHA256(spec.SHA256); err != nil {
|
||||
return fmt.Errorf("semantic reconciliation prompt digest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultPromptSpec returns the identity of the core-owned generic prompt.
|
||||
func DefaultPromptSpec() (PromptSpec, error) {
|
||||
digest, err := PromptHash()
|
||||
if err != nil {
|
||||
return PromptSpec{}, err
|
||||
}
|
||||
return PromptSpec{ID: PromptID, Version: PromptVersion, SHA256: digest}, nil
|
||||
}
|
||||
|
||||
// Request contains one typed owner's source-backed reconciliation input.
|
||||
type Request struct {
|
||||
StageName string
|
||||
Source *source.SourceDocument
|
||||
Candidates []Candidate
|
||||
ProfileID string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// ResultDisposition classifies a provider-neutral reconciliation outcome.
|
||||
type ResultDisposition uint8
|
||||
|
||||
const (
|
||||
Complete ResultDisposition = iota + 1
|
||||
RetryableInvalidStructuredOutput
|
||||
RetryableDiscardedProposalGroups
|
||||
SkippedInsufficientCandidates
|
||||
SkippedLimitExceeded
|
||||
)
|
||||
|
||||
// Result owns the safe plan and neutral diagnostics from one call.
|
||||
type Result struct {
|
||||
disposition ResultDisposition
|
||||
plan Plan
|
||||
issues []Issue
|
||||
discardedGroupCount int
|
||||
candidateMappings []CandidateMapping
|
||||
}
|
||||
|
||||
// Disposition returns the classified outcome.
|
||||
func (result Result) Disposition() ResultDisposition { return result.disposition }
|
||||
|
||||
// Plan returns an independently owned safe plan.
|
||||
func (result Result) Plan() Plan { return result.planCopy() }
|
||||
|
||||
// Issues returns an owned copy of stable proposal issues.
|
||||
func (result Result) Issues() []Issue { return append([]Issue(nil), result.issues...) }
|
||||
|
||||
// DiscardedGroupCount returns the number of excluded proposal groups.
|
||||
func (result Result) DiscardedGroupCount() int { return result.discardedGroupCount }
|
||||
|
||||
// CandidateMappings returns the request-local handle mapping used for this call.
|
||||
func (result Result) CandidateMappings() []CandidateMapping {
|
||||
return append([]CandidateMapping(nil), result.candidateMappings...)
|
||||
}
|
||||
|
||||
func (result Result) planCopy() Plan {
|
||||
return Plan{groups: result.plan.Groups()}
|
||||
}
|
||||
|
||||
// Engine prepares bounded material, performs one structured completion, and
|
||||
// classifies the deterministic assessment without applying it to typed values.
|
||||
type Engine struct {
|
||||
client contracts.StructuredLLMClient
|
||||
prompt PromptSpec
|
||||
schema llm.ResponseSchema
|
||||
limits Limits
|
||||
}
|
||||
|
||||
// NewEngine constructs a reconciliation engine using the core response schema.
|
||||
func NewEngine(client contracts.StructuredLLMClient, prompt PromptSpec, limits Limits) (*Engine, error) {
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("construct semantic reconciliation engine: LLM client must not be nil")
|
||||
}
|
||||
if err := prompt.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("construct semantic reconciliation engine: %w", err)
|
||||
}
|
||||
if err := limits.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("construct semantic reconciliation engine: %w", err)
|
||||
}
|
||||
schema, err := LoadResponseSchema()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("construct semantic reconciliation engine: load response schema: %w", err)
|
||||
}
|
||||
return newEngine(client, prompt, schema, limits), nil
|
||||
}
|
||||
|
||||
func newEngine(client contracts.StructuredLLMClient, prompt PromptSpec, schema llm.ResponseSchema, limits Limits) *Engine {
|
||||
return &Engine{client: client, prompt: prompt, schema: schema, limits: limits}
|
||||
}
|
||||
|
||||
// Reconcile prepares and assesses one request. Retryable semantic outcomes are
|
||||
// returned as results; provider and transport failures remain errors.
|
||||
func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, error) {
|
||||
if err := engine.validate(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if ctx == nil {
|
||||
return Result{}, fmt.Errorf("semantic reconciliation %q: context must not be nil", request.StageName)
|
||||
}
|
||||
if strings.TrimSpace(request.StageName) == "" {
|
||||
return Result{}, fmt.Errorf("semantic reconciliation stage name must not be empty")
|
||||
}
|
||||
if request.Source == nil {
|
||||
return Result{}, fmt.Errorf("semantic reconciliation %q: source document must not be nil", request.StageName)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, fmt.Errorf("semantic reconciliation %q: context error before preparation: %w", request.StageName, err)
|
||||
}
|
||||
|
||||
preparation, err := Prepare(request.Source, request.Candidates, engine.limits)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("semantic reconciliation %q: prepare materials: %w", request.StageName, err)
|
||||
}
|
||||
result := Result{candidateMappings: preparation.CandidateMappings()}
|
||||
switch preparation.Disposition() {
|
||||
case InsufficientCandidates:
|
||||
result.disposition = SkippedInsufficientCandidates
|
||||
return result, nil
|
||||
case LimitExceeded:
|
||||
result.disposition = SkippedLimitExceeded
|
||||
return result, nil
|
||||
case Ready:
|
||||
default:
|
||||
return Result{}, fmt.Errorf("semantic reconciliation %q: unknown preparation disposition %d", request.StageName, preparation.Disposition())
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, fmt.Errorf("semantic reconciliation %q: context error before completion: %w", request.StageName, err)
|
||||
}
|
||||
var response ProposalResponse
|
||||
_, err = engine.client.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: request.StageName,
|
||||
PromptID: engine.prompt.ID,
|
||||
PromptVersion: engine.prompt.Version,
|
||||
ProfileID: request.ProfileID,
|
||||
SessionID: request.SessionID,
|
||||
Inputs: preparation.Materials(),
|
||||
}, &response)
|
||||
if err != nil {
|
||||
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
|
||||
result.disposition = RetryableInvalidStructuredOutput
|
||||
return result, nil
|
||||
}
|
||||
return Result{}, fmt.Errorf("semantic reconciliation %q: complete structured output: %w", request.StageName, err)
|
||||
}
|
||||
|
||||
assessment := preparation.Assess(response)
|
||||
result.plan = assessment.Plan()
|
||||
result.issues = assessment.Issues()
|
||||
result.discardedGroupCount = assessment.DiscardedGroupCount()
|
||||
if result.discardedGroupCount > 0 {
|
||||
result.disposition = RetryableDiscardedProposalGroups
|
||||
} else {
|
||||
result.disposition = Complete
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (engine *Engine) validate() error {
|
||||
if engine == nil {
|
||||
return fmt.Errorf("semantic reconciliation engine must not be nil")
|
||||
}
|
||||
if engine.client == nil {
|
||||
return fmt.Errorf("semantic reconciliation engine: LLM client must not be nil")
|
||||
}
|
||||
if err := engine.prompt.Validate(); err != nil {
|
||||
return fmt.Errorf("semantic reconciliation engine: invalid construction state: %w", err)
|
||||
}
|
||||
if err := engine.limits.Validate(); err != nil {
|
||||
return fmt.Errorf("semantic reconciliation engine: invalid construction state: %w", err)
|
||||
}
|
||||
if err := validateResponseSchemaIdentity(engine.schema); err != nil {
|
||||
return fmt.Errorf("semantic reconciliation engine: invalid construction state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user