285 lines
13 KiB
Swift
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 }
|
|
}
|