- 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)
72 lines
2.2 KiB
Go
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
|
|
}
|