// Package proposal_generation provides shared, deterministic LLM-backed // proposal-generation helpers. It only produces candidate proposals; validation // and application remain runner responsibilities. package proposal_generation import ( "context" "errors" "fmt" "path/filepath" "strings" "gitea.maximumdirect.net/eric/audita/internal/core/config" "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" "gitea.maximumdirect.net/eric/audita/internal/framework/llm" "gitea.maximumdirect.net/eric/audita/internal/framework/outcomes" "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" "gitea.maximumdirect.net/eric/audita/internal/framework/responseschema" "gitea.maximumdirect.net/eric/audita/internal/framework/stagename" "gitea.maximumdirect.net/eric/audita/internal/framework/structuredoutput" ) // InteractionDiagnosticsWriter writes machine-readable prompt/response artifacts. type InteractionDiagnosticsWriter interface { WriteInteraction(stage string, requestMetadata any, requestPayload any, responsePayload any, errorPayload any) (InteractionArtifacts, error) } // InteractionArtifacts contains written diagnostics paths. type InteractionArtifacts struct { RequestMetadataPath string `json:"request_metadata_path,omitempty"` RequestPayloadPath string `json:"request_payload_path,omitempty"` ResponsePayloadPath string `json:"response_payload_path,omitempty"` ErrorPayloadPath string `json:"error_payload_path,omitempty"` } // StructuredCorrectionProposal is one LLM response correction payload. type StructuredCorrectionProposal struct { TargetSegmentID int `json:"id"` OriginalText string `json:"original_text"` CorrectedText string `json:"corrected_text"` Confidence float64 `json:"confidence"` } // StructuredCorrectionSet is the reusable structured LLM response model for // candidate correction proposals. type StructuredCorrectionSet struct { Corrections []StructuredCorrectionProposal `json:"corrections"` } // Request captures reusable proposal-generation inputs for future modules. type Request struct { // ReportDiagnosticError may be called concurrently; it must not affect processing. ReportDiagnosticError func(error) `json:"-"` ModuleKey string `json:"module_key"` ModuleInstance string `json:"module_instance"` ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"` WorkingTranscript *schema.Transcript `json:"-"` Section *contracts.SectionMetadata `json:"section,omitempty"` Glossary *schema.Glossary `json:"-"` Config *config.Config `json:"-"` PromptID string `json:"prompt_id"` PromptVersion string `json:"prompt_version"` Variables map[string]string `json:"variables"` StageName string `json:"stage_name,omitempty"` StartIndex int `json:"start_index"` LLMClient contracts.StructuredLLMClient DiagnosticsDir string DiagnosticsWriter InteractionDiagnosticsWriter } // Result contains generated candidate proposals and optional diagnostics paths. type Result struct { Corrections []proposals.CorrectionProposal `json:"corrections"` Enriched []proposals.EnrichedCorrectionProposal `json:"enriched"` Operations []outcomes.Operation `json:"operations,omitempty"` Artifacts InteractionArtifacts `json:"artifacts,omitempty"` } // GenerateCandidates executes one structured LLM call and deterministically maps // its correction-set response into framework proposal types. func GenerateCandidates(ctx context.Context, req Request) (Result, error) { if strings.TrimSpace(req.ModuleKey) == "" { return Result{}, fmt.Errorf("module key must not be empty") } if strings.TrimSpace(req.ModuleInstance) == "" { return Result{}, fmt.Errorf("module instance must not be empty") } if req.StartIndex < 0 { return Result{}, fmt.Errorf("start index must be non-negative") } if req.LLMClient == nil { return Result{}, fmt.Errorf("structured LLM client is required") } if strings.TrimSpace(req.PromptID) == "" { return Result{}, fmt.Errorf("prompt ID must not be empty") } stage := strings.TrimSpace(req.StageName) if stage == "" { stage = stagename.ProposalGeneration(req.ModuleInstance, sectionIndexPtr(req.Section)) } var writer InteractionDiagnosticsWriter if req.DiagnosticsWriter != nil { writer = req.DiagnosticsWriter } else if strings.TrimSpace(req.DiagnosticsDir) != "" { writer = diagnosticsWriterAdapter{ writer: llm.NewDiagnosticsWriter( filepath.Join(req.DiagnosticsDir, req.ModuleInstance), llm.ConfiguredSecrets(req.Config), ), } } var ( response StructuredCorrectionSet completion contracts.StructuredCompletionResponse callErr error artifacts InteractionArtifacts ) completion, callErr = req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ PromptID: req.PromptID, PromptVersion: req.PromptVersion, Variables: req.Variables, Schema: responseschema.CorrectionSetKey, Context: structuredoutput.DiagnosticContext{Role: "proposal", ModuleKey: req.ModuleKey, ModuleInstance: req.ModuleInstance, SectionIndex: sectionIndexPtr(req.Section)}, }, &response) sectionIndex := 0 if req.Section != nil { sectionIndex = req.Section.Index } operation := outcomes.Operation{ Reference: outcomes.Reference{ ModuleKey: req.ModuleKey, ModuleInstance: req.ModuleInstance, SectionIndex: sectionIndex, Kind: outcomes.OperationProposal, }, Counts: completion.Execution, } requestMetadata := map[string]any{ "module_key": req.ModuleKey, "module_instance": req.ModuleInstance, "replacement_policy": req.ReplacementPolicy, "section": req.Section, "start_index": req.StartIndex, "prompt": completion.Prompt, } requestMetadata["response_schema"] = responseschema.MustLookup(responseschema.CorrectionSetKey).DiagnosticsMap() if writer != nil { var diagnosticErr error artifacts, diagnosticErr = writer.WriteInteraction( stage, requestMetadata, completion.Prepared, map[string]any{"decoded": response, "final_content": completion.FinalContent, "model": completion.Model}, errPayload(callErr), ) if diagnosticErr != nil && req.ReportDiagnosticError != nil { req.ReportDiagnosticError(fmt.Errorf("proposal interaction %s: %w", stage, diagnosticErr)) } } if callErr != nil { var failure *structuredoutput.Failure if errors.As(callErr, &failure) { operation.FailureReason = failure.Reason } if operation.FailureReason == "" { operation.FailureReason = outcomes.ReasonInternalFailure } result := Result{Operations: []outcomes.Operation{operation}, Artifacts: artifacts} if structuredoutput.IsRecoverable(callErr) && failure != nil && failure.Reason != "" { return result, nil } return result, fmt.Errorf("proposal generation completion failed: %w", callErr) } operation.Succeeded = true if completion.Execution.StructuralRepairs > 0 { operation.RecoveryReasons = append(operation.RecoveryReasons, outcomes.RecoveryOutputRepaired) } if completion.Execution.TransientRetries > 0 { operation.RecoveryReasons = append(operation.RecoveryReasons, outcomes.RecoveryServiceRecovered) } corrections := make([]proposals.CorrectionProposal, 0, len(response.Corrections)) enriched := make([]proposals.EnrichedCorrectionProposal, 0, len(response.Corrections)) for i, raw := range response.Corrections { candidate := proposals.CorrectionProposal{ TargetSegmentID: raw.TargetSegmentID, OriginalText: raw.OriginalText, CorrectedText: raw.CorrectedText, Confidence: raw.Confidence, } corrections = append(corrections, candidate) enrichedCandidate := proposals.EnrichedCorrectionProposal{ CorrectionProposal: candidate, ProposalMetadata: proposals.ProposalMetadata{ ProposalIndex: req.StartIndex + i, ModuleKey: req.ModuleKey, ModuleInstance: req.ModuleInstance, }, } if req.Section != nil { sectionIndex := req.Section.Index enrichedCandidate.SectionIndex = §ionIndex } enriched = append(enriched, enrichedCandidate) } return Result{ Corrections: corrections, Enriched: enriched, Operations: []outcomes.Operation{operation}, Artifacts: artifacts, }, nil } func errPayload(err error) any { if err == nil { return nil } return map[string]any{"error": err.Error()} } func sectionIndexPtr(section *contracts.SectionMetadata) *int { if section == nil { return nil } index := section.Index return &index } type diagnosticsWriterAdapter struct { writer *llm.DiagnosticsWriter } func (a diagnosticsWriterAdapter) WriteInteraction(stage string, requestMetadata any, requestPayload any, responsePayload any, errorPayload any) (InteractionArtifacts, error) { artifacts, err := a.writer.WriteInteraction(stage, requestMetadata, requestPayload, responsePayload, errorPayload) if err != nil { return InteractionArtifacts{}, err } return InteractionArtifacts{ RequestMetadataPath: artifacts.RequestMetadataPath, RequestPayloadPath: artifacts.RequestPayloadPath, ResponsePayloadPath: artifacts.ResponsePayloadPath, ErrorPayloadPath: artifacts.ErrorPayloadPath, }, nil }