diff --git a/internal/ai/orchestrator.go b/internal/ai/orchestrator.go new file mode 100644 index 0000000..3eae24a --- /dev/null +++ b/internal/ai/orchestrator.go @@ -0,0 +1,72 @@ +package ai + +import ( + "context" + "log" + + "git.georgsen.dk/hwlab/internal/inventory" +) + +// Orchestrator manages the three-tier AI pipeline. +// Tier1 is local oMLX (fast, low cost). Tier2 is OpenRouter (slower, better). +// Tier3 (Lab Advisor) is out of scope for Phase 2. +type Orchestrator struct { + tier1 AIClient + tier2 AIClient + threshold float64 // confidence threshold for escalation; default 0.75 +} + +// NewOrchestrator creates an Orchestrator. Both tier1 and tier2 must be non-nil. +func NewOrchestrator(tier1, tier2 AIClient, threshold float64) *Orchestrator { + if threshold <= 0 { + threshold = 0.75 + } + return &Orchestrator{tier1: tier1, tier2: tier2, threshold: threshold} +} + +// Analyze runs tier1 and escalates to tier2 if confidence is below threshold. +// Never returns an error from individual tier failures — tier errors cause escalation. +// Returns a non-nil IntakeResult in all cases (may be zero-value on total failure). +// The returned CatalogStatus is either StatusIndexed or StatusNeedsResearch. +func (o *Orchestrator) Analyze(ctx context.Context, req IntakeRequest) (*IntakeResult, inventory.CatalogStatus, error) { + result, err := o.tier1.AnalyzePhotos(ctx, req) + if err != nil { + log.Printf("orchestrator: tier1 error (escalating to tier2): %v", err) + result = nil + } + + // Escalate if tier1 result is missing, nil, or low confidence + if result == nil || result.Confidence < o.threshold { + log.Printf("orchestrator: tier1 confidence=%.2f < threshold=%.2f — escalating to tier2", + confidenceOf(result), o.threshold) + result2, err2 := o.tier2.AnalyzePhotos(ctx, req) + if err2 != nil { + log.Printf("orchestrator: tier2 error: %v", err2) + } else if result2 != nil { + result = result2 + } + } + + // Map confidence to CatalogStatus + if result == nil { + return &IntakeResult{ + AINotes: "all AI tiers failed", + Confidence: 0.0, + ConfidenceNote: "no result from any tier", + }, inventory.StatusNeedsResearch, nil + } + + status := inventory.StatusIndexed + if result.Confidence < o.threshold { + status = inventory.StatusNeedsResearch + } + return result, status, nil +} + +// confidenceOf returns 0.0 for nil results, otherwise result.Confidence. +func confidenceOf(r *IntakeResult) float64 { + if r == nil { + return 0.0 + } + return r.Confidence +} diff --git a/internal/ai/orchestrator_test.go b/internal/ai/orchestrator_test.go new file mode 100644 index 0000000..690a93c --- /dev/null +++ b/internal/ai/orchestrator_test.go @@ -0,0 +1,126 @@ +package ai + +import ( + "context" + "errors" + "testing" + + "git.georgsen.dk/hwlab/internal/inventory" +) + +func testReq() IntakeRequest { + return IntakeRequest{PhotosBase64: []string{"data:image/jpeg;base64,/9j/"}, JobID: "test-job"} +} + +// TestOrchestratorHighConfidence: tier1 returns confidence 0.95; tier2 never called; status == StatusIndexed +func TestOrchestratorHighConfidence(t *testing.T) { + tier1 := &MockAIClient{FixedResult: HighConfidenceResult()} + tier2 := &MockAIClient{} + + o := NewOrchestrator(tier1, tier2, 0.75) + result, status, err := o.Analyze(context.Background(), testReq()) + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if status != inventory.StatusIndexed { + t.Errorf("expected StatusIndexed, got %q", status) + } + if len(tier2.Calls) != 0 { + t.Errorf("expected tier2 never called, got %d calls", len(tier2.Calls)) + } + if len(tier1.Calls) != 1 { + t.Errorf("expected tier1 called once, got %d calls", len(tier1.Calls)) + } +} + +// TestOrchestratorLowConfidenceEscalates: tier1 returns confidence 0.40; tier2 called once; tier2 returns 0.85; status == StatusIndexed +func TestOrchestratorLowConfidenceEscalates(t *testing.T) { + tier1 := &MockAIClient{FixedResult: LowConfidenceResult()} + tier2 := &MockAIClient{FixedResult: HighConfidenceResult()} + + o := NewOrchestrator(tier1, tier2, 0.75) + result, status, err := o.Analyze(context.Background(), testReq()) + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if status != inventory.StatusIndexed { + t.Errorf("expected StatusIndexed after tier2 escalation, got %q", status) + } + if len(tier2.Calls) != 1 { + t.Errorf("expected tier2 called once, got %d calls", len(tier2.Calls)) + } + if result.Confidence < 0.75 { + t.Errorf("expected tier2 result confidence >= 0.75, got %.2f", result.Confidence) + } +} + +// TestOrchestratorBothTiersFail: both tiers error; result is non-nil, status == StatusNeedsResearch, err == nil +func TestOrchestratorBothTiersFail(t *testing.T) { + tier1 := &MockAIClient{FixedError: errors.New("tier1 connection refused")} + tier2 := &MockAIClient{FixedError: errors.New("tier2 connection refused")} + + o := NewOrchestrator(tier1, tier2, 0.75) + result, status, err := o.Analyze(context.Background(), testReq()) + + if err != nil { + t.Fatalf("orchestrator must not propagate tier errors, got: %v", err) + } + if result == nil { + t.Fatal("orchestrator must return non-nil result even on total failure") + } + if status != inventory.StatusNeedsResearch { + t.Errorf("expected StatusNeedsResearch on total failure, got %q", status) + } +} + +// TestOrchestratorTier1NilResult: tier1 returns nil result with nil error; orchestrator escalates to tier2 +func TestOrchestratorTier1NilResult(t *testing.T) { + tier1 := &MockAIClient{FixedResult: nil, FixedError: nil} + tier2 := &MockAIClient{FixedResult: HighConfidenceResult()} + + o := NewOrchestrator(tier1, tier2, 0.75) + result, status, err := o.Analyze(context.Background(), testReq()) + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result from tier2 escalation") + } + if len(tier2.Calls) != 1 { + t.Errorf("expected tier2 called once after tier1 nil result, got %d calls", len(tier2.Calls)) + } + if status != inventory.StatusIndexed { + t.Errorf("expected StatusIndexed from tier2, got %q", status) + } +} + +// TestOrchestratorNeedsResearch: tier1 returns 0.40; tier2 also returns 0.40; final status == StatusNeedsResearch +func TestOrchestratorNeedsResearch(t *testing.T) { + tier1 := &MockAIClient{FixedResult: LowConfidenceResult()} + tier2 := &MockAIClient{FixedResult: LowConfidenceResult()} + + o := NewOrchestrator(tier1, tier2, 0.75) + result, status, err := o.Analyze(context.Background(), testReq()) + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if status != inventory.StatusNeedsResearch { + t.Errorf("expected StatusNeedsResearch when both tiers return low confidence, got %q", status) + } + if len(tier2.Calls) != 1 { + t.Errorf("expected tier2 called once, got %d calls", len(tier2.Calls)) + } +} diff --git a/internal/ai/research.go b/internal/ai/research.go new file mode 100644 index 0000000..13d1b66 --- /dev/null +++ b/internal/ai/research.go @@ -0,0 +1,24 @@ +package ai + +import "context" + +// SearchResult is a single result from a SearXNG research query. +type SearchResult struct { + Title string + URL string + Snippet string +} + +// ResearchClient abstracts the SearXNG search backend. +// Phase 7 will provide a real implementation. +type ResearchClient interface { + Search(ctx context.Context, query string) ([]SearchResult, error) +} + +// NoOpResearchClient is a Phase 2 stub that returns empty results. +// Replace with SearXNG HTTP client in Phase 7. +type NoOpResearchClient struct{} + +func (n *NoOpResearchClient) Search(_ context.Context, _ string) ([]SearchResult, error) { + return nil, nil +}