feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging
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:
Krishna Kumar
2026-06-24 12:55:32 -05:00
parent e82e69c5f3
commit 96ea5fe6f6
14 changed files with 499 additions and 18 deletions

View 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." }
}
}