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>
71 lines
3.0 KiB
Swift
71 lines
3.0 KiB
Swift
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)))
|
|
}
|
|
}
|