diff --git a/Sources/XCDocsBridge/VectorSearch/VSKClientObject.swift b/Sources/XCDocsBridge/VectorSearch/VSKClientObject.swift index c63a231..45020a4 100644 --- a/Sources/XCDocsBridge/VectorSearch/VSKClientObject.swift +++ b/Sources/XCDocsBridge/VectorSearch/VSKClientObject.swift @@ -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], @@ -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 = @@ -152,3 +184,6 @@ private typealias VSKStringIdentifiedAssetsMethod = @convention(c) ( AnyObject, Selector, NSArray, NSArray?, AnyObject?, Bool, NSArray?, UnsafeMutablePointer? ) -> AnyObject? + +private typealias VSKStringIdentifiersApplyingFiltersMethod = + @convention(c) (AnyObject, Selector, NSArray?, UnsafeMutablePointer?) -> AnyObject? diff --git a/Sources/XCDocsSupport/Search/VectorSearchClient.swift b/Sources/XCDocsSupport/Search/VectorSearchClient.swift index ec5117f..d2e5cb6 100644 --- a/Sources/XCDocsSupport/Search/VectorSearchClient.swift +++ b/Sources/XCDocsSupport/Search/VectorSearchClient.swift @@ -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 @@ -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 = [] + var uniqueIdentifiers: [String] = [] + uniqueIdentifiers.reserveCapacity(identifiers.count) + + for identifier in identifiers where seen.insert(identifier).inserted { uniqueIdentifiers.append(identifier) } + + return uniqueIdentifiers + } } diff --git a/Tests/TestSupport/LiveEnvironment.swift b/Tests/TestSupport/LiveEnvironment.swift index 01ea18e..23cf097 100644 --- a/Tests/TestSupport/LiveEnvironment.swift +++ b/Tests/TestSupport/LiveEnvironment.swift @@ -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" diff --git a/Tests/XCDocsTests/ClientIntegrationTests.swift b/Tests/XCDocsTests/ClientIntegrationTests.swift index d931a66..ecd3fb1 100644 --- a/Tests/XCDocsTests/ClientIntegrationTests.swift +++ b/Tests/XCDocsTests/ClientIntegrationTests.swift @@ -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 } @@ -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()