Files
nucleic/Sources/NucleicApp/PromptPurposeService.swift
T

285 lines
13 KiB
Swift

import Foundation
import NucleicCore
import Observation
/// Classifies the composer's draft prompt as the user types, so the Intelligence slider's
/// router already knows the purpose by the time they hit send. One instance per composer.
///
/// The layering itself — local by default, with the opt-in cloud lane taking over the layers
/// below the bundled model when the user enables it — lives in `LayeredPurposeClassifier`, which
/// the host-side remote send path shares. This type owns the *composer* half: debounce, caching,
/// the preview state, and the send-time latency contract.
///
/// Latency contract: classification is *debounced draft work* — the send path never blocks
/// on a model. `resolveForSend` returns a cached verdict instantly, grace-waits at most
/// 400 ms on an in-flight classification, and otherwise answers with the synchronous
/// heuristic (<1 ms).
@MainActor
@Observable
final class PromptPurposeService {
/// Which classifier ladder a composer is allowed to use. New-chat routing may use the full
/// configured stack; Context Switch is deliberately zero-network and stops after the bundled
/// model, while retaining this type's debounce/cache behavior.
enum ClassificationScope: Sendable {
case layered
case localOnly
}
enum PreviewState: Equatable {
case idle
case pending
case classifying
case resolved
var isPending: Bool { self == .pending || self == .classifying }
}
/// The newest completed verdict — drives the resolved chip and model/effort sublabel. The
/// preview state hides it while a materially changed draft waits for a new verdict; retaining
/// it privately lets a user undo that edit without paying for another classification.
private(set) var verdict: PurposeVerdict?
private(set) var previewState: PreviewState = .idle
/// The primary (AFM-backed) provider. A value, not a factory: the provider itself reads
/// `IntelligenceChoice.current` live, so a mid-session Settings change still applies.
private let intelligence: any IntelligenceProviding
/// The local task-specific Core ML layer. Nil disables it (primarily for tests); the
/// production actor also honors the defaults-backed kill switch and model pin.
private let modelClassifier: (any PurposeModelClassifying)?
/// The one-shot small-SKU escalation behind AFM — nil removes that rung (tests, or a Mac
/// with no agent CLI; the call itself also degrades harmlessly if the CLI is missing).
private let escalation: (any IntelligenceProviding)?
private let classificationScope: ClassificationScope
/// Backends this host can actually reach, kept current by the mounting composer before each
/// classification. The cloud lane only ever runs on a provider the user is already signed in
/// to, and which of the two SKUs it picks depends on which one that is.
var connectedProviders: Set<BackendID> = []
/// The cloud lane for this classification, resolved fresh each time: both the Settings choice
/// and the connected providers can move mid-session, and a stale answer here would either
/// keep sending prompts to a lane the user just turned off or ignore one they just turned on.
private var cloud: CloudPurposeClassifier? {
CloudPurposeClassifier.resolved(connected: connectedProviders)
}
/// Verdicts keyed by normalized draft hash, so backspacing to a prior draft (or the
/// send following the last debounce tick) never re-classifies. Bounded — drafts churn,
/// and retaining eight full pasted prompts just for cache keys would waste memory.
private var cache: [Int: PurposeVerdict] = [:]
private var cacheOrder: [Int] = []
private var inFlight: Task<PurposeVerdict, Never>?
private var inFlightDraft: String?
/// The normalized prompt that produced `verdict`. Small edits keep using that verdict; a
/// meaningful change clears it and starts another debounced classification.
private var classifiedDraft: String?
/// How long the send path is willing to wait on an in-flight classification. Users
/// pause before hitting send, so this rarely triggers — and `createSession` does its
/// own async work after, so the wait is imperceptible when it does.
private static let sendGraceSeconds: TimeInterval = 0.4
init(
intelligence: any IntelligenceProviding,
modelClassifier: (any PurposeModelClassifying)? = PurposeMLClassifier.shared,
escalation: (any IntelligenceProviding)? = DelegatedIntelligenceProvider.agent(),
classificationScope: ClassificationScope = .layered
) {
self.intelligence = intelligence
self.modelClassifier = modelClassifier
self.escalation = escalation
self.classificationScope = classificationScope
}
/// Registers an immediate text edit, before the composer's debounce. Returns true only when
/// the caller should schedule classification after its quiet interval. Exact cache hits land
/// immediately, while punctuation/whitespace-sized edits retain the previous decision and
/// avoid waking AFM or the escalation agent.
@discardableResult
func prepareDraftChange(_ text: String) -> Bool {
let normalized = Self.normalize(text)
guard !normalized.isEmpty else {
inFlight?.cancel()
inFlight = nil
inFlightDraft = nil
verdict = nil
classifiedDraft = nil
previewState = .idle
return false
}
if let cached = cache[normalized.hashValue] {
inFlight?.cancel()
inFlight = nil
inFlightDraft = nil
verdict = cached
classifiedDraft = normalized
previewState = .resolved
return false
}
if let classifiedDraft, let verdict,
!Self.isSubstantialChange(from: classifiedDraft, to: normalized)
{
// Keep the resolved label stable through typo fixes and punctuation changes. The
// send path uses the same test, so the visible choice and the actual route agree.
inFlight?.cancel()
inFlight = nil
inFlightDraft = nil
self.verdict = verdict
previewState = .resolved
return false
}
inFlight?.cancel()
inFlight = nil
inFlightDraft = nil
previewState = .pending
return true
}
/// Called from the composer's debounced `.task(id:)` whenever the draft has materially
/// changed. Cheap on cache hits; otherwise kicks off (and publishes) a classification.
@discardableResult
func draftChanged(_ text: String) async -> PurposeVerdict? {
let normalized = Self.normalize(text)
guard !normalized.isEmpty else {
verdict = nil
classifiedDraft = nil
previewState = .idle
return nil
}
if let cached = cache[normalized.hashValue] {
verdict = cached
classifiedDraft = normalized
previewState = .resolved
return cached
}
if let inFlight, inFlightDraft == normalized {
let result = await inFlight.value
guard !Task.isCancelled, self.inFlightDraft == normalized else { return nil }
remember(draft: normalized, verdict: result)
verdict = result
classifiedDraft = normalized
self.inFlight = nil
inFlightDraft = nil
previewState = .resolved
return result
}
inFlight?.cancel()
inFlightDraft = normalized
previewState = .classifying
let cloud = self.cloud
let task = Task { [
intelligence, modelClassifier, escalation, classificationScope
] in
switch classificationScope {
case .layered:
await Self.classifyLayered(
normalized, modelClassifier: modelClassifier,
intelligence: intelligence, escalation: escalation, cloud: cloud)
case .localOnly:
await ContextSwitchPurposeClassifier.classify(
normalized, modelClassifier: modelClassifier)
}
}
inFlight = task
let result = await task.value
guard !Task.isCancelled, inFlightDraft == normalized else { return nil }
remember(draft: normalized, verdict: result)
verdict = result
classifiedDraft = normalized
inFlight = nil
inFlightDraft = nil
previewState = .resolved
return result
}
/// The verdict to route the send on — never blocks perceptibly (see latency contract).
func resolveForSend(_ text: String) async -> PurposeVerdict {
let normalized = Self.normalize(text)
guard !normalized.isEmpty else { return .general }
if let cached = cache[normalized.hashValue] { return cached }
if let classifiedDraft, let verdict,
!Self.isSubstantialChange(from: classifiedDraft, to: normalized)
{
return verdict
}
if let task = inFlight, inFlightDraft == normalized {
if let result = await withTimeout(Self.sendGraceSeconds, { await task.value }) {
remember(draft: normalized, verdict: result)
return result
}
}
// No classification in time — the deterministic layer answers instantly.
return HeuristicPurposeClassifier.classify(normalized)
}
// MARK: - Internals
/// The layered ladder, as the composer calls it. Kept here so the app tests can pin the
/// acceptance/fallback contract without loading Core ML or waiting through the debounce.
static func classifyLayered(
_ text: String,
modelClassifier: (any PurposeModelClassifying)?,
intelligence: any IntelligenceProviding,
escalation: (any IntelligenceProviding)?,
cloud: (any CloudPurposeClassifying)? = nil
) async -> PurposeVerdict {
await LayeredPurposeClassifier.classify(
text, modelClassifier: modelClassifier, cloud: cloud,
intelligence: intelligence, escalation: escalation)
}
private func remember(draft: String, verdict: PurposeVerdict) {
let hash = draft.hashValue
if cache[hash] == nil {
cacheOrder.append(hash)
if cacheOrder.count > 8 { cache[cacheOrder.removeFirst()] = nil }
}
cache[hash] = verdict
}
/// A bounded, linear edit test used between classifier runs. Three changed words is always
/// meaningful; short prompts also rerun when a single word changes at least a quarter of the
/// request ("fix this" → "review this"). Long prose needs a larger edit, keeping pasted
/// prompts and one-character corrections from repeatedly invoking the expensive layers.
nonisolated static func isSubstantialChange(from previous: String, to current: String) -> Bool {
let oldWords = words(in: normalize(previous))
let newWords = words(in: normalize(current))
guard oldWords != newWords else { return false }
guard !oldWords.isEmpty, !newWords.isEmpty else { return true }
var prefix = 0
while prefix < min(oldWords.count, newWords.count),
oldWords[prefix] == newWords[prefix]
{
prefix += 1
}
var suffix = 0
while suffix < min(oldWords.count, newWords.count) - prefix,
oldWords[oldWords.count - 1 - suffix] == newWords[newWords.count - 1 - suffix]
{
suffix += 1
}
let oldChanged = oldWords.count - prefix - suffix
let newChanged = newWords.count - prefix - suffix
let changed = max(oldChanged, newChanged)
let relativeChange = Double(changed) / Double(max(oldWords.count, newWords.count))
return changed >= 3 || relativeChange >= 0.25
}
/// Whitespace-collapsed draft text, so a re-wrap or trailing newline doesn't read as a
/// materially different prompt. The composer keys its debounce `.task(id:)` on the same
/// normalization (`normalizedHash`).
nonisolated private static func normalize(_ text: String) -> String {
text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
}
/// Word comparison ignores case and surrounding punctuation; those edits do not alter the
/// router's purpose signal and should not trigger another model pass.
nonisolated private static func words(in text: String) -> [String] {
text.lowercased().split { !$0.isLetter && !$0.isNumber }.map(String.init)
}
/// The debounce key for the composer's `.task(id:)`.
nonisolated static func normalizedHash(_ text: String) -> Int { normalize(text).hashValue }
}