211 lines
7.3 KiB
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
|
|
}
|