feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging
All checks were successful
CI / build-and-deploy (push) Successful in 22s
All checks were successful
CI / build-and-deploy (push) Successful in 22s
- SpotlightIndexer: actually push BookmarkEntity into the Spotlight index via indexAppEntities (IndexedEntity conformance alone indexes nothing). Wired into every sync/mutation point. This is what lets Apple Intelligence answer free-form Siri questions grounded in the user's bookmarks. - On-device RAG: BookmarkAssistant (LanguageModelSession) + BookmarkSearchTool + SpotlightBookmarkSearch (CSSearchQuery retrieval), surfaced via AskView with lightweight Markdown rendering of answers. - Log: os.Logger facility (com.magicive.marks) replacing ad-hoc print(); shared with ShareExtension. ClaudeService now logs server error bodies on non-2xx. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
81
Marks/Services/BookmarkAssistant.swift
Normal file
81
Marks/Services/BookmarkAssistant.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import FoundationModels
|
||||
|
||||
/// Generation half of on-device RAG. Wraps a `LanguageModelSession` configured
|
||||
/// with `BookmarkSearchTool`, so every question is answered by Apple's
|
||||
/// on-device model grounded in the user's own bookmarks — no network, no
|
||||
/// third-party LLM. Mirrors the `ClaudeService` role, but fully on-device.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BookmarkAssistant {
|
||||
enum Status: Equatable {
|
||||
case checking
|
||||
case ready
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .checking
|
||||
private var session: LanguageModelSession?
|
||||
|
||||
init() { configure() }
|
||||
|
||||
private func configure() {
|
||||
switch SystemLanguageModel.default.availability {
|
||||
case .available:
|
||||
session = LanguageModelSession(
|
||||
tools: [BookmarkSearchTool()],
|
||||
instructions: """
|
||||
You are an assistant inside a bookmarks app. The user's saved bookmarks \
|
||||
are your only source of truth. To answer any question, call the \
|
||||
searchBookmarks tool to look things up — never rely on outside knowledge \
|
||||
or invent links. Ground every answer in the returned bookmarks, refer to \
|
||||
them by title, and include their URLs when relevant. If nothing relevant \
|
||||
is found, say so plainly. Keep answers concise.
|
||||
"""
|
||||
)
|
||||
status = .ready
|
||||
Log.assistant.info("On-device model available; session ready")
|
||||
case .unavailable(let reason):
|
||||
status = .unavailable(Self.message(for: reason))
|
||||
Log.assistant.notice("On-device model unavailable: \(String(describing: reason), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a question; the model retrieves from Spotlight via the tool as needed.
|
||||
func ask(_ question: String) async throws -> String {
|
||||
guard let session else {
|
||||
Log.assistant.error("ask() with no session (model unavailable)")
|
||||
throw AssistantError.unavailable
|
||||
}
|
||||
Log.assistant.info("Ask: \(question, privacy: .public)")
|
||||
let start = Date()
|
||||
do {
|
||||
let answer = try await session.respond(to: question).content
|
||||
let ms = Int(Date().timeIntervalSince(start) * 1000)
|
||||
Log.assistant.info("Answered in \(ms, privacy: .public)ms, \(answer.count, privacy: .public) chars")
|
||||
return answer
|
||||
} catch {
|
||||
Log.assistant.error("Ask failed: \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func message(for reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
|
||||
switch reason {
|
||||
case .deviceNotEligible:
|
||||
return "This device doesn't support Apple Intelligence."
|
||||
case .appleIntelligenceNotEnabled:
|
||||
return "Turn on Apple Intelligence in Settings to ask your bookmarks."
|
||||
case .modelNotReady:
|
||||
return "The on-device model is still downloading. Try again shortly."
|
||||
@unknown default:
|
||||
return "On-device intelligence isn't available right now."
|
||||
}
|
||||
}
|
||||
|
||||
enum AssistantError: LocalizedError {
|
||||
case unavailable
|
||||
var errorDescription: String? { "On-device intelligence is unavailable." }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user