263 lines
9.8 KiB
Go
263 lines
9.8 KiB
Go
// 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/v3/internal/core/config"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/core/schema"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/llm"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/proposals"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/responseschema"
|
|
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/stagename"
|
|
"gitea.maximumdirect.net/eric/audita/v3/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 {
|
|
SegmentReference 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,
|
|
"segment_ids": segmentIDs(req.WorkingTranscript),
|
|
"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
|
|
operation.RecoveryReasons = outcomes.ExecutionRecoveries(completion.Execution)
|
|
|
|
corrections := make([]proposals.CorrectionProposal, 0, len(response.Corrections))
|
|
enriched := make([]proposals.EnrichedCorrectionProposal, 0, len(response.Corrections))
|
|
for i, raw := range response.Corrections {
|
|
// Unknown references remain invalid candidates for the existing domain validators.
|
|
targetID := 0
|
|
if req.WorkingTranscript != nil && raw.SegmentReference > 0 && raw.SegmentReference <= len(req.WorkingTranscript.Segments) {
|
|
targetID = req.WorkingTranscript.Segments[raw.SegmentReference-1].ID
|
|
}
|
|
candidate := proposals.CorrectionProposal{
|
|
TargetSegmentID: targetID,
|
|
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
|
|
}
|
|
|
|
// segmentIDs records the request-local reference mapping in debug metadata.
|
|
func segmentIDs(transcript *schema.Transcript) []int {
|
|
if transcript == nil {
|
|
return nil
|
|
}
|
|
ids := make([]int, len(transcript.Segments))
|
|
for i, segment := range transcript.Segments {
|
|
ids[i] = segment.ID
|
|
}
|
|
return ids
|
|
}
|