Forráskód Böngészése

Add OpenAI Indeed web_search with progressive live scrape enrichment.

Surface web_search results first, then append deduped live Indeed listings so searches feel faster and return fuller result sets.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 17 órája
szülő
commit
8e98be238e

+ 4 - 2
Docs/Phase4/Search_Pipeline.md

@@ -34,9 +34,11 @@ NL query
 
 ## Indeed source
 
-Live DI uses **`LiveIndeedJobDataSource`**: WebKit loads Indeed `/jobs` search pages, `IndeedHTMLJobParser` extracts real job cards (`jk`, title, company, location, salary when present), and `IndeedListingMapper` maps them. Mock/fixture catalogs remain for **SwiftUI previews and unit tests only**.
+Live DI uses **`OpenAIIndeedWebSearchDataSource`**: OpenAI **Responses API** + hosted **`web_search`** (Indeed domains only via `allowed_domains`), then JSON → `IndeedListingMapper`. Rows without a valid Indeed URL are dropped. Mock catalogs remain for **SwiftUI previews / unit tests only**.
 
-**Open on Indeed:** real 16-char hex `jk` → `/viewjob?jk=…`. Fake keys (previews only) → company-scoped `/jobs` search.
+WebKit HTML fetch (`LiveIndeedJobDataSource`) remains in the tree as an alternate adapter but is **not** wired in production DI.
+
+**Open on Indeed:** real 16-char hex `jk` → `/viewjob?jk=…`. Company-scoped browse search is only for preview/fake keys.
 
 ## Open on Indeed
 

+ 29 - 8
indeed_ai_job_search/App/AppDependencies.swift

@@ -55,44 +55,54 @@ final class AppDependencies {
         )
     }
 
-    /// App launch — live Indeed search pages + OpenAI interpret/rank when configured.
-    /// Mock listings are preview/tests only — never shown in production search.
+    /// App launch — OpenAI Responses + web_search (Indeed-only) for live job rows.
+    /// Interpretation/ranking still use Chat Completions (gpt-6-luna) when available.
     static var live: AppDependencies {
         let mockInterpreter = MockSearchInterpreter()
         let heuristicRanking = HeuristicJobRankingService()
         let interpreter: OpenAISearchService
         let ranking: OpenAIRankingService
+        let primarySource: any IndeedJobDataSource
+        let enrichmentSource: (any IndeedJobDataSource)?
 
         if SecretsProvider.isOpenAIConfigured, let apiKey = SecretsProvider.openAIAPIKey {
-            let client = OpenAIClient(apiKey: apiKey)
+            let chatClient = OpenAIClient(apiKey: apiKey)
             interpreter = FallbackOpenAISearchService(
-                primary: OpenAISearchInterpreter(client: client),
+                primary: OpenAISearchInterpreter(client: chatClient),
                 fallback: mockInterpreter
             )
             ranking = FallbackOpenAIRankingService(
-                primary: OpenAIJobRankingService(client: client),
+                primary: OpenAIJobRankingService(client: chatClient),
                 fallback: heuristicRanking
             )
+
+            let responsesClient = OpenAIResponsesClient(apiKey: apiKey)
+            // Progressive: web_search first (shown immediately), then live Indeed scrape appends.
+            primarySource = OpenAIIndeedWebSearchDataSource(client: responsesClient, maxResults: 12)
+            enrichmentSource = LiveIndeedJobDataSource()
         } else {
             interpreter = mockInterpreter
             ranking = heuristicRanking
+            // Without an OpenAI key, web_search cannot run — surface a clear error on search.
+            primarySource = MissingOpenAIIndeedDataSource()
+            enrichmentSource = nil
         }
 
-        let indeedSource = LiveIndeedJobDataSource()
         let validation = CompositeJobValidationService(
             source: SourceSignalJobValidationService(),
             http: nil
         )
         let pipeline = DefaultJobSearchPipeline(
             interpreter: interpreter,
-            dataSource: indeedSource,
+            dataSource: primarySource,
+            enrichmentDataSource: enrichmentSource,
             rankingService: ranking,
             validationService: validation,
             cache: InMemoryIndeedListingCache()
         )
 
         return AppDependencies(
-            jobDataSource: indeedSource,
+            jobDataSource: primarySource,
             searchInterpreter: interpreter,
             validationService: validation,
             searchPipeline: pipeline,
@@ -101,3 +111,14 @@ final class AppDependencies {
         )
     }
 }
+
+/// Live search requires OpenAI web_search — fail clearly when the key is missing.
+private struct MissingOpenAIIndeedDataSource: IndeedJobDataSource {
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        throw IndeedDataError.unauthorized
+    }
+}

+ 1 - 1
indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift

@@ -49,7 +49,7 @@ struct SearchComposerView: View {
                     .font(AppTypography.heroTitle)
                     .foregroundStyle(AppColors.textPrimary)
 
-                Text("Describe the role you want. AI turns it into filters and Indeed results.")
+                Text("Describe the role you want. AI searches live Indeed listings for matching jobs.")
                     .font(AppTypography.heroSubtitle)
                     .foregroundStyle(AppColors.textSecondary)
                     .multilineTextAlignment(.center)

+ 3 - 3
indeed_ai_job_search/Domain/Enums/JobEnums.swift

@@ -5,7 +5,7 @@
 
 import Foundation
 
-enum RemoteType: String, CaseIterable, Identifiable, Codable, Hashable {
+nonisolated enum RemoteType: String, CaseIterable, Identifiable, Codable, Hashable, Sendable {
     case remote
     case hybrid
     case onsite
@@ -32,7 +32,7 @@ enum RemoteType: String, CaseIterable, Identifiable, Codable, Hashable {
     }
 }
 
-enum EmploymentType: String, CaseIterable, Identifiable, Codable, Hashable {
+nonisolated enum EmploymentType: String, CaseIterable, Identifiable, Codable, Hashable, Sendable {
     case fullTime
     case partTime
     case contract
@@ -52,7 +52,7 @@ enum EmploymentType: String, CaseIterable, Identifiable, Codable, Hashable {
     }
 }
 
-enum ExperienceLevel: String, CaseIterable, Identifiable, Codable, Hashable {
+nonisolated enum ExperienceLevel: String, CaseIterable, Identifiable, Codable, Hashable, Sendable {
     case entry
     case junior
     case mid

+ 74 - 0
indeed_ai_job_search/Domain/Protocols/JobSearchProtocols.swift

@@ -20,6 +20,29 @@ protocol JobValidationService: Sendable {
 /// End-to-end Phase 4 search: interpret → fetch → filter → validate → rank → dedupe.
 protocol JobSearchPipeline: Sendable {
     func search(_ request: JobSearchPipelineRequest) async throws -> JobSearchPipelineResult
+    /// Staged search: may yield a partial page (e.g. web_search) then a final merge with live scrape.
+    func searchProgressive(
+        _ request: JobSearchPipelineRequest
+    ) -> AsyncThrowingStream<JobSearchPipelineProgress, Error>
+}
+
+extension JobSearchPipeline {
+    /// Default: single final emission from `search`.
+    func searchProgressive(
+        _ request: JobSearchPipelineRequest
+    ) -> AsyncThrowingStream<JobSearchPipelineProgress, Error> {
+        AsyncThrowingStream { continuation in
+            Task {
+                do {
+                    let result = try await search(request)
+                    continuation.yield(JobSearchPipelineProgress(from: result, phase: .complete))
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
+            }
+        }
+    }
 }
 
 struct JobSearchPipelineRequest: Sendable {
@@ -46,6 +69,57 @@ struct JobSearchPipelineResult: Sendable {
     var usedCache: Bool
 }
 
+/// One progressive emission from the search pipeline.
+struct JobSearchPipelineProgress: Sendable {
+    enum Phase: Sendable, Equatable {
+        /// First usable batch (typically OpenAI web_search). More listings may follow.
+        case partial
+        /// Final merged (or single-source / cache) result.
+        case complete
+    }
+
+    var interpreted: InterpretedSearch
+    var listings: [JobListing]
+    var totalEstimated: Int?
+    var usedCache: Bool
+    var phase: Phase
+
+    var isLoadingMore: Bool { phase == .partial }
+
+    init(
+        interpreted: InterpretedSearch,
+        listings: [JobListing],
+        totalEstimated: Int?,
+        usedCache: Bool,
+        phase: Phase
+    ) {
+        self.interpreted = interpreted
+        self.listings = listings
+        self.totalEstimated = totalEstimated
+        self.usedCache = usedCache
+        self.phase = phase
+    }
+
+    init(from result: JobSearchPipelineResult, phase: Phase) {
+        self.init(
+            interpreted: result.interpreted,
+            listings: result.listings,
+            totalEstimated: result.totalEstimated,
+            usedCache: result.usedCache,
+            phase: phase
+        )
+    }
+
+    var asResult: JobSearchPipelineResult {
+        JobSearchPipelineResult(
+            interpreted: interpreted,
+            listings: listings,
+            totalEstimated: totalEstimated,
+            usedCache: usedCache
+        )
+    }
+}
+
 protocol SavedJobsStore: AnyObject {
     var savedJobs: [SavedJob] { get }
     func isSaved(_ listing: JobListing) -> Bool

+ 47 - 33
indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift

@@ -13,6 +13,8 @@ final class JobSearchViewModel {
     var criteria = SearchCriteria()
     var results: [JobListing] = []
     var state: SearchScreenState = .idle
+    /// True while live Indeed scrape is still appending after web_search results are shown.
+    var isLoadingMore = false
     var sortOption: JobSortOption = .relevance
     var layout: ResultsLayout = .list
     var hasSearched = false
@@ -71,32 +73,7 @@ final class JobSearchViewModel {
     func submitSearch() {
         let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmed.isEmpty else { return }
-
-        searchTask?.cancel()
-        searchTask = Task {
-            state = .loading
-            hasSearched = true
-
-            do {
-                let result = try await pipeline.search(
-                    JobSearchPipelineRequest(
-                        naturalLanguageQuery: trimmed,
-                        userCriteria: criteria,
-                        reinterpret: true
-                    )
-                )
-                if Task.isCancelled { return }
-
-                criteria = result.interpreted.criteria
-                results = result.listings
-                totalResultCount = result.totalEstimated ?? result.listings.count
-                state = result.listings.isEmpty ? .empty : .success
-            } catch is CancellationError {
-                return
-            } catch {
-                state = .error(error.localizedDescription)
-            }
-        }
+        runProgressiveSearch(query: trimmed, criteria: criteria, reinterpret: true)
     }
 
     func applyFiltersAndRefresh() {
@@ -122,25 +99,62 @@ final class JobSearchViewModel {
     /// Re-runs fetch/filter/rank with current criteria without re-interpreting the query.
     private func refreshResultsOnly() {
         let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        runProgressiveSearch(query: trimmed, criteria: criteria, reinterpret: false)
+    }
+
+    private func runProgressiveSearch(
+        query trimmed: String,
+        criteria searchCriteria: SearchCriteria,
+        reinterpret: Bool
+    ) {
         searchTask?.cancel()
         searchTask = Task {
             state = .loading
+            isLoadingMore = false
+            hasSearched = true
+            results = []
+            totalResultCount = 0
+
             do {
-                let result = try await pipeline.search(
+                let stream = pipeline.searchProgressive(
                     JobSearchPipelineRequest(
                         naturalLanguageQuery: trimmed,
-                        userCriteria: criteria,
-                        reinterpret: false
+                        userCriteria: searchCriteria,
+                        reinterpret: reinterpret
                     )
                 )
+
+                for try await progress in stream {
+                    if Task.isCancelled { return }
+
+                    criteria = progress.interpreted.criteria
+                    results = progress.listings
+                    totalResultCount = progress.totalEstimated ?? progress.listings.count
+                    isLoadingMore = progress.isLoadingMore
+
+                    if !progress.listings.isEmpty {
+                        state = .success
+                    } else if progress.phase == .complete {
+                        state = .empty
+                    }
+                    // else: still loading primary/enrichment with no rows yet
+                }
+
                 if Task.isCancelled { return }
-                results = result.listings
-                totalResultCount = result.totalEstimated ?? result.listings.count
-                state = result.listings.isEmpty ? .empty : .success
+                isLoadingMore = false
+                if state == .loading {
+                    state = results.isEmpty ? .empty : .success
+                }
             } catch is CancellationError {
                 return
             } catch {
-                state = .error(error.localizedDescription)
+                isLoadingMore = false
+                // Keep partial results if we already showed some.
+                if results.isEmpty {
+                    state = .error(error.localizedDescription)
+                } else {
+                    state = .success
+                }
             }
         }
     }

+ 26 - 1
indeed_ai_job_search/Features/JobSearch/Views/AIJobSearchView.swift

@@ -110,7 +110,11 @@ struct AIJobSearchView: View {
                 case .loading:
                     Text("Searching Indeed…")
                 case .success, .empty:
-                    Text("\(viewModel.totalResultCount.formatted()) jobs found")
+                    if viewModel.isLoadingMore {
+                        Text("\(viewModel.totalResultCount.formatted()) jobs · Loading more from Indeed…")
+                    } else {
+                        Text("\(viewModel.totalResultCount.formatted()) jobs found")
+                    }
                 case .error:
                     Text("Search unavailable")
                 case .idle:
@@ -209,6 +213,9 @@ struct AIJobSearchView: View {
                                 }
                             }
                         }
+                        if viewModel.isLoadingMore {
+                            loadingMoreRow
+                        }
                     }
                 } else {
                     LazyVGrid(
@@ -231,8 +238,26 @@ struct AIJobSearchView: View {
                             }
                         }
                     }
+                    if viewModel.isLoadingMore {
+                        loadingMoreRow
+                            .padding(.top, AppSpacing.sm)
+                    }
                 }
             }
         }
     }
+
+    private var loadingMoreRow: some View {
+        HStack(spacing: AppSpacing.sm) {
+            ProgressView()
+                .controlSize(.small)
+            Text("Loading more Indeed jobs…")
+                .font(AppTypography.meta)
+                .foregroundStyle(AppColors.textSecondary)
+            Spacer(minLength: 0)
+        }
+        .padding(.vertical, AppSpacing.sm)
+        .accessibilityElement(children: .combine)
+        .accessibilityLabel("Loading more Indeed jobs")
+    }
 }

+ 48 - 0
indeed_ai_job_search/Services/Indeed/FallbackIndeedJobDataSource.swift

@@ -0,0 +1,48 @@
+//
+//  FallbackIndeedJobDataSource.swift
+//  indeed_ai_job_search
+//
+//  Tries the primary Indeed source first; if it succeeds with zero listings
+//  (or fails with a soft network/parse error), uses the fallback source.
+//
+
+import Foundation
+import OSLog
+
+struct FallbackIndeedJobDataSource: IndeedJobDataSource {
+    private static let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "IndeedFallback"
+    )
+
+    var primary: any IndeedJobDataSource
+    var fallback: any IndeedJobDataSource
+
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        do {
+            let primaryPage = try await primary.searchPage(
+                criteria: criteria,
+                query: query,
+                page: page
+            )
+            if !primaryPage.listings.isEmpty || page.pageIndex > 0 {
+                return primaryPage
+            }
+            Self.log.warning("Primary Indeed source returned 0 listings — trying fallback")
+        } catch {
+            Self.log.warning(
+                "Primary Indeed source failed (\(error.localizedDescription, privacy: .public)) — trying fallback"
+            )
+        }
+
+        return try await fallback.searchPage(
+            criteria: criteria,
+            query: query,
+            page: page
+        )
+    }
+}

+ 7 - 5
indeed_ai_job_search/Services/Indeed/IndeedJobDataSource.swift

@@ -66,7 +66,7 @@ enum IndeedDataError: LocalizedError, Equatable, Sendable {
     var errorDescription: String? {
         switch self {
         case .unauthorized:
-            return "Indeed data source credentials are missing or invalid."
+            return "OpenAI is not configured. Add your API key to search live Indeed jobs."
         case .forbidden:
             return "Indeed blocked this search (security check). Try again in a moment, or open Indeed in Safari once and retry."
         case .rateLimited(let seconds):
@@ -91,8 +91,10 @@ enum IndeedDataError: LocalizedError, Equatable, Sendable {
 }
 
 /// Suggested in-memory cache policy for Phase 4+ adapters (documented here for DADR).
-enum IndeedCachePolicy {
-    static let listingMetadataTTL: TimeInterval = 15 * 60
-    static let savedJobRevalidationTTL: TimeInterval = 24 * 60 * 60
-    static let maxPagesPerSearchUI = 4
+/// `nonisolated` so actors / background caches can read defaults under MainActor isolation.
+enum IndeedCachePolicy: Sendable {
+    /// Short TTL so recent-job searches don’t keep stale AI results around.
+    nonisolated static let listingMetadataTTL: TimeInterval = 5 * 60
+    nonisolated static let savedJobRevalidationTTL: TimeInterval = 24 * 60 * 60
+    nonisolated static let maxPagesPerSearchUI = 4
 }

+ 4 - 3
indeed_ai_job_search/Services/Indeed/LiveIndeedJobDataSource.swift

@@ -81,12 +81,13 @@ struct LiveIndeedJobDataSource: IndeedJobDataSource {
         )
 
         // Indeed usually returns ~15 cards per page while our request size is 25.
-        // Only claim another page when this page looks full — otherwise page-1
-        // loads often hang and used to wipe a successful page-0 result.
+        // Page again when the page looks full, or once more from page 0 if we
+        // still have fewer than ~10 usable rows (filters often thin the first page).
         let requestedSize = page.pageSize > 0 ? page.pageSize : pageSize
         let looksFull = listings.count >= max(15, requestedSize - 5)
+        let needsSecondPage = page.pageIndex == 0 && listings.count < 10
         let canPage = page.pageIndex + 1 < IndeedCachePolicy.maxPagesPerSearchUI
-        let hasMore = looksFull && canPage && !listings.isEmpty
+        let hasMore = canPage && !listings.isEmpty && (looksFull || needsSecondPage)
 
         return IndeedSearchPage(
             listings: listings,

+ 532 - 0
indeed_ai_job_search/Services/Indeed/OpenAIIndeedWebSearchDataSource.swift

@@ -0,0 +1,532 @@
+//
+//  OpenAIIndeedWebSearchDataSource.swift
+//  indeed_ai_job_search
+//
+//  Production Indeed search via OpenAI Responses API + hosted web_search,
+//  restricted to Indeed domains. Returns only listings with valid Indeed URLs.
+//
+
+import Foundation
+import OSLog
+
+struct OpenAIIndeedWebSearchDataSource: IndeedJobDataSource {
+    private static let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "OpenAIIndeedSearch"
+    )
+
+    /// Soft prefer window (prompt). Hard drop above `maxPostedDaysAgo`.
+    static let preferredPostedDaysAgo = 14
+    /// Hard max age for AI-returned postings (client-side drop when age is known).
+    static let maxPostedDaysAgo = 30
+
+    /// Domains web_search may consult (subdomains of indeed.com included when listed).
+    static let indeedAllowedDomains = [
+        "indeed.com",
+        "www.indeed.com",
+        "pk.indeed.com",
+        "uk.indeed.com",
+        "ca.indeed.com",
+        "indeed.co.uk",
+        "indeed.com.au",
+        "ae.indeed.com",
+        "de.indeed.com",
+        "fr.indeed.com",
+        "in.indeed.com"
+    ]
+
+    var client: OpenAIResponsesClient
+    var maxResults: Int
+    var maxAgeDays: Int
+
+    init(
+        client: OpenAIResponsesClient,
+        maxResults: Int = 12,
+        maxAgeDays: Int = Self.maxPostedDaysAgo
+    ) {
+        self.client = client
+        self.maxResults = max(8, min(maxResults, 25))
+        self.maxAgeDays = max(1, maxAgeDays)
+    }
+
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        // Single-shot web search — no multi-page crawl.
+        if page.pageIndex > 0 {
+            return IndeedSearchPage(
+                listings: [],
+                pageIndex: page.pageIndex,
+                pageSize: page.pageSize,
+                totalEstimated: 0,
+                nextCursor: nil,
+                hasMore: false
+            )
+        }
+
+        let freshnessDays = criteria.dateFreshnessDays.map { min(max(1, $0), maxAgeDays) } ?? maxAgeDays
+        let preferredDays = min(Self.preferredPostedDaysAgo, freshnessDays)
+
+        // First pass: prefer last 14 days. If empty, one looser retry (same hard max).
+        var listings = try await runSearch(
+            query: query,
+            criteria: criteria,
+            maxResults: maxResults,
+            freshnessDays: freshnessDays,
+            preferredDays: preferredDays,
+            relaxed: false
+        )
+
+        if listings.isEmpty {
+            Self.log.warning("First Indeed web_search returned 0 — retrying with relaxed freshness instructions")
+            listings = try await runSearch(
+                query: query,
+                criteria: criteria,
+                maxResults: maxResults,
+                freshnessDays: freshnessDays,
+                preferredDays: freshnessDays,
+                relaxed: true
+            )
+        }
+
+        return IndeedSearchPage(
+            listings: listings,
+            pageIndex: 0,
+            pageSize: page.pageSize,
+            totalEstimated: listings.count,
+            nextCursor: nil,
+            hasMore: false
+        )
+    }
+
+    private func runSearch(
+        query: String,
+        criteria: SearchCriteria,
+        maxResults: Int,
+        freshnessDays: Int,
+        preferredDays: Int,
+        relaxed: Bool
+    ) async throws -> [JobListing] {
+        let prompt = Self.buildPrompt(
+            query: query,
+            criteria: criteria,
+            maxResults: maxResults,
+            preferredDays: preferredDays,
+            maxAgeDays: freshnessDays,
+            relaxed: relaxed
+        )
+
+        Self.log.info(
+            "Indeed web_search start relaxed=\(relaxed, privacy: .public) queryChars=\(query.count, privacy: .public) title=\(criteria.jobTitle.isEmpty ? "(none)" : criteria.jobTitle, privacy: .public) preferred=\(preferredDays, privacy: .public) maxAge=\(freshnessDays, privacy: .public)"
+        )
+
+        let outputText: String
+        do {
+            outputText = try await client.createWithWebSearch(
+                input: prompt,
+                allowedDomains: Self.indeedAllowedDomains,
+                requireWebSearch: true
+            )
+        } catch let error as OpenAIClientError {
+            throw Self.mapOpenAIError(error)
+        }
+
+        let preview = String(outputText.prefix(400))
+        Self.log.info("Indeed web_search rawPreview=\(preview, privacy: .public)")
+
+        let rows: [AIIndeedJobRow]
+        do {
+            rows = try Self.decodeJobRows(from: outputText)
+        } catch {
+            Self.log.error(
+                "Indeed web_search JSON parse failed: \(error.localizedDescription, privacy: .public) preview=\(preview, privacy: .public)"
+            )
+            throw error
+        }
+
+        var listings: [JobListing] = []
+        listings.reserveCapacity(rows.count)
+        var droppedStale = 0
+        var droppedClosed = 0
+        var droppedInvalid = 0
+
+        for row in rows {
+            guard row.isAcceptingApplications else {
+                droppedClosed += 1
+                continue
+            }
+            guard let dto = row.asIndeedJobDTO(maxAgeDays: freshnessDays, allowMissingDate: true) else {
+                if let days = row.postedDaysAgo, days > freshnessDays {
+                    droppedStale += 1
+                } else {
+                    droppedInvalid += 1
+                }
+                continue
+            }
+
+            do {
+                let listing = try IndeedListingMapper.map(dto)
+                guard IndeedURLCanonicalizer.isIndeedHost(listing.indeedURL.host ?? "") else {
+                    Self.log.warning("Dropped non-Indeed URL \(listing.indeedURL.absoluteString, privacy: .public)")
+                    droppedInvalid += 1
+                    continue
+                }
+                if let days = row.postedDaysAgo, days > freshnessDays {
+                    droppedStale += 1
+                    continue
+                }
+                // Only age-check when we have a real posted date (not mapper "now" fallback).
+                if row.postedDaysAgo != nil || (row.postedAt?.isEmpty == false) {
+                    let age = Calendar.current.dateComponents([.day], from: listing.postedAt, to: Date()).day ?? 0
+                    if age > freshnessDays {
+                        droppedStale += 1
+                        continue
+                    }
+                }
+                if listing.availability == .unavailable {
+                    droppedClosed += 1
+                    continue
+                }
+                listings.append(listing)
+            } catch {
+                droppedInvalid += 1
+                Self.log.warning(
+                    "Skipped unmappable AI job row: \(error.localizedDescription, privacy: .public)"
+                )
+            }
+        }
+
+        listings = JobListingFilter.filter(listings, criteria: criteria, query: query)
+        listings = JobListingDeduper.dedupe(listings)
+        listings.sort { $0.postedAt > $1.postedAt }
+
+        Self.log.info(
+            "Indeed web_search kept \(listings.count, privacy: .public) (raw \(rows.count, privacy: .public), stale=\(droppedStale, privacy: .public), closed=\(droppedClosed, privacy: .public), invalid=\(droppedInvalid, privacy: .public))"
+        )
+        return listings
+    }
+
+    // MARK: - Prompt
+
+    static func buildPrompt(
+        query: String,
+        criteria: SearchCriteria,
+        maxResults: Int,
+        preferredDays: Int,
+        maxAgeDays: Int,
+        relaxed: Bool
+    ) -> String {
+        var filters: [String] = []
+        let title = criteria.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
+        if !title.isEmpty { filters.append("Job title focus: \(title)") }
+        let keywords = criteria.keywords.trimmingCharacters(in: .whitespacesAndNewlines)
+        if !keywords.isEmpty { filters.append("Keywords: \(keywords)") }
+        if !criteria.country.isEmpty { filters.append("Country: \(criteria.country)") }
+        if !criteria.city.isEmpty { filters.append("City/region: \(criteria.city)") }
+        if !criteria.remoteTypes.isEmpty {
+            let modes = criteria.remoteTypes.map(\.rawValue).sorted().joined(separator: ", ")
+            filters.append("Work mode must be one of: \(modes)")
+        }
+        if !criteria.employmentTypes.isEmpty {
+            let types = criteria.employmentTypes.map(\.rawValue).sorted().joined(separator: ", ")
+            filters.append("Employment type preference: \(types)")
+        }
+        if !criteria.excludedKeywords.isEmpty {
+            filters.append("Exclude roles mentioning: \(criteria.excludedKeywords)")
+        }
+        if let floor = criteria.salaryFloor {
+            filters.append("Prefer roles at or above salary floor \(Int(floor)) (when salary is published)")
+        }
+
+        let filterBlock = filters.isEmpty
+            ? "No extra filters beyond the user request."
+            : filters.joined(separator: "\n")
+
+        let what = title.isEmpty
+            ? query.trimmingCharacters(in: .whitespacesAndNewlines)
+            : title
+        let encodedWhat = what.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? what
+        let locationHint: String
+        if !criteria.city.isEmpty || !criteria.country.isEmpty {
+            locationHint = [criteria.city, criteria.country]
+                .filter { !$0.isEmpty }
+                .joined(separator: ", ")
+        } else if criteria.remoteTypes.contains(.remote) {
+            locationHint = "Remote"
+        } else {
+            locationHint = ""
+        }
+        let encodedLoc = locationHint.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
+
+        let exampleURL: String
+        if encodedLoc.isEmpty {
+            exampleURL = "https://www.indeed.com/jobs?q=\(encodedWhat)&fromage=\(preferredDays)"
+        } else {
+            exampleURL = "https://www.indeed.com/jobs?q=\(encodedWhat)&l=\(encodedLoc)&fromage=\(preferredDays)"
+        }
+
+        let freshnessTone = relaxed
+            ? "Return currently open Indeed jobs posted within the last \(maxAgeDays) days. Prefer newer ones."
+            : "Strongly prefer jobs posted within the last \(preferredDays) days (Indeed fromage=\(preferredDays)). You may include up to \(maxAgeDays) days if needed to fill the list."
+
+        let today = ISO8601DateFormatter().string(from: Date())
+
+        return """
+        You are an Indeed-only job search assistant for a macOS app.
+        Today’s date (UTC): \(today).
+
+        Use web_search to find CURRENTLY OPEN jobs on Indeed that match the user request.
+        Search ONLY Indeed sites (indeed.com and regional Indeed TLDs).
+
+        Required search approach:
+        1) Start with Indeed search URLs that include fromage for freshness, for example:
+           \(exampleURL)
+           Also try regional hosts when location implies them (e.g. pk.indeed.com for Pakistan).
+        2) From the results, collect specific job postings with /viewjob?jk=… (or rc/clk links that resolve to a job).
+        3) Return a JSON array of those jobs — do not return an empty array if open Indeed postings exist for this query.
+
+        Freshness:
+        - \(freshnessTone)
+        - HARD EXCLUDE: “No longer accepting applications”, closed, expired, filled.
+        - HARD EXCLUDE: posted more than \(maxAgeDays) days ago (“30+ days ago”, “1 month ago”, “1 year ago”).
+
+        Other rules:
+        - Do NOT invent companies, titles, salaries, or URLs.
+        - Every url MUST be an Indeed absolute https URL.
+        - Deduplicate by company + title.
+        - Return \(max(8, min(maxResults, 12)))–\(maxResults) jobs when enough open Indeed postings exist (target ~10). Newest first.
+        - If salary/currency is unknown, omit salary fields.
+        - Always set accepting_applications and posted_days_ago when you can read them from the page.
+
+        User request:
+        \(query.trimmingCharacters(in: .whitespacesAndNewlines))
+
+        Additional filters:
+        \(filterBlock)
+
+        Return ONLY a JSON array (no markdown fences, no commentary). Each object keys:
+        - "title": string
+        - "company": string
+        - "location": string
+        - "url": absolute https Indeed URL (include jk= when possible)
+        - "jobKey": optional 16-character hex Indeed jk
+        - "remoteType": optional "remote" | "hybrid" | "onsite"
+        - "employmentType": optional "full-time" | "part-time" | "contract" | "temporary" | "internship"
+        - "salaryMin": optional number
+        - "salaryMax": optional number
+        - "salaryCurrency": optional ISO code — required if salaryMin/Max present
+        - "salaryPeriod": optional "year" | "month" | "hour" | "week"
+        - "snippet": optional short description
+        - "postedAt": optional ISO-8601 date string
+        - "posted_days_ago": integer when known (0 = today)
+        - "accepting_applications": boolean (omit only if unknown; false if closed)
+        - "availability": optional "open" | "unavailable"
+        - "isEasyApply": optional boolean
+        - "isUrgentlyHiring": optional boolean
+        """
+    }
+
+    // Compatibility overload used by older tests.
+    static func buildPrompt(
+        query: String,
+        criteria: SearchCriteria,
+        maxResults: Int,
+        maxAgeDays: Int
+    ) -> String {
+        buildPrompt(
+            query: query,
+            criteria: criteria,
+            maxResults: maxResults,
+            preferredDays: min(preferredPostedDaysAgo, maxAgeDays),
+            maxAgeDays: maxAgeDays,
+            relaxed: false
+        )
+    }
+
+    // MARK: - Decode
+
+    static func decodeJobRows(from outputText: String) throws -> [AIIndeedJobRow] {
+        let jsonText = extractJSONArray(from: outputText)
+        guard let data = jsonText.data(using: .utf8) else {
+            throw IndeedDataError.invalidResponse("Empty AI job search payload.")
+        }
+
+        if let decoded = try? JSONDecoder().decode([AIIndeedJobRow].self, from: data) {
+            return decoded
+        }
+        if let wrapped = try? JSONDecoder().decode(WrappedAIResults.self, from: data) {
+            return wrapped.results
+        }
+        // Model sometimes returns a single object instead of an array.
+        if let single = try? JSONDecoder().decode(AIIndeedJobRow.self, from: data),
+           single.asIndeedJobDTO(maxAgeDays: maxPostedDaysAgo, allowMissingDate: true) != nil
+            || !(single.title ?? "").isEmpty {
+            return [single]
+        }
+        throw IndeedDataError.invalidResponse(
+            "Could not parse Indeed job JSON from the AI response. Try searching again."
+        )
+    }
+
+    static func decodeJobDTOs(
+        from outputText: String,
+        maxAgeDays: Int = maxPostedDaysAgo
+    ) throws -> [IndeedJobDTO] {
+        try decodeJobRows(from: outputText).compactMap {
+            $0.asIndeedJobDTO(maxAgeDays: maxAgeDays, allowMissingDate: true)
+        }
+    }
+
+    static func extractJSONArray(from text: String) -> String {
+        var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.hasPrefix("```") {
+            trimmed = trimmed
+                .replacingOccurrences(of: "```json", with: "", options: .caseInsensitive)
+                .replacingOccurrences(of: "```", with: "")
+                .trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+        if let start = trimmed.firstIndex(of: "["),
+           let end = trimmed.lastIndex(of: "]"),
+           start <= end {
+            return String(trimmed[start...end])
+        }
+        if let start = trimmed.firstIndex(of: "{"),
+           let end = trimmed.lastIndex(of: "}"),
+           start <= end {
+            return String(trimmed[start...end])
+        }
+        return trimmed
+    }
+
+    /// Flexible decode shape — AI JSON is often incomplete; we normalize before mapping.
+    struct AIIndeedJobRow: Decodable, Equatable {
+        var title: String?
+        var company: String?
+        var location: String?
+        var url: String?
+        var jobKey: String?
+        var remoteType: String?
+        var employmentType: String?
+        var experienceLevel: String?
+        var salaryMin: Double?
+        var salaryMax: Double?
+        var salaryCurrency: String?
+        var salaryPeriod: String?
+        var snippet: String?
+        var postedAt: String?
+        var postedDaysAgo: Int?
+        var acceptingApplications: Bool?
+        var availability: String?
+        var isEasyApply: Bool?
+        var isFeatured: Bool?
+        var isUrgentlyHiring: Bool?
+
+        enum CodingKeys: String, CodingKey {
+            case title, company, location, url, jobKey, remoteType, employmentType, experienceLevel
+            case salaryMin, salaryMax, salaryCurrency, salaryPeriod, snippet, postedAt
+            case postedDaysAgo = "posted_days_ago"
+            case acceptingApplications = "accepting_applications"
+            case availability, isEasyApply, isFeatured, isUrgentlyHiring
+        }
+
+        var isAcceptingApplications: Bool {
+            if acceptingApplications == false { return false }
+            let availability = availability?.lowercased() ?? ""
+            if availability == "unavailable" || availability == "closed" || availability == "expired" {
+                return false
+            }
+            let hay = "\(snippet ?? "") \(title ?? "")".lowercased()
+            if hay.contains("no longer accepting")
+                || hay.contains("not accepting applications")
+                || hay.contains("job has expired") {
+                return false
+            }
+            return true
+        }
+
+        func asIndeedJobDTO(maxAgeDays: Int, allowMissingDate: Bool) -> IndeedJobDTO? {
+            let title = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+            let company = company?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+            let url = url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+            guard !title.isEmpty, !company.isEmpty, !url.isEmpty else { return nil }
+
+            let postedISO: String?
+            if let days = postedDaysAgo {
+                guard days >= 0, days <= maxAgeDays else { return nil }
+                if let postedAt, !postedAt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+                    postedISO = postedAt
+                } else if let date = Calendar.current.date(byAdding: .day, value: -days, to: Date()) {
+                    postedISO = ISO8601DateFormatter().string(from: date)
+                } else {
+                    return nil
+                }
+            } else if let postedAt, let parsed = Self.parseISODate(postedAt) {
+                let days = Calendar.current.dateComponents([.day], from: parsed, to: Date()).day ?? maxAgeDays + 1
+                guard days <= maxAgeDays else { return nil }
+                postedISO = postedAt
+            } else if allowMissingDate {
+                // Keep open jobs even when Indeed snippet omitted a date.
+                postedISO = nil
+            } else {
+                return nil
+            }
+
+            let location = location?.trimmingCharacters(in: .whitespacesAndNewlines)
+            return IndeedJobDTO(
+                jobKey: jobKey,
+                title: title,
+                company: company,
+                location: (location?.isEmpty == false) ? location! : "Indeed",
+                remoteType: remoteType,
+                employmentType: employmentType,
+                experienceLevel: experienceLevel,
+                salaryMin: salaryMin,
+                salaryMax: salaryMax,
+                salaryCurrency: salaryCurrency,
+                salaryPeriod: salaryPeriod,
+                postedAt: postedISO,
+                snippet: snippet,
+                url: url,
+                availability: "open",
+                isEasyApply: isEasyApply,
+                isFeatured: isFeatured,
+                isUrgentlyHiring: isUrgentlyHiring
+            )
+        }
+
+        private static func parseISODate(_ raw: String) -> Date? {
+            let fractional = ISO8601DateFormatter()
+            fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+            if let date = fractional.date(from: raw) { return date }
+            let basic = ISO8601DateFormatter()
+            basic.formatOptions = [.withInternetDateTime]
+            return basic.date(from: raw)
+        }
+    }
+
+    private struct WrappedAIResults: Decodable {
+        var results: [AIIndeedJobRow]
+    }
+
+    private static func mapOpenAIError(_ error: OpenAIClientError) -> IndeedDataError {
+        switch error {
+        case .notConfigured:
+            return .unauthorized
+        case .timeout:
+            return .network("The AI Indeed search timed out. Try again.")
+        case .cancelled:
+            return .cancelled
+        case .httpStatus(let code, let detail):
+            if code == 401 || code == 403 {
+                return .unauthorized
+            }
+            return .network(detail ?? "OpenAI HTTP \(code)")
+        case .network(let detail):
+            return .network(detail)
+        case .invalidResponse, .decodingFailed, .disabled:
+            return .invalidResponse(error.localizedDescription)
+        }
+    }
+}

+ 4 - 0
indeed_ai_job_search/Services/OpenAI/OpenAIModels.swift

@@ -15,4 +15,8 @@ enum OpenAIModels {
     /// Chat Completions structured JSON works best with reasoning disabled
     /// (lower latency/cost; required for function calling on this model).
     static let defaultReasoningEffort = "none"
+
+    /// Responses API + `web_search` (live Indeed job discovery).
+    /// Must be a model that supports hosted web search tools.
+    static let defaultWebSearchModel = "gpt-4o"
 }

+ 148 - 0
indeed_ai_job_search/Services/OpenAI/OpenAIResponsesClient.swift

@@ -0,0 +1,148 @@
+//
+//  OpenAIResponsesClient.swift
+//  indeed_ai_job_search
+//
+//  Thin URLSession client for POST /v1/responses (hosted tools such as web_search).
+//
+
+import Foundation
+import OSLog
+
+struct OpenAIResponsesClient: Sendable {
+    private static let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "OpenAIResponses"
+    )
+
+    var apiKey: String
+    var model: String
+    var timeoutInterval: TimeInterval
+    var session: URLSession
+
+    init(
+        apiKey: String,
+        model: String = OpenAIModels.defaultWebSearchModel,
+        timeoutInterval: TimeInterval = 90,
+        session: URLSession? = nil
+    ) {
+        self.apiKey = apiKey
+        self.model = model
+        self.timeoutInterval = timeoutInterval
+        if let session {
+            self.session = session
+        } else {
+            let config = URLSessionConfiguration.ephemeral
+            config.timeoutIntervalForRequest = timeoutInterval
+            config.timeoutIntervalForResource = timeoutInterval
+            self.session = URLSession(configuration: config)
+        }
+    }
+
+    /// Runs a Responses request. When `requireWebSearch` is true, forces the hosted
+    /// `web_search` tool and optionally restricts domains (Indeed-only).
+    func createWithWebSearch(
+        input: String,
+        allowedDomains: [String],
+        requireWebSearch: Bool = true
+    ) async throws -> String {
+        guard let url = URL(string: "https://api.openai.com/v1/responses") else {
+            throw OpenAIClientError.invalidResponse
+        }
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.timeoutInterval = timeoutInterval
+
+        var webSearchTool: [String: Any] = ["type": "web_search"]
+        if !allowedDomains.isEmpty {
+            webSearchTool["filters"] = ["allowed_domains": allowedDomains]
+        }
+
+        var body: [String: Any] = [
+            "model": model,
+            "input": input,
+            "tools": [webSearchTool]
+        ]
+        if requireWebSearch {
+            body["tool_choice"] = "required"
+        }
+
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+        Self.log.info(
+            "POST /v1/responses model=\(self.model, privacy: .public) web_search domains=\(allowedDomains.count, privacy: .public) inputChars=\(input.count, privacy: .public)"
+        )
+
+        do {
+            let (data, response) = try await session.data(for: request)
+            guard let http = response as? HTTPURLResponse else {
+                throw OpenAIClientError.invalidResponse
+            }
+            guard (200..<300).contains(http.statusCode) else {
+                throw OpenAIClientError.httpStatus(http.statusCode, Self.redactedErrorMessage(from: data))
+            }
+            return try Self.extractOutputText(from: data)
+        } catch let error as OpenAIClientError {
+            throw error
+        } catch is CancellationError {
+            throw OpenAIClientError.cancelled
+        } catch let urlError as URLError where urlError.code == .timedOut {
+            throw OpenAIClientError.timeout
+        } catch {
+            throw OpenAIClientError.network(error.localizedDescription)
+        }
+    }
+
+    // MARK: - Response parsing
+
+    /// Prefer convenience `output_text`; otherwise concatenate message `output_text` parts.
+    static func extractOutputText(from data: Data) throws -> String {
+        guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+            throw OpenAIClientError.invalidResponse
+        }
+
+        if let text = root["output_text"] as? String {
+            let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+            if !trimmed.isEmpty { return trimmed }
+        }
+
+        guard let output = root["output"] as? [[String: Any]] else {
+            throw OpenAIClientError.invalidResponse
+        }
+
+        var chunks: [String] = []
+        for item in output {
+            guard (item["type"] as? String) == "message",
+                  let content = item["content"] as? [[String: Any]] else { continue }
+            for part in content {
+                let type = part["type"] as? String
+                if type == "output_text" || type == "text",
+                   let text = part["text"] as? String {
+                    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+                    if !trimmed.isEmpty { chunks.append(trimmed) }
+                }
+            }
+        }
+
+        let joined = chunks.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !joined.isEmpty else { throw OpenAIClientError.invalidResponse }
+        return joined
+    }
+
+    private static func redactedErrorMessage(from data: Data) -> String? {
+        guard
+            let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+            let error = root["error"] as? [String: Any],
+            let message = error["message"] as? String
+        else {
+            return nil
+        }
+        let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.count > 180 {
+            return String(trimmed.prefix(180)) + "…"
+        }
+        return trimmed
+    }
+}

+ 4 - 3
indeed_ai_job_search/Services/OpenAI/SearchCriteriaInterpretationDTO.swift

@@ -61,7 +61,8 @@ struct SearchCriteriaInterpretationDTO: Codable, Equatable, Sendable {
         return criteria
     }
 
-    private static func mapRemote(_ raw: String) -> RemoteType? {
+    // Domain mappers stay nonisolated — DTO is Sendable and may run off the main actor.
+    nonisolated private static func mapRemote(_ raw: String) -> RemoteType? {
         switch raw.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "") {
         case "remote": return .remote
         case "hybrid": return .hybrid
@@ -71,7 +72,7 @@ struct SearchCriteriaInterpretationDTO: Codable, Equatable, Sendable {
         }
     }
 
-    private static func mapEmployment(_ raw: String) -> EmploymentType? {
+    nonisolated private static func mapEmployment(_ raw: String) -> EmploymentType? {
         switch raw.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "") {
         case "fulltime": return .fullTime
         case "parttime": return .partTime
@@ -82,7 +83,7 @@ struct SearchCriteriaInterpretationDTO: Codable, Equatable, Sendable {
         }
     }
 
-    private static func mapExperience(_ raw: String) -> ExperienceLevel? {
+    nonisolated private static func mapExperience(_ raw: String) -> ExperienceLevel? {
         switch raw.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "") {
         case "entry", "entrylevel": return .entry
         case "junior": return .junior

+ 183 - 68
indeed_ai_job_search/Services/Search/JobSearchPipeline.swift

@@ -4,6 +4,8 @@
 //
 //  Phase 4 orchestrator: NL → criteria → Indeed fetch → filter → validate →
 //  rank → dedupe (with optional in-memory metadata cache).
+//  Supports progressive enrichment: primary (e.g. web_search) shown first,
+//  then optional live scrape merged underneath.
 //
 
 import Foundation
@@ -16,7 +18,10 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
     )
 
     var interpreter: OpenAISearchService
+    /// Fast / AI path (OpenAI web_search). Shown to the user first when enrichment is configured.
     var dataSource: IndeedJobDataSource
+    /// Optional second pass (live Indeed HTML). Appended after primary, deduped by job key.
+    var enrichmentDataSource: (any IndeedJobDataSource)?
     var rankingService: OpenAIRankingService
     var validationService: CompositeJobValidationService
     var cache: any JobListingCache
@@ -25,6 +30,7 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
     init(
         interpreter: OpenAISearchService,
         dataSource: IndeedJobDataSource,
+        enrichmentDataSource: (any IndeedJobDataSource)? = nil,
         rankingService: OpenAIRankingService,
         validationService: CompositeJobValidationService,
         cache: any JobListingCache = NullJobListingCache(),
@@ -32,6 +38,7 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
     ) {
         self.interpreter = interpreter
         self.dataSource = dataSource
+        self.enrichmentDataSource = enrichmentDataSource
         self.rankingService = rankingService
         self.validationService = validationService
         self.cache = cache
@@ -39,6 +46,48 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
     }
 
     func search(_ request: JobSearchPipelineRequest) async throws -> JobSearchPipelineResult {
+        var final = JobSearchPipelineResult(
+            interpreted: InterpretedSearch(
+                naturalLanguageQuery: request.naturalLanguageQuery,
+                criteria: request.userCriteria,
+                confidenceNote: ""
+            ),
+            listings: [],
+            totalEstimated: 0,
+            usedCache: false
+        )
+        for try await progress in searchProgressive(request) {
+            final = progress.asResult
+        }
+        return final
+    }
+
+    func searchProgressive(
+        _ request: JobSearchPipelineRequest
+    ) -> AsyncThrowingStream<JobSearchPipelineProgress, Error> {
+        AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    try await self.runProgressive(request, continuation: continuation)
+                    continuation.finish()
+                } catch is CancellationError {
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
+            }
+            continuation.onTermination = { _ in
+                task.cancel()
+            }
+        }
+    }
+
+    // MARK: - Progressive core
+
+    private func runProgressive(
+        _ request: JobSearchPipelineRequest,
+        continuation: AsyncThrowingStream<JobSearchPipelineProgress, Error>.Continuation
+    ) async throws {
         let trimmed = request.naturalLanguageQuery.trimmingCharacters(in: .whitespacesAndNewlines)
 
         let interpreted: InterpretedSearch
@@ -59,51 +108,156 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
             )
         }
 
+        let noteWrapped = InterpretedSearch(
+            naturalLanguageQuery: trimmed,
+            criteria: criteria,
+            confidenceNote: interpreted.confidenceNote
+        )
+
         Self.log.info(
             """
             Search start query=\(trimmed, privacy: .public) \
             country=\(criteria.country.isEmpty ? "(none)" : criteria.country, privacy: .public) \
             city=\(criteria.city.isEmpty ? "(none)" : criteria.city, privacy: .public) \
             title=\(criteria.jobTitle.isEmpty ? "(none)" : criteria.jobTitle, privacy: .public) \
-            reinterpret=\(request.reinterpret, privacy: .public)
+            reinterpret=\(request.reinterpret, privacy: .public) \
+            enrichment=\(self.enrichmentDataSource != nil, privacy: .public)
             """
         )
 
-        if let cached = await cache.get(query: trimmed, criteria: criteria) {
+        if let cached = await cache.get(query: trimmed, criteria: criteria), !cached.listings.isEmpty {
             Self.log.debug("Cache hit — \(cached.listings.count, privacy: .public) listings")
-            return JobSearchPipelineResult(
-                interpreted: InterpretedSearch(
-                    naturalLanguageQuery: trimmed,
-                    criteria: criteria,
-                    confidenceNote: interpreted.confidenceNote
-                ),
-                listings: cached.listings,
-                totalEstimated: cached.totalEstimated,
-                usedCache: true
+            continuation.yield(
+                JobSearchPipelineProgress(
+                    interpreted: noteWrapped,
+                    listings: cached.listings,
+                    totalEstimated: cached.totalEstimated,
+                    usedCache: true,
+                    phase: .complete
+                )
             )
+            return
         }
 
-        let fetched = try await fetchPages(criteria: criteria, query: trimmed)
-        Self.log.info("Fetched \(fetched.listings.count, privacy: .public) raw listings")
+        try Task.checkCancellation()
 
-        for listing in fetched.listings where listing.availability == .unavailable {
+        // Phase 1 — primary (web_search). Soft-fail into enrichment when empty/error.
+        var primaryListings: [JobListing] = []
+        var primaryError: Error?
+        do {
+            let fetched = try await fetchPages(from: dataSource, criteria: criteria, query: trimmed)
+            Self.log.info("Primary fetch \(fetched.listings.count, privacy: .public) raw listings")
+            primaryListings = try await processListings(
+                fetched.listings,
+                criteria: criteria,
+                query: trimmed
+            )
+            logResultRows(primaryListings, label: "primary")
+        } catch is CancellationError {
+            throw CancellationError()
+        } catch {
+            primaryError = error
             Self.log.warning(
-                "Source marked unavailable — key=\(listing.indeedJobKey, privacy: .public) title=\(listing.title, privacy: .public) url=\(listing.indeedURL.absoluteString, privacy: .public)"
+                "Primary Indeed source failed: \(error.localizedDescription, privacy: .public)"
             )
         }
 
-        var listings = JobListingFilter.filter(fetched.listings, criteria: criteria, query: trimmed)
+        let hasEnrichment = enrichmentDataSource != nil
+
+        if !primaryListings.isEmpty, hasEnrichment {
+            continuation.yield(
+                JobSearchPipelineProgress(
+                    interpreted: noteWrapped,
+                    listings: primaryListings,
+                    totalEstimated: primaryListings.count,
+                    usedCache: false,
+                    phase: .partial
+                )
+            )
+        }
+
+        try Task.checkCancellation()
+
+        // Phase 2 — live scrape enrichment (or sole source if primary empty/failed).
+        var merged = primaryListings
+        if let enrichment = enrichmentDataSource {
+            do {
+                let fetched = try await fetchPages(from: enrichment, criteria: criteria, query: trimmed)
+                Self.log.info("Enrichment fetch \(fetched.listings.count, privacy: .public) raw listings")
+                let processed = try await processListings(
+                    fetched.listings,
+                    criteria: criteria,
+                    query: trimmed
+                )
+                let existingKeys = Set(primaryListings.map(\.indeedJobKey))
+                let appended = processed.filter { !existingKeys.contains($0.indeedJobKey) }
+                // Keep primary order on top; append scrape-only rows underneath.
+                if primaryListings.isEmpty {
+                    merged = processed
+                } else {
+                    merged = primaryListings + appended
+                }
+                Self.log.info(
+                    "Enrichment appended \(appended.count, privacy: .public) new (scrape \(processed.count, privacy: .public), primary \(primaryListings.count, privacy: .public))"
+                )
+                logResultRows(appended, label: "enrichment-new")
+            } catch is CancellationError {
+                throw CancellationError()
+            } catch {
+                Self.log.warning(
+                    "Enrichment Indeed source failed: \(error.localizedDescription, privacy: .public)"
+                )
+                // Keep primary results if we already have them.
+                if primaryListings.isEmpty, let primaryError {
+                    throw primaryError
+                }
+                if primaryListings.isEmpty {
+                    throw error
+                }
+            }
+        } else if primaryListings.isEmpty, let primaryError {
+            throw primaryError
+        }
+
+        merged = JobListingDeduper.dedupe(merged)
+        let total = merged.count
+
+        if !merged.isEmpty {
+            await cache.set(
+                CachedJobSearchPage(listings: merged, totalEstimated: total, storedAt: Date()),
+                query: trimmed,
+                criteria: criteria
+            )
+        }
+
+        Self.log.info(
+            "Search complete — \(merged.count, privacy: .public) listings (primary \(primaryListings.count, privacy: .public))"
+        )
+
+        continuation.yield(
+            JobSearchPipelineProgress(
+                interpreted: noteWrapped,
+                listings: merged,
+                totalEstimated: total,
+                usedCache: false,
+                phase: .complete
+            )
+        )
+    }
+
+    // MARK: - Shared processing
+
+    private func processListings(
+        _ raw: [JobListing],
+        criteria: SearchCriteria,
+        query: String
+    ) async throws -> [JobListing] {
+        var listings = JobListingFilter.filter(raw, criteria: criteria, query: query)
         listings = JobListingDeduper.dedupe(listings)
 
         if FeatureFlags.isListingValidationEnabled {
             let before = listings.count
             listings = await validationService.validateAll(listings)
-            let dropped = listings.filter { $0.availability == .unavailable }
-            for listing in dropped {
-                Self.log.warning(
-                    "Validation dropped unavailable — key=\(listing.indeedJobKey, privacy: .public) title=\(listing.title, privacy: .public) url=\(listing.indeedURL.absoluteString, privacy: .public)"
-                )
-            }
             listings = listings.filter { $0.availability != .unavailable }
             Self.log.debug(
                 "Validation kept \(listings.count, privacy: .public)/\(before, privacy: .public) listings"
@@ -112,31 +266,19 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
 
         listings = try await rankingService.rank(
             listings: listings,
-            query: trimmed,
+            query: query,
             criteria: criteria
         )
-        // Final dedupe after ranking in case batches reintroduced keys.
-        listings = JobListingDeduper.dedupe(listings)
+        return JobListingDeduper.dedupe(listings)
+    }
 
-        var browseFallbackCount = 0
-        var realJobKeyCount = 0
+    private func logResultRows(_ listings: [JobListing], label: String) {
         for listing in listings {
             let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
-            if IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey) {
-                realJobKeyCount += 1
-            } else {
-                browseFallbackCount += 1
-            }
-            if resolved.usedBrowseFallback, let reason = resolved.reason {
-                Self.log.info(
-                    "Open URL note key=\(listing.indeedJobKey, privacy: .public): \(reason, privacy: .public)"
-                )
-            }
             let salarySummary = listing.salary?.formatted ?? "(none)"
-            // Info (not debug) so Xcode console shows fidelity while tuning interim mocks / vendor.
             Self.log.info(
                 """
-                Result key=\(listing.indeedJobKey, privacy: .public) \
+                Result[\(label, privacy: .public)] key=\(listing.indeedJobKey, privacy: .public) \
                 company=\(listing.company, privacy: .public) \
                 title=\(listing.title, privacy: .public) \
                 remote=\(listing.remoteType.rawValue, privacy: .public) \
@@ -146,36 +288,10 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
                 """
             )
         }
-
-        let total = fetched.totalEstimated ?? listings.count
-        await cache.set(
-            CachedJobSearchPage(listings: listings, totalEstimated: total, storedAt: Date()),
-            query: trimmed,
-            criteria: criteria
-        )
-
-        Self.log.info(
-            """
-            Search complete — \(listings.count, privacy: .public) listings \
-            (estimated \(total, privacy: .public); \
-            real jk=\(realJobKeyCount, privacy: .public), \
-            browse fallback=\(browseFallbackCount, privacy: .public))
-            """
-        )
-
-        return JobSearchPipelineResult(
-            interpreted: InterpretedSearch(
-                naturalLanguageQuery: trimmed,
-                criteria: criteria,
-                confidenceNote: interpreted.confidenceNote
-            ),
-            listings: listings,
-            totalEstimated: total,
-            usedCache: false
-        )
     }
 
     private func fetchPages(
+        from source: IndeedJobDataSource,
         criteria: SearchCriteria,
         query: String
     ) async throws -> (listings: [JobListing], totalEstimated: Int?) {
@@ -187,7 +303,7 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
         while pageIndex < maxPages {
             let page: IndeedSearchPage
             do {
-                page = try await dataSource.searchPage(
+                page = try await source.searchPage(
                     criteria: criteria,
                     query: query,
                     page: IndeedSearchPageRequest(
@@ -197,7 +313,6 @@ struct DefaultJobSearchPipeline: JobSearchPipeline {
                     )
                 )
             } catch {
-                // Never discard a successful earlier page because a later page timed out.
                 if !all.isEmpty {
                     Self.log.warning(
                         "Stopping pagination after page \(pageIndex, privacy: .public) failed — keeping \(all.count, privacy: .public) listings. Error: \(error.localizedDescription, privacy: .public)"

+ 65 - 0
indeed_ai_job_searchTests/JobSearchPipelineTests.swift

@@ -260,6 +260,71 @@ final class DefaultJobSearchPipelineTests: XCTestCase {
         XCTAssertTrue(second.usedCache)
         XCTAssertEqual(source.fetchCount, 1)
     }
+
+    func testProgressiveYieldsPrimaryThenAppendsEnrichment() async throws {
+        let primaryA = makeListing(key: "web1", title: "iOS Developer")
+        let primaryB = makeListing(key: "web2", title: "iOS Engineer")
+        let scrapeDup = makeListing(key: "web1", title: "iOS Developer Duplicate")
+        let scrapeNew = makeListing(key: "live1", title: "Swift iOS Developer")
+
+        let primary = InlinePipelineDataSource(listings: [primaryA, primaryB])
+        let enrichment = InlinePipelineDataSource(listings: [scrapeDup, scrapeNew])
+        let pipeline = DefaultJobSearchPipeline(
+            interpreter: StaticInterpreter(
+                InterpretedSearch(
+                    naturalLanguageQuery: "iOS",
+                    criteria: {
+                        var c = SearchCriteria()
+                        c.jobTitle = "iOS"
+                        return c
+                    }(),
+                    confidenceNote: "test"
+                )
+            ),
+            dataSource: primary,
+            enrichmentDataSource: enrichment,
+            rankingService: HeuristicJobRankingService(),
+            validationService: CompositeJobValidationService(
+                source: SourceSignalJobValidationService(),
+                http: nil
+            ),
+            cache: NullJobListingCache()
+        )
+
+        let previousValidation = FeatureFlags.isListingValidationEnabled
+        let previousRanking = FeatureFlags.isAIRankingEnabled
+        FeatureFlags.isListingValidationEnabled = true
+        FeatureFlags.isAIRankingEnabled = false
+        defer {
+            FeatureFlags.isListingValidationEnabled = previousValidation
+            FeatureFlags.isAIRankingEnabled = previousRanking
+        }
+
+        var phases: [JobSearchPipelineProgress.Phase] = []
+        var lastKeys: [String] = []
+        for try await progress in pipeline.searchProgressive(
+            JobSearchPipelineRequest(
+                naturalLanguageQuery: "iOS jobs",
+                userCriteria: SearchCriteria(),
+                reinterpret: true
+            )
+        ) {
+            phases.append(progress.phase)
+            lastKeys = progress.listings.map(\.indeedJobKey)
+            if progress.phase == .partial {
+                XCTAssertTrue(progress.isLoadingMore)
+                XCTAssertEqual(Set(progress.listings.map(\.indeedJobKey)), Set(["web1", "web2"]))
+            }
+        }
+
+        XCTAssertEqual(phases, [.partial, .complete])
+        // Primary web results stay on top; scrape-only keys append after.
+        XCTAssertEqual(Set(lastKeys.prefix(2)), Set(["web1", "web2"]))
+        XCTAssertEqual(lastKeys.last, "live1")
+        XCTAssertEqual(Set(lastKeys), Set(["web1", "web2", "live1"]))
+        XCTAssertEqual(primary.fetchCount, 1)
+        XCTAssertEqual(enrichment.fetchCount, 1)
+    }
 }
 
 final class OpenAIModelsTests: XCTestCase {

+ 148 - 0
indeed_ai_job_searchTests/OpenAIIndeedWebSearchTests.swift

@@ -0,0 +1,148 @@
+//
+//  OpenAIIndeedWebSearchTests.swift
+//  indeed_ai_job_searchTests
+//
+
+import XCTest
+@testable import indeed_ai_job_search
+
+final class OpenAIResponsesClientParsingTests: XCTestCase {
+    func testExtractsConvenienceOutputText() throws {
+        let json = """
+        { "output_text": "[{ \\"title\\": \\"iOS\\" }]" }
+        """.data(using: .utf8)!
+        let text = try OpenAIResponsesClient.extractOutputText(from: json)
+        XCTAssertTrue(text.contains("iOS"))
+    }
+
+    func testExtractsMessageOutputParts() throws {
+        let json = """
+        {
+          "output": [
+            { "type": "web_search_call", "status": "completed" },
+            {
+              "type": "message",
+              "role": "assistant",
+              "content": [
+                { "type": "output_text", "text": "[{\\"title\\":\\"Swift Engineer\\"}]" }
+              ]
+            }
+          ]
+        }
+        """.data(using: .utf8)!
+        let text = try OpenAIResponsesClient.extractOutputText(from: json)
+        XCTAssertTrue(text.contains("Swift Engineer"))
+    }
+}
+
+final class OpenAIIndeedWebSearchParsingTests: XCTestCase {
+    func testDecodesMarkdownFencedJSON() throws {
+        let output = """
+        ```json
+        [
+          {
+            "title": "iOS Developer",
+            "company": "Tuyo",
+            "location": "Remote",
+            "url": "https://www.indeed.com/viewjob?jk=5185d0dd8c286093",
+            "jobKey": "5185d0dd8c286093",
+            "remoteType": "remote",
+            "salaryMin": 120000,
+            "salaryMax": 160000,
+            "salaryCurrency": "USD",
+            "salaryPeriod": "year",
+            "posted_days_ago": 2,
+            "accepting_applications": true
+          }
+        ]
+        ```
+        """
+        let dtos = try OpenAIIndeedWebSearchDataSource.decodeJobDTOs(from: output)
+        XCTAssertEqual(dtos.count, 1)
+        let listing = try IndeedListingMapper.map(dtos[0])
+        XCTAssertEqual(listing.company, "Tuyo")
+        XCTAssertEqual(listing.indeedJobKey, "5185d0dd8c286093")
+        XCTAssertTrue(listing.indeedURL.absoluteString.contains("viewjob"))
+        XCTAssertEqual(listing.salary?.currency, "USD")
+        XCTAssertFalse(IndeedBrowseURLBuilder.openableURL(for: listing).usedBrowseFallback)
+    }
+
+    func testDropsRowsMissingTitleOrURL() throws {
+        let output = """
+        [
+          { "title": "", "company": "Acme", "url": "https://www.indeed.com/viewjob?jk=aaaaaaaaaaaaaaaa", "posted_days_ago": 1, "accepting_applications": true },
+          { "title": "iOS", "company": "Acme", "url": "https://www.indeed.com/viewjob?jk=bbbbbbbbbbbbbbbb", "posted_days_ago": 1, "accepting_applications": true }
+        ]
+        """
+        let dtos = try OpenAIIndeedWebSearchDataSource.decodeJobDTOs(from: output)
+        XCTAssertEqual(dtos.count, 1)
+        XCTAssertEqual(dtos[0].title, "iOS")
+    }
+
+    func testDropsStaleAndClosedButKeepsUndatedOpen() throws {
+        let output = """
+        [
+          {
+            "title": "Old iOS Role",
+            "company": "Stale Co",
+            "url": "https://www.indeed.com/viewjob?jk=1111111111111111",
+            "posted_days_ago": 90,
+            "accepting_applications": true
+          },
+          {
+            "title": "Closed iOS Role",
+            "company": "Closed Co",
+            "url": "https://www.indeed.com/viewjob?jk=2222222222222222",
+            "posted_days_ago": 1,
+            "accepting_applications": false
+          },
+          {
+            "title": "Fresh iOS Role",
+            "company": "Fresh Co",
+            "url": "https://www.indeed.com/viewjob?jk=3333333333333333",
+            "posted_days_ago": 3,
+            "accepting_applications": true
+          },
+          {
+            "title": "Undated iOS Role",
+            "company": "Mystery Co",
+            "url": "https://www.indeed.com/viewjob?jk=4444444444444444",
+            "accepting_applications": true
+          }
+        ]
+        """
+        let rows = try OpenAIIndeedWebSearchDataSource.decodeJobRows(from: output)
+        XCTAssertEqual(rows.count, 4)
+
+        let kept = rows.filter(\.isAcceptingApplications).compactMap {
+            $0.asIndeedJobDTO(
+                maxAgeDays: OpenAIIndeedWebSearchDataSource.maxPostedDaysAgo,
+                allowMissingDate: true
+            )
+        }
+        XCTAssertEqual(kept.map(\.company).sorted(), ["Fresh Co", "Mystery Co"])
+    }
+
+    func testPromptRequiresRecentOpenJobs() {
+        var criteria = SearchCriteria()
+        criteria.jobTitle = "iOS Developer"
+        criteria.country = "Pakistan"
+        criteria.remoteTypes = [.remote]
+        let prompt = OpenAIIndeedWebSearchDataSource.buildPrompt(
+            query: "remote iOS in Pakistan",
+            criteria: criteria,
+            maxResults: 10,
+            maxAgeDays: 14
+        )
+        XCTAssertTrue(prompt.lowercased().contains("indeed"))
+        XCTAssertTrue(prompt.contains("Pakistan"))
+        XCTAssertTrue(prompt.contains("fromage="))
+        XCTAssertTrue(prompt.lowercased().contains("no longer accepting") || prompt.lowercased().contains("expired"))
+        XCTAssertTrue(prompt.contains("posted_days_ago"))
+    }
+
+    func testWebSearchModelConstantIsSet() {
+        XCTAssertFalse(OpenAIModels.defaultWebSearchModel.isEmpty)
+        XCTAssertNotEqual(OpenAIModels.defaultWebSearchModel, OpenAIModels.defaultChatModel)
+    }
+}