Files
notarius/internal/framework/semanticreconcile/engine.go

211 lines
7.3 KiB
Go

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
}