Every bookmark failed to reach the Spotlight index. Each item died in translation with "Provided object for field url is of class NSURL, expected class: NSString", so on-device search and Ask Your Bookmarks were retrieving from an index that was effectively empty. Left to itself, App Intents indexes BookmarkEntity's `url` property under the attribute set's own `url` key, which Spotlight's Cascade translator types as NSString. Giving the property an explicit `indexingKey: \.contentURL` sends it to a URL-typed field instead — and contentURL is the right field for "where this content lives" regardless. Keeping the property a URL rather than retyping it to String means existing Shortcuts that read it are unaffected. The attribute set now sets contentURL too, and the retrieval side reads it back, so the round trip stays on one field. Why it went unnoticed: translation happens after `indexAppEntities` returns, so indexing logged success the whole time. Measured on the simulator against the live library — 202 translation failures per launch before, 0 after. Adds SpotlightRetrievalTests, which indexes a bookmark and retrieves it through the assistant's own path. Nothing weaker would have caught this, since the failure was silent at every layer above the index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
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", "contentURL"]
|
|
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.contentURL?.host() ?? "",
|
|
description: a.contentDescription ?? "",
|
|
url: a.contentURL?.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)))
|
|
}
|
|
}
|