Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Sources/XCDocsBridge/VectorSearch/VSKClientObject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,35 @@ package final class VSKClientObject: PrivateObject {
return assets.map(VSKAssetObject.init(base:))
}

package func stringIdentifiers(applying attributeFilters: [VSKFilterObject]) throws -> [String] {
guard
let method = objcInstanceMethod(
selector: Self.stringIdentifiersApplyingFiltersSelector,
as: VSKStringIdentifiersApplyingFiltersMethod.self
)
else {
throw BridgeError(.selectorUnavailable, "Missing stringIdentifiersApplyingFilters selector on VSKClient")
}

var errorObject: AnyObject?
let identifiersObject = method(
base,
Self.stringIdentifiersApplyingFiltersSelector,
attributeFilters.isEmpty ? nil : attributeFilters.map(\.base) as NSArray,
&errorObject
)

if let errorObject = errorObject as? Error {
throw BridgeError(.searchFailed, "Identifier lookup failed", underlyingError: errorObject)
}

guard let identifiers = identifiersObject as? [String] else {
throw BridgeError(.invalidResponse, "Identifier lookup returned an unexpected response")
}

return identifiers
}

package func asset(
forIdentifier identifier: String,
attributeFilters: [VSKFilterObject],
Expand All @@ -137,6 +166,9 @@ package final class VSKClientObject: PrivateObject {
private static let stringIdentifiedAssetsSelector = NSSelectorFromString(
"stringIdentifiedAssetsWithIdentifiers:attributeFilters:pagination:includeVectors:selectAttributes:error:"
)
private static let stringIdentifiersApplyingFiltersSelector = NSSelectorFromString(
"stringIdentifiersApplyingFilters:error:"
)
}

private typealias VSKClientInitMethod =
Expand All @@ -152,3 +184,6 @@ private typealias VSKStringIdentifiedAssetsMethod =
@convention(c) (
AnyObject, Selector, NSArray, NSArray?, AnyObject?, Bool, NSArray?, UnsafeMutablePointer<AnyObject?>?
) -> AnyObject?

private typealias VSKStringIdentifiersApplyingFiltersMethod =
@convention(c) (AnyObject, Selector, NSArray?, UnsafeMutablePointer<AnyObject?>?) -> AnyObject?
176 changes: 163 additions & 13 deletions Sources/XCDocsSupport/Search/VectorSearchClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,48 @@ package final class VectorSearchClient {
hits.reserveCapacity(rawResults.count)
for result in rawResults {
hits.append(
try await VectorSearchHit(
identifier: result.identifier,
score: result.score,
attributes: result.attributes
VectorSearchHit(
identifier: await result.identifier,
score: try await result.score,
attributes: await result.attributes
)
)
}

return try await hydrateSearchHits(hits, selectedAttributes: selectedAttributes)
let hydratedHits = try await hydrateSearchHits(hits, selectedAttributes: selectedAttributes)
let descendantsByParent = try await descendantIdentifiersByParent(in: hydratedHits)
let contentExpandedHits: [VectorSearchHit]
if omitContent {
contentExpandedHits = hydratedHits
} else {
contentExpandedHits = try await appendDescendantContents(
to: hydratedHits,
descendantsByParent: descendantsByParent,
selectedAttributes: selectedAttributes
)
}

return deduplicateSearchHits(contentExpandedHits, descendantsByParent: descendantsByParent)
}

package func entry(for identifier: String) async throws -> VectorSearchHit {
let selectedAttributes = [
try await VSKAttributeObject.stringAttribute(named: "framework"),
try await VSKAttributeObject.stringAttribute(named: "type"),
try await VSKAttributeObject.stringAttribute(named: "title"),
try await VSKAttributeObject.stringAttribute(named: "content"),
]

let selectedAttributes = try await exactLookupAttributes()
let asset = try await client.asset(
forIdentifier: identifier,
attributeFilters: [],
includeVectors: false,
selectedAttributes: selectedAttributes
)
let hit = VectorSearchHit(identifier: await asset.identifier, score: .nan, attributes: await asset.attributes)

return await VectorSearchHit(identifier: asset.identifier, score: .nan, attributes: asset.attributes)
let descendantsByParent = try await descendantIdentifiersByParent(forParentIdentifiers: [identifier])
guard descendantsByParent[identifier] != nil else { return hit }
let expandedHits = try await appendDescendantContents(
to: [hit],
descendantsByParent: descendantsByParent,
selectedAttributes: selectedAttributes
)
return expandedHits[0]
}

// MARK: Private
Expand Down Expand Up @@ -141,4 +156,139 @@ package final class VectorSearchClient {
return VectorSearchHit(identifier: hit.identifier, score: hit.score, attributes: mergedAttributes)
}
}

private func fetchHits(for identifiers: [String], selectedAttributes: [VSKAttributeObject]) async throws
-> [VectorSearchHit]
{
guard !identifiers.isEmpty else { return [] }

let uniqueIdentifiers = orderedUniqueIdentifiers(from: identifiers)
let assets = try await client.assets(
forIdentifiers: uniqueIdentifiers,
attributeFilters: [],
includeVectors: false,
selectedAttributes: selectedAttributes
)
var hitsByIdentifier: [String: VectorSearchHit] = [:]
hitsByIdentifier.reserveCapacity(assets.count)
for asset in assets {
let identifier = await asset.identifier
let attributes = await asset.attributes
hitsByIdentifier[identifier] = VectorSearchHit(identifier: identifier, score: .nan, attributes: attributes)
}

for identifier in uniqueIdentifiers where hitsByIdentifier[identifier] == nil {
throw missingEntryError(for: identifier)
}

return try identifiers.map { identifier in
guard let hit = hitsByIdentifier[identifier] else { throw missingEntryError(for: identifier) }
return hit
}
}

private func descendantIdentifiersByParent(in hits: [VectorSearchHit]) async throws -> [String: [String]] {
try await descendantIdentifiersByParent(
forParentIdentifiers: hits.map(\.identifier).filter { !$0.contains("#") }
)
}

private func descendantIdentifiersByParent(forParentIdentifiers identifiers: [String]) async throws -> [String:
[String]]
{
let parentIdentifiers = orderedUniqueIdentifiers(from: identifiers.filter { !$0.contains("#") })
guard !parentIdentifiers.isEmpty else { return [:] }

let topicIdentifiers = try await topicIdentifiers()
guard !topicIdentifiers.isEmpty else { return [:] }

var descendantsByParent: [String: [String]] = [:]
descendantsByParent.reserveCapacity(parentIdentifiers.count)
for parentIdentifier in parentIdentifiers {
let descendantPrefix = "\(parentIdentifier)#"
let descendants = topicIdentifiers.filter { $0.hasPrefix(descendantPrefix) }
if !descendants.isEmpty { descendantsByParent[parentIdentifier] = descendants }
}

return descendantsByParent
}

private func topicIdentifiers() async throws -> [String] {
orderedUniqueIdentifiers(
from: try await client.stringIdentifiers(
applying: try await makeFilters(attributeName: "type", values: ["topic"])
)
)
}

private func appendDescendantContents(
to hits: [VectorSearchHit],
descendantsByParent: [String: [String]],
selectedAttributes: [VSKAttributeObject]
) async throws -> [VectorSearchHit] {
let descendantIdentifiers = orderedUniqueIdentifiers(from: descendantsByParent.values.flatMap { $0 })
guard !descendantIdentifiers.isEmpty else { return hits }

let descendantHits = try await fetchHits(for: descendantIdentifiers, selectedAttributes: selectedAttributes)
let descendantContentByIdentifier: [String: String] = Dictionary(
uniqueKeysWithValues: descendantHits.compactMap { hit in
guard let content = hit.content else { return nil }
return (hit.identifier, content)
}
)

return hits.map { hit in
guard let descendantIdentifiers = descendantsByParent[hit.identifier], !descendantIdentifiers.isEmpty else {
return hit
}

let descendantContents = descendantIdentifiers.compactMap { identifier -> String? in
guard let content = descendantContentByIdentifier[identifier], !content.isEmpty else { return nil }
return content
}
guard !descendantContents.isEmpty else { return hit }

let joinedContent = ([hit.content].compactMap { $0 }.filter { !$0.isEmpty } + descendantContents).joined(
separator: "\n\n"
)
var attributes = hit.attributes
attributes["content"] = joinedContent
return VectorSearchHit(identifier: hit.identifier, score: hit.score, attributes: attributes)
}
}

private func deduplicateSearchHits(_ hits: [VectorSearchHit], descendantsByParent: [String: [String]])
-> [VectorSearchHit]
{
let descendantIdentifiers = Set(descendantsByParent.values.flatMap { $0 })
guard !descendantIdentifiers.isEmpty else { return hits }
return hits.filter { !descendantIdentifiers.contains($0.identifier) }
}

private func exactLookupAttributes() async throws -> [VSKAttributeObject] {
[
try await VSKAttributeObject.stringAttribute(named: "framework"),
try await VSKAttributeObject.stringAttribute(named: "type"),
try await VSKAttributeObject.stringAttribute(named: "title"),
try await VSKAttributeObject.stringAttribute(named: "content"),
]
}

private func missingEntryError(for identifier: String) -> NSError {
NSError(
domain: "XCDocsSupport.VectorSearchClient",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "No documentation entry was found for \(identifier)"]
)
}

private func orderedUniqueIdentifiers(from identifiers: [String]) -> [String] {
var seen: Set<String> = []
var uniqueIdentifiers: [String] = []
uniqueIdentifiers.reserveCapacity(identifiers.count)

for identifier in identifiers where seen.insert(identifier).inserted { uniqueIdentifiers.append(identifier) }

return uniqueIdentifiers
}
}
2 changes: 2 additions & 0 deletions Tests/TestSupport/LiveEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package enum LiveEnvironment {
package static let documentationIdentifier = "/documentation/Testing"
package static let searchFramework = "Swift Testing"
package static let searchQuery = "swift testing"
package static let articleWithSubtopicsIdentifier = "/documentation/TechnologyOverviews/liquid-glass"
package static let articleWithSubtopicsQuery = "liquid glass"

private static let mediaAnalysisServicesPath = "/System/Library/PrivateFrameworks/MediaAnalysisServices.framework"
private static let vectorSearchPath = "/System/Library/PrivateFrameworks/VectorSearch.framework"
Expand Down
40 changes: 40 additions & 0 deletions Tests/XCDocsTests/ClientIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ struct ClientIntegrationTests {
try await entryReturnsExpectedMetadataAndContentOnSupportedOS()
}

@Test
func entryAppendsSubtopicContentForCollectionGroupArticles() async throws {
guard #available(macOS 26, *) else { return }
try await entryAppendsSubtopicContentForCollectionGroupArticlesOnSupportedOS()
}

@Test
func searchDoesNotReturnArticleSubtopicsSeparatelyWhenParentArticleIsPresent() async throws {
guard #available(macOS 26, *) else { return }
try await searchDoesNotReturnArticleSubtopicsSeparatelyWhenParentArticleIsPresentOnSupportedOS()
}

@Test
func missingIdentifiersThrowAssetNotFoundBridgeErrors() async throws {
guard #available(macOS 26, *) else { return }
Expand Down Expand Up @@ -95,6 +107,34 @@ private func entryReturnsExpectedMetadataAndContentOnSupportedOS() async throws
#expect(!(result.content ?? "").isEmpty)
}

@available(macOS 26, *)
private func entryAppendsSubtopicContentForCollectionGroupArticlesOnSupportedOS() async throws {
let client = Client()
let result = try await client.entry(for: LiveEnvironment.articleWithSubtopicsIdentifier)
let content = try #require(result.content)

#expect(content.contains("Learn how to design and develop beautiful interfaces that leverage Liquid Glass."))
#expect(content.contains("Liquid Glass: Introduction to Liquid Glass"))
#expect(content.contains("Liquid Glass: Adopting Liquid Glass"))
#expect(content.contains("Liquid Glass: Essentials"))
}

@available(macOS 26, *)
private func searchDoesNotReturnArticleSubtopicsSeparatelyWhenParentArticleIsPresentOnSupportedOS() async throws {
let client = Client()
let results = try await client.search(LiveEnvironment.articleWithSubtopicsQuery, limit: 10, omitContent: false)
let articleResult = try #require(results.first { $0.entry.id == LiveEnvironment.articleWithSubtopicsIdentifier })
let articleContent = try #require(articleResult.entry.content)

#expect(articleContent.contains("Liquid Glass: Introduction to Liquid Glass"))
#expect(
results.allSatisfy { result in
result.entry.id == LiveEnvironment.articleWithSubtopicsIdentifier
|| !result.entry.id.hasPrefix("\(LiveEnvironment.articleWithSubtopicsIdentifier)#")
}
)
}

@available(macOS 26, *)
private func missingIdentifiersThrowAssetNotFoundBridgeErrorsOnSupportedOS() async throws {
let client = Client()
Expand Down
Loading