Skip to content

PR 5: Unified Categorization Service (Provider Cascade) #6

Description

@gilmanb1

Overview

Implement the UnifiedCategorizationService that orchestrates the provider cascade. This is the central service that manages provider selection, escalation, and fallback logic.

Dependencies

Files to Create

File Action Description
Sources/SortAI/Core/LLM/UnifiedCategorizationService.swift Create Main orchestration service
Sources/SortAI/Core/LLM/ProviderSettingsAvailability.swift Create Settings availability struct

Implementation Details

1. UnifiedCategorizationService

import Foundation
import Combine

/// Unified service that manages provider cascade
actor UnifiedCategorizationService {
    // MARK: - Properties
    
    private var providers: [any LLMCategorizationProvider] = []
    private var currentProvider: (any LLMCategorizationProvider)?
    private let ollamaInstaller = OllamaInstaller()
    
    private var preference: ProviderPreference = .automatic
    private var escalationThreshold: Double = 0.5
    private var autoInstallOllama: Bool = true
    
    // Observable state
    @Published private(set) var activeProvider: String = "initializing"
    @Published private(set) var isEscalating: Bool = false
    
    // MARK: - Initialization
    
    init() async {
        await initialize()
    }
    
    func initialize() async {
        providers = []
        
        // Register Apple Intelligence (priority 1)
        if #available(macOS 26.0, *) {
            let appleProvider = AppleIntelligenceProvider(
                escalationThreshold: escalationThreshold
            )
            if await appleProvider.isAvailable() {
                providers.append(appleProvider)
            }
        }
        
        // Register Ollama (priority 2)
        let ollamaProvider = OllamaCategorizationProvider()
        providers.append(ollamaProvider)
        
        // Register Cloud providers (priority 3)
        // OpenAICategorizationProvider, AnthropicProvider, etc.
        
        // Register Local ML (priority 4 - always available fallback)
        let localMLProvider = LocalMLProvider()
        providers.append(localMLProvider)
        
        // Sort by priority
        providers.sort { $0.priority < $1.priority }
        
        NSLog("📱 [UnifiedService] Initialized with %d providers", providers.count)
    }
    
    // MARK: - Configuration
    
    func setPreference(_ pref: ProviderPreference) {
        self.preference = pref
        NSLog("📱 [UnifiedService] Preference: %@", pref.rawValue)
    }
    
    func setEscalationThreshold(_ threshold: Double) {
        self.escalationThreshold = max(0.0, min(1.0, threshold))
    }
    
    func setAutoInstallOllama(_ enabled: Bool) {
        self.autoInstallOllama = enabled
    }
    
    // MARK: - Categorization
    
    func categorize(signature: FileSignature) async throws -> CategorizationResult {
        let orderedProviders = getProvidersForPreference()
        var lastError: Error?
        var lastResult: CategorizationResult?
        
        for provider in orderedProviders {
            // Check availability
            guard await provider.isAvailable() else {
                NSLog("⚠️ [UnifiedService] Provider %@ unavailable", provider.identifier)
                
                // Offer to install Ollama if needed
                if provider.identifier == "ollama" && autoInstallOllama {
                    await handleOllamaUnavailable()
                }
                continue
            }
            
            do {
                NSLog("🧠 [UnifiedService] Trying: %@", provider.identifier)
                let result = try await provider.categorize(signature: signature)
                
                currentProvider = provider
                activeProvider = provider.identifier
                
                // Check for escalation
                if result.shouldEscalate && preference == .automatic {
                    NSLog("📈 [UnifiedService] Confidence %.2f < threshold, escalating...", 
                          result.confidence)
                    isEscalating = true
                    lastResult = result
                    continue
                }
                
                isEscalating = false
                return result
                
            } catch {
                NSLog("⚠️ [UnifiedService] %@ failed: %@", 
                      provider.identifier, error.localizedDescription)
                lastError = error
                continue
            }
        }
        
        // Return low-confidence result if we have one
        if let result = lastResult {
            isEscalating = false
            return result
        }
        
        throw CategorizationError.allProvidersFailed(lastError)
    }
    
    // MARK: - Provider Ordering
    
    private func getProvidersForPreference() -> [any LLMCategorizationProvider] {
        switch preference {
        case .automatic:
            return providers.sorted { $0.priority < $1.priority }
            
        case .appleIntelligenceOnly:
            return providers.filter {
                $0.identifier == "apple-intelligence" || $0.identifier == "local-ml"
            }
            
        case .preferOllama:
            return providers.sorted { p1, p2 in
                if p1.identifier == "ollama" { return true }
                if p2.identifier == "ollama" { return false }
                return p1.priority < p2.priority
            }
            
        case .cloud:
            return providers.sorted { p1, p2 in
                let cloudIds = ["openai", "anthropic"]
                if cloudIds.contains(p1.identifier) { return true }
                if cloudIds.contains(p2.identifier) { return false }
                return p1.priority < p2.priority
            }
        }
    }
    
    // MARK: - Ollama Installation
    
    private func handleOllamaUnavailable() async {
        if !ollamaInstaller.isInstalled() {
            let shouldInstall = await ollamaInstaller.showInstallationPrompt()
            if shouldInstall {
                do {
                    try await ollamaInstaller.install()
                    try await ollamaInstaller.pullModel("deepseek-r1:8b") { _ in }
                    NSLog("✅ [UnifiedService] Ollama installed")
                } catch {
                    NSLog("❌ [UnifiedService] Install failed: %@", error.localizedDescription)
                }
            }
        } else if await !ollamaInstaller.isServerRunning() {
            do {
                try await ollamaInstaller.launchOllama()
                NSLog("✅ [UnifiedService] Ollama server started")
            } catch {
                NSLog("❌ [UnifiedService] Launch failed: %@", error.localizedDescription)
            }
        }
    }
    
    // MARK: - Settings Support
    
    func getSettingsAvailability() -> ProviderSettingsAvailability {
        guard let current = currentProvider else {
            return ProviderSettingsAvailability.allEnabled
        }
        
        return ProviderSettingsAvailability(
            modelSelection: current.supportsModelSelection,
            temperature: current.supportsTemperature,
            customPrompts: current.supportsCustomPrompts
        )
    }
    
    func getAvailableProviders() async -> [String] {
        var available: [String] = []
        for provider in providers {
            if await provider.isAvailable() {
                available.append(provider.identifier)
            }
        }
        return available
    }
}

2. ProviderSettingsAvailability

struct ProviderSettingsAvailability: Sendable {
    let modelSelection: Bool
    let temperature: Bool
    let customPrompts: Bool
    
    static let allEnabled = ProviderSettingsAvailability(
        modelSelection: true,
        temperature: true,
        customPrompts: true
    )
    
    static let allDisabled = ProviderSettingsAvailability(
        modelSelection: false,
        temperature: false,
        customPrompts: false
    )
}

Provider Cascade Behavior

Automatic Mode (Default)

1. Apple Intelligence → if available AND confidence >= threshold
2. Ollama → if available AND confidence >= threshold
3. Cloud → if configured AND confidence >= threshold
4. Local ML → always available, final fallback

Escalation Flow

Apple Intelligence returns confidence 0.4 (< 0.5 threshold)
    ↓
Mark shouldEscalate = true
    ↓
Try Ollama (if available)
    ↓
If Ollama unavailable → offer installation
    ↓
If all fail → return best result (even if low confidence)

Acceptance Criteria

  • Registers all provider types correctly
  • Respects user preference setting
  • Implements escalation logic for low confidence
  • Handles provider unavailability gracefully
  • Offers Ollama installation when needed
  • Tracks active provider for UI display
  • Returns settings availability for current provider
  • All providers tried in correct order
  • Final fallback (Local ML) always works

Testing

func testAutomaticModeOrdering() async {
    let service = await UnifiedCategorizationService()
    await service.setPreference(.automatic)
    
    // Verify Apple Intelligence is first when available
    let providers = await service.getAvailableProviders()
    if providers.contains("apple-intelligence") {
        XCTAssertEqual(providers.first, "apple-intelligence")
    }
}

func testPreferOllamaOrdering() async {
    let service = await UnifiedCategorizationService()
    await service.setPreference(.preferOllama)
    
    // Categorize and verify Ollama was tried first
    // (when available)
}

func testEscalationOnLowConfidence() async throws {
    let service = await UnifiedCategorizationService()
    await service.setPreference(.automatic)
    await service.setEscalationThreshold(0.9) // High threshold
    
    let signature = FileSignature.mock(filename: "ambiguous_file.dat")
    let result = try await service.categorize(signature: signature)
    
    // With high threshold, should have escalated
    // Result may come from Ollama or later provider
}

func testFallbackOnProviderFailure() async throws {
    let service = await UnifiedCategorizationService()
    
    // Even if all LLMs fail, Local ML should work
    let signature = FileSignature.mock(filename: "test.txt")
    let result = try await service.categorize(signature: signature)
    
    XCTAssertNotNil(result)
    XCTAssertFalse(result.categoryPath.path.isEmpty)
}

Estimated Size

~250-300 lines of code

Risk Assessment

Medium - Core orchestration logic. Mitigation: comprehensive test coverage, gradual rollout with feature flag.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestllm-providerLLM provider infrastructurephase-3Phase 3 - Integration

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions