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:
70
Marks/Services/SpotlightBookmarkSearch.swift
Normal file
70
Marks/Services/SpotlightBookmarkSearch.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import CoreSpotlight
|
||||
|
||||
/// A bookmark retrieved from the Spotlight index, reduced to the fields the
|
||||
/// on-device model needs to ground an answer.
|
||||
struct RetrievedBookmark: Sendable {
|
||||
let title: String
|
||||
let host: String
|
||||
let description: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
/// Retrieval half of on-device RAG: queries the Spotlight index that
|
||||
/// `SpotlightIndexer` populated and returns the best-matching bookmarks. The
|
||||
/// search runs entirely on-device against the app's own indexed entities.
|
||||
enum SpotlightBookmarkSearch {
|
||||
static func run(query rawQuery: String, limit: Int) async -> [RetrievedBookmark] {
|
||||
guard CSSearchableIndex.isIndexingAvailable() else {
|
||||
Log.spotlight.notice("Search skipped: indexing unavailable")
|
||||
return []
|
||||
}
|
||||
let queryString = makeQueryString(from: rawQuery)
|
||||
guard !queryString.isEmpty else { return [] }
|
||||
Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)")
|
||||
|
||||
let context = CSSearchQueryContext()
|
||||
context.fetchAttributes = ["title", "contentDescription", "keywords", "url"]
|
||||
let query = CSSearchQuery(queryString: queryString, queryContext: context)
|
||||
|
||||
var out: [RetrievedBookmark] = []
|
||||
do {
|
||||
for try await result in query.results {
|
||||
let a = result.item.attributeSet
|
||||
out.append(RetrievedBookmark(
|
||||
title: a.title ?? "Untitled",
|
||||
host: a.url?.host() ?? "",
|
||||
description: a.contentDescription ?? "",
|
||||
url: a.url?.absoluteString ?? ""
|
||||
))
|
||||
if out.count >= limit { break }
|
||||
}
|
||||
Log.spotlight.info("Search \"\(rawQuery, privacy: .public)\" → \(out.count, privacy: .public) hits")
|
||||
} catch {
|
||||
Log.spotlight.error("Search failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
query.cancel()
|
||||
return out
|
||||
}
|
||||
|
||||
/// Build a CoreSpotlight query string that OR-matches each significant token
|
||||
/// across title / description / keywords, case- and diacritic-insensitive.
|
||||
private static func makeQueryString(from raw: String) -> String {
|
||||
let tokens = raw
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.map { $0.lowercased() }
|
||||
.filter { $0.count >= 3 }
|
||||
let terms = (tokens.isEmpty ? [raw] : tokens).map(sanitize).filter { !$0.isEmpty }
|
||||
let fields = ["title", "contentDescription", "keywords"]
|
||||
let clauses = terms.flatMap { term in
|
||||
fields.map { "\($0) == \"*\(term)*\"cd" }
|
||||
}
|
||||
guard !clauses.isEmpty else { return "" }
|
||||
return "(" + clauses.joined(separator: " || ") + ")"
|
||||
}
|
||||
|
||||
/// Keep only alphanumerics so a token can't break the query string syntax.
|
||||
private static func sanitize(_ s: String) -> String {
|
||||
String(String.UnicodeScalarView(s.unicodeScalars.filter(CharacterSet.alphanumerics.contains)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user