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 }