homelabby/internal/ai/orchestrator.go
Mikkel Georgsen 799acd26ef 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)
2026-04-10 05:47:41 +00:00

72 lines
2.2 KiB
Go

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
}