diff --git a/internal/framework/contracts/contracts.go b/internal/framework/contracts/contracts.go new file mode 100644 index 0000000..9b72c5a --- /dev/null +++ b/internal/framework/contracts/contracts.go @@ -0,0 +1,135 @@ +package contracts + +import ( + "context" + "encoding/json" + "fmt" + + "gitea.maximumdirect.net/eric/audita/internal/core/chunking" + "gitea.maximumdirect.net/eric/audita/internal/core/config" + "gitea.maximumdirect.net/eric/audita/internal/core/schema" + "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" +) + +// StructuredLLMClient provides provider-agnostic structured completion. +type StructuredLLMClient interface { + CompleteStructured(ctx context.Context, req StructuredCompletionRequest) (StructuredCompletionResponse, error) +} + +// TranscriptModule is the minimal contract for framework-integrated modules. +type TranscriptModule interface { + Key() string + ReplacementPolicy() proposals.ReplacementPolicy + Validators() []Validator + Propose(ctx context.Context, req ProposalRequest) ([]proposals.CorrectionProposal, error) +} + +// Validator evaluates candidate proposals and returns one decision per proposal index. +type Validator interface { + Name() string + Validate(ctx context.Context, req ValidationRequest) ([]ValidationDecision, error) +} + +// StructuredCompletionRequest is a transport-neutral structured completion request. +type StructuredCompletionRequest struct { + StageName string `json:"stage_name"` + Messages []LLMMessage `json:"messages"` + ResponseSchema json.RawMessage `json:"response_schema,omitempty"` +} + +// StructuredCompletionResponse is a transport-neutral structured completion response payload. +type StructuredCompletionResponse struct { + Content json.RawMessage `json:"content"` +} + +// LLMMessage is a minimal chat message shape for LLM prompts. +type LLMMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// ModuleRunSpec identifies one resolved module instance in a pipeline. +type ModuleRunSpec struct { + ModuleKey string `json:"module_key"` + InstanceName string `json:"instance_name"` + ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"` +} + +// SectionMetadata captures chunk-level metadata without embedding full segment payloads. +type SectionMetadata struct { + Index int `json:"section_index"` + StartSegmentID int `json:"start_segment_id"` + EndSegmentID int `json:"end_segment_id"` + EstimatedTokens int `json:"estimated_tokens"` +} + +// SectionMetadataFromSection converts a chunking section into framework metadata. +func SectionMetadataFromSection(section chunking.Section) SectionMetadata { + return SectionMetadata{ + Index: section.Index, + StartSegmentID: section.StartSegmentID, + EndSegmentID: section.EndSegmentID, + EstimatedTokens: section.EstimatedTokens, + } +} + +// ExecutionContext carries shared execution state for proposal generation and validation. +type ExecutionContext struct { + Config *config.Config `json:"-"` + WorkingTranscript *schema.Transcript `json:"-"` + Glossary *schema.Glossary `json:"-"` + Section *SectionMetadata `json:"section,omitempty"` + DiagnosticsDir string `json:"diagnostics_dir,omitempty"` +} + +// ProposalRequest is the input to module proposal generation. +type ProposalRequest struct { + ExecutionContext + RunSpec ModuleRunSpec `json:"run_spec"` + LLMClient StructuredLLMClient `json:"-"` +} + +// ValidationRequest is the input to validator execution. +type ValidationRequest struct { + ExecutionContext + RunSpec ModuleRunSpec `json:"run_spec"` + CandidateProposals []proposals.EnrichedCorrectionProposal `json:"candidate_proposals"` +} + +// ValidationDecision is one validator decision for one proposal index. +type ValidationDecision struct { + ProposalIndex int `json:"proposal_index"` + Approved bool `json:"approved"` + Confidence *float64 `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// ResolveModuleRunSpecs deterministically resolves instance names from logical keys. +// Repeated keys are suffixed with _ (1-based), while singleton keys keep their raw key. +func ResolveModuleRunSpecs(moduleKeys []string) ([]ModuleRunSpec, error) { + totals := make(map[string]int, len(moduleKeys)) + for i, key := range moduleKeys { + if key == "" { + return nil, fmt.Errorf("module key at index %d must not be empty", i) + } + totals[key]++ + } + + seen := make(map[string]int, len(totals)) + specs := make([]ModuleRunSpec, len(moduleKeys)) + for i, key := range moduleKeys { + seen[key]++ + + name := key + if totals[key] > 1 { + name = fmt.Sprintf("%s_%d", key, seen[key]) + } + + specs[i] = ModuleRunSpec{ + ModuleKey: key, + InstanceName: name, + } + } + + return specs, nil +} diff --git a/internal/framework/contracts/contracts_test.go b/internal/framework/contracts/contracts_test.go new file mode 100644 index 0000000..314bbcf --- /dev/null +++ b/internal/framework/contracts/contracts_test.go @@ -0,0 +1,117 @@ +package contracts + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/chunking" + "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" +) + +type fakeLLMClient struct{} + +func (f *fakeLLMClient) CompleteStructured(ctx context.Context, req StructuredCompletionRequest) (StructuredCompletionResponse, error) { + _ = ctx + _ = req + return StructuredCompletionResponse{Content: json.RawMessage(`{"ok":true}`)}, nil +} + +type fakeValidator struct{} + +func (f *fakeValidator) Name() string { return "fake-validator" } + +func (f *fakeValidator) Validate(ctx context.Context, req ValidationRequest) ([]ValidationDecision, error) { + _ = ctx + decisions := make([]ValidationDecision, len(req.CandidateProposals)) + for i, proposal := range req.CandidateProposals { + decisions[i] = ValidationDecision{ProposalIndex: proposal.ProposalIndex, Approved: true} + } + return decisions, nil +} + +type fakeModule struct{} + +func (f *fakeModule) Key() string { return "fake" } + +func (f *fakeModule) ReplacementPolicy() proposals.ReplacementPolicy { + return proposals.ReplacementPolicyRequireUnique +} + +func (f *fakeModule) Validators() []Validator { + return []Validator{&fakeValidator{}} +} + +func (f *fakeModule) Propose(ctx context.Context, req ProposalRequest) ([]proposals.CorrectionProposal, error) { + _ = ctx + _ = req + return []proposals.CorrectionProposal{ + {TargetSegmentID: 1, OriginalText: "a", CorrectedText: "b", Confidence: 0.9}, + }, nil +} + +func TestInterfaceContractsCompileWithFakes(t *testing.T) { + var _ StructuredLLMClient = (*fakeLLMClient)(nil) + var _ Validator = (*fakeValidator)(nil) + var _ TranscriptModule = (*fakeModule)(nil) + + module := &fakeModule{} + if got := module.Key(); got != "fake" { + t.Fatalf("unexpected module key: %q", got) + } + + proposalsOut, err := module.Propose(context.Background(), ProposalRequest{}) + if err != nil { + t.Fatalf("unexpected propose error: %v", err) + } + if len(proposalsOut) != 1 { + t.Fatalf("expected one proposal, got %d", len(proposalsOut)) + } +} + +func TestResolveModuleRunSpecs(t *testing.T) { + specs, err := ResolveModuleRunSpecs([]string{"glossary", "homophones", "glossary", "spoken_word", "grammar"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := []ModuleRunSpec{ + {ModuleKey: "glossary", InstanceName: "glossary_1"}, + {ModuleKey: "homophones", InstanceName: "homophones"}, + {ModuleKey: "glossary", InstanceName: "glossary_2"}, + {ModuleKey: "spoken_word", InstanceName: "spoken_word"}, + {ModuleKey: "grammar", InstanceName: "grammar"}, + } + + if !reflect.DeepEqual(specs, expected) { + t.Fatalf("unexpected specs\nexpected: %+v\nactual: %+v", expected, specs) + } +} + +func TestResolveModuleRunSpecsRejectsEmptyKey(t *testing.T) { + _, err := ResolveModuleRunSpecs([]string{"glossary", ""}) + if err == nil { + t.Fatal("expected error for empty module key") + } +} + +func TestSectionMetadataFromSection(t *testing.T) { + section := chunking.Section{ + Index: 3, + StartSegmentID: 10, + EndSegmentID: 14, + EstimatedTokens: 123, + } + + got := SectionMetadataFromSection(section) + want := SectionMetadata{ + Index: 3, + StartSegmentID: 10, + EndSegmentID: 14, + EstimatedTokens: 123, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected section metadata: got=%+v want=%+v", got, want) + } +}