JobSearchPipeline.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. //
  2. // JobSearchPipeline.swift
  3. // indeed_ai_job_search
  4. //
  5. // Phase 4 orchestrator: NL → criteria → Indeed fetch → filter → validate →
  6. // rank → dedupe (with optional in-memory metadata cache).
  7. //
  8. import Foundation
  9. import OSLog
  10. struct DefaultJobSearchPipeline: JobSearchPipeline {
  11. private static let log = Logger(
  12. subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
  13. category: "SearchPipeline"
  14. )
  15. var interpreter: OpenAISearchService
  16. var dataSource: IndeedJobDataSource
  17. var rankingService: OpenAIRankingService
  18. var validationService: CompositeJobValidationService
  19. var cache: any JobListingCache
  20. var maxPages: Int
  21. init(
  22. interpreter: OpenAISearchService,
  23. dataSource: IndeedJobDataSource,
  24. rankingService: OpenAIRankingService,
  25. validationService: CompositeJobValidationService,
  26. cache: any JobListingCache = NullJobListingCache(),
  27. maxPages: Int = IndeedCachePolicy.maxPagesPerSearchUI
  28. ) {
  29. self.interpreter = interpreter
  30. self.dataSource = dataSource
  31. self.rankingService = rankingService
  32. self.validationService = validationService
  33. self.cache = cache
  34. self.maxPages = max(1, maxPages)
  35. }
  36. func search(_ request: JobSearchPipelineRequest) async throws -> JobSearchPipelineResult {
  37. let trimmed = request.naturalLanguageQuery.trimmingCharacters(in: .whitespacesAndNewlines)
  38. let interpreted: InterpretedSearch
  39. let criteria: SearchCriteria
  40. if request.reinterpret {
  41. interpreted = try await interpreter.interpret(query: trimmed)
  42. criteria = SearchCriteriaMerge.merge(
  43. interpreted: interpreted.criteria,
  44. userEdited: request.userCriteria
  45. )
  46. } else {
  47. criteria = request.userCriteria
  48. interpreted = InterpretedSearch(
  49. naturalLanguageQuery: trimmed,
  50. criteria: criteria,
  51. confidenceNote: "Using current filters without re-interpretation."
  52. )
  53. }
  54. Self.log.info(
  55. """
  56. Search start query=\(trimmed, privacy: .public) \
  57. country=\(criteria.country.isEmpty ? "(none)" : criteria.country, privacy: .public) \
  58. city=\(criteria.city.isEmpty ? "(none)" : criteria.city, privacy: .public) \
  59. title=\(criteria.jobTitle.isEmpty ? "(none)" : criteria.jobTitle, privacy: .public) \
  60. reinterpret=\(request.reinterpret, privacy: .public)
  61. """
  62. )
  63. if let cached = await cache.get(query: trimmed, criteria: criteria) {
  64. Self.log.debug("Cache hit — \(cached.listings.count, privacy: .public) listings")
  65. return JobSearchPipelineResult(
  66. interpreted: InterpretedSearch(
  67. naturalLanguageQuery: trimmed,
  68. criteria: criteria,
  69. confidenceNote: interpreted.confidenceNote
  70. ),
  71. listings: cached.listings,
  72. totalEstimated: cached.totalEstimated,
  73. usedCache: true
  74. )
  75. }
  76. let fetched = try await fetchPages(criteria: criteria, query: trimmed)
  77. Self.log.info("Fetched \(fetched.listings.count, privacy: .public) raw listings")
  78. for listing in fetched.listings where listing.availability == .unavailable {
  79. Self.log.warning(
  80. "Source marked unavailable — key=\(listing.indeedJobKey, privacy: .public) title=\(listing.title, privacy: .public) url=\(listing.indeedURL.absoluteString, privacy: .public)"
  81. )
  82. }
  83. var listings = JobListingFilter.filter(fetched.listings, criteria: criteria, query: trimmed)
  84. listings = JobListingDeduper.dedupe(listings)
  85. if FeatureFlags.isListingValidationEnabled {
  86. let before = listings.count
  87. listings = await validationService.validateAll(listings)
  88. let dropped = listings.filter { $0.availability == .unavailable }
  89. for listing in dropped {
  90. Self.log.warning(
  91. "Validation dropped unavailable — key=\(listing.indeedJobKey, privacy: .public) title=\(listing.title, privacy: .public) url=\(listing.indeedURL.absoluteString, privacy: .public)"
  92. )
  93. }
  94. listings = listings.filter { $0.availability != .unavailable }
  95. Self.log.debug(
  96. "Validation kept \(listings.count, privacy: .public)/\(before, privacy: .public) listings"
  97. )
  98. }
  99. listings = try await rankingService.rank(
  100. listings: listings,
  101. query: trimmed,
  102. criteria: criteria
  103. )
  104. // Final dedupe after ranking in case batches reintroduced keys.
  105. listings = JobListingDeduper.dedupe(listings)
  106. for listing in listings {
  107. let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
  108. if resolved.usedBrowseFallback, let reason = resolved.reason {
  109. Self.log.info(
  110. "Open URL note key=\(listing.indeedJobKey, privacy: .public): \(reason, privacy: .public)"
  111. )
  112. }
  113. }
  114. let total = fetched.totalEstimated ?? listings.count
  115. await cache.set(
  116. CachedJobSearchPage(listings: listings, totalEstimated: total, storedAt: Date()),
  117. query: trimmed,
  118. criteria: criteria
  119. )
  120. Self.log.info("Search complete — \(listings.count, privacy: .public) listings (estimated \(total, privacy: .public))")
  121. return JobSearchPipelineResult(
  122. interpreted: InterpretedSearch(
  123. naturalLanguageQuery: trimmed,
  124. criteria: criteria,
  125. confidenceNote: interpreted.confidenceNote
  126. ),
  127. listings: listings,
  128. totalEstimated: total,
  129. usedCache: false
  130. )
  131. }
  132. private func fetchPages(
  133. criteria: SearchCriteria,
  134. query: String
  135. ) async throws -> (listings: [JobListing], totalEstimated: Int?) {
  136. var all: [JobListing] = []
  137. var totalEstimated: Int?
  138. var cursor: String?
  139. var pageIndex = 0
  140. while pageIndex < maxPages {
  141. let page = try await dataSource.searchPage(
  142. criteria: criteria,
  143. query: query,
  144. page: IndeedSearchPageRequest(
  145. pageIndex: pageIndex,
  146. pageSize: IndeedSearchPageRequest.defaultPageSize,
  147. cursor: cursor
  148. )
  149. )
  150. all.append(contentsOf: page.listings)
  151. if totalEstimated == nil {
  152. totalEstimated = page.totalEstimated
  153. }
  154. guard page.hasMore else { break }
  155. cursor = page.nextCursor
  156. pageIndex += 1
  157. }
  158. return (all, totalEstimated)
  159. }
  160. }
  161. /// Adapts a plain `JobDataSource` (e.g. mock) into the paginated Indeed surface.
  162. struct JobDataSourceIndeedAdapter: IndeedJobDataSource {
  163. let base: JobDataSource
  164. func searchPage(
  165. criteria: SearchCriteria,
  166. query: String,
  167. page: IndeedSearchPageRequest
  168. ) async throws -> IndeedSearchPage {
  169. // Non-paginated sources only serve page 0.
  170. if page.pageIndex > 0 {
  171. return IndeedSearchPage(
  172. listings: [],
  173. pageIndex: page.pageIndex,
  174. pageSize: page.pageSize,
  175. totalEstimated: 0,
  176. nextCursor: nil,
  177. hasMore: false
  178. )
  179. }
  180. let listings = try await base.search(criteria: criteria, query: query)
  181. return IndeedSearchPage(
  182. listings: listings,
  183. pageIndex: 0,
  184. pageSize: page.pageSize,
  185. totalEstimated: listings.count,
  186. nextCursor: nil,
  187. hasMore: false
  188. )
  189. }
  190. }