Files
linkding-ios/Marks/Services/SpotlightBookmarkSearch.swift
Krishna Kumar 96ea5fe6f6
All checks were successful
CI / build-and-deploy (push) Successful in 22s
feat(ai): on-device RAG over bookmarks + Spotlight indexing + unified logging
- 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>
2026-06-24 12:55:32 -05:00

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)))
}
}