feat(02-02): three-tier orchestrator with confidence routing and research stub
- Orchestrator.Analyze: tier1 → confidence check → tier2 escalation if < threshold - CatalogStatus mapped from confidence: >= threshold → StatusIndexed, else StatusNeedsResearch - Both tiers fail gracefully: returns zero-value IntakeResult + StatusNeedsResearch, err nil - ResearchClient interface + NoOpResearchClient stub for Phase 7 SearXNG - 5 TestOrchestrator* tests all passing (TDD green)
This commit is contained in:
parent
3eed2e9c63
commit
799acd26ef
3 changed files with 222 additions and 0 deletions
72
internal/ai/orchestrator.go
Normal file
72
internal/ai/orchestrator.go
Normal file
|
|
@ -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
|
||||
}
|
||||
126
internal/ai/orchestrator_test.go
Normal file
126
internal/ai/orchestrator_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
24
internal/ai/research.go
Normal file
24
internal/ai/research.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue