Quellcode durchsuchen

Add Phase 4 search pipeline with clickable Indeed opens and neutral geo.

Wire interpret→fetch→filter→rank→dedupe, switch to gpt-6-luna, remove default country, and open listings via browse URLs from full result tiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali vor 18 Stunden
Ursprung
Commit
f364a968ea
34 geänderte Dateien mit 1752 neuen und 214 gelöschten Zeilen
  1. 2 1
      Docs/Phase3/OpenAI_Integration.md
  2. 69 0
      Docs/Phase4/Search_Pipeline.md
  3. 50 10
      indeed_ai_job_search/App/AppDependencies.swift
  4. 2 2
      indeed_ai_job_search/DesignSystem/Components/JobBadges.swift
  5. 43 0
      indeed_ai_job_search/DesignSystem/Components/JobRowView.swift
  6. 1 1
      indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift
  7. 2 1
      indeed_ai_job_search/Domain/Models/SearchCriteria.swift
  8. 29 0
      indeed_ai_job_search/Domain/Protocols/JobSearchProtocols.swift
  9. 0 1
      indeed_ai_job_search/Features/JobAlerts/ViewModels/JobAlertsViewModel.swift
  10. 30 50
      indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift
  11. 9 2
      indeed_ai_job_search/Features/JobSearch/Views/AIJobSearchView.swift
  12. 2 3
      indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift
  13. 33 31
      indeed_ai_job_search/PreviewSupport/MockData/MockJobListings.swift
  14. 58 5
      indeed_ai_job_search/Resources/IndeedFixtures/search_page_0.json
  15. 20 2
      indeed_ai_job_search/Resources/IndeedFixtures/search_page_1.json
  16. 2 26
      indeed_ai_job_search/Services/Indeed/FixtureIndeedJobDataSource.swift
  17. 79 0
      indeed_ai_job_search/Services/Indeed/InMemoryIndeedListingCache.swift
  18. 90 0
      indeed_ai_job_search/Services/Indeed/IndeedBrowseURLBuilder.swift
  19. 21 4
      indeed_ai_job_search/Services/Indeed/IndeedListingMapper.swift
  20. 50 0
      indeed_ai_job_search/Services/Indeed/JobListingOpener.swift
  21. 17 64
      indeed_ai_job_search/Services/Mock/MockServices.swift
  22. 29 0
      indeed_ai_job_search/Services/OpenAI/FeatureFlags.swift
  23. 33 0
      indeed_ai_job_search/Services/OpenAI/JobRankingDTO.swift
  24. 10 3
      indeed_ai_job_search/Services/OpenAI/OpenAIClient.swift
  25. 18 0
      indeed_ai_job_search/Services/OpenAI/OpenAIModels.swift
  26. 196 0
      indeed_ai_job_search/Services/OpenAI/OpenAIRankingService.swift
  27. 7 3
      indeed_ai_job_search/Services/OpenAI/OpenAISearchInterpreter.swift
  28. 31 0
      indeed_ai_job_search/Services/Search/JobListingDeduper.swift
  29. 118 0
      indeed_ai_job_search/Services/Search/JobListingFilter.swift
  30. 216 0
      indeed_ai_job_search/Services/Search/JobSearchPipeline.swift
  31. 49 0
      indeed_ai_job_search/Services/Search/SearchCriteriaMerge.swift
  32. 114 0
      indeed_ai_job_search/Services/Validation/ListingValidationServices.swift
  33. 8 5
      indeed_ai_job_searchTests/IndeedListingMapperTests.swift
  34. 314 0
      indeed_ai_job_searchTests/JobSearchPipelineTests.swift

+ 2 - 1
Docs/Phase3/OpenAI_Integration.md

@@ -6,11 +6,12 @@
 ## What shipped
 
 - `OpenAIClient` — async chat completions with JSON Schema response format, 30s timeout
+- Default model: **`gpt-6-luna`** (updated in Phase 4; `reasoning_effort=none` for structured JSON)
 - `OpenAISearchInterpreter` — NL query → schema-validated `SearchCriteria`
 - `SecretsProvider` — XOR/base64 de-obfuscation of build-injected key (still extractable from binary)
 - `FeatureFlags.isAIInterpretationEnabled` — disable AI and fall back to mock interpreter
 - Sandbox: `ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES`
-- Job listings still come from **mock/fixture** Indeed sources (Option C vendor pending)
+- Job listings still come from **mock/fixture** Indeed sources (Option C vendor pending); see Phase 4 pipeline docs
 
 ## Local setup
 

+ 69 - 0
Docs/Phase4/Search_Pipeline.md

@@ -0,0 +1,69 @@
+# Phase 4 — Search pipeline, ranking, dedupe, validation
+
+**Status:** Implemented  
+**Date:** 2026-10-12  
+**OpenAI model:** `gpt-6-luna` (cheap, accurate; `$0.10` / `$0.50` per 1M tokens)
+
+## Pipeline
+
+```
+NL query
+  → OpenAI interpretation (gpt-6-luna, reasoning_effort=none)
+  → merge with user filter edits
+  → Indeed fetch (paginated, max 4 pages)
+  → local JobListingFilter
+  → dedupe by indeedJobKey
+  → availability validation (source signals)
+  → optional OpenAI / heuristic ranking
+  → in-memory cache (15m TTL)
+  → UI results
+```
+
+## What shipped
+
+| Component | Path |
+|-----------|------|
+| Orchestrator | `Services/Search/JobSearchPipeline.swift` |
+| Local filter | `Services/Search/JobListingFilter.swift` |
+| Dedupe | `Services/Search/JobListingDeduper.swift` |
+| Criteria merge | `Services/Search/SearchCriteriaMerge.swift` |
+| Ranking | `Services/OpenAI/OpenAIRankingService.swift` |
+| Validation | `Services/Validation/ListingValidationServices.swift` |
+| Cache | `Services/Indeed/InMemoryIndeedListingCache.swift` |
+| Model default | `Services/OpenAI/OpenAIModels.swift` → `gpt-6-luna` |
+
+## Indeed source
+
+Live DI uses **`MockJobDataSource`** (via `JobDataSourceIndeedAdapter`) with openable Indeed **browse/search** URLs until the licensed Option C vendor is ready. Fixtures remain for mapper/contract tests.
+
+**Why links used to look “expired”:** interim keys like `fixture-ios-…` / `ios001` are not real Indeed `jk` values. Opening `/viewjob?jk=…` with those keys shows Indeed’s expired/not-found page. The app now opens `/jobs?q=…&l=…` (live Indeed search for that role) when the key is not a real 16-char hex `jk`.
+
+## Open on Indeed
+
+`JobListingOpener` + `OpenIndeedLinkButton` are available on **AI Job Search** rows (and Saved Jobs). Xcode console logs (`JobOpen` / `SearchPipeline`) explain fallbacks and unavailable listings.
+
+## Cache / TTL
+
+| Policy | Value | Use |
+|--------|-------|-----|
+| Listing metadata cache | **15 minutes** | Search result pages in `InMemoryIndeedListingCache` |
+| Saved job revalidation | **24 hours** | Reserved for Phase 5 persistence |
+
+## Feature flags
+
+```swift
+FeatureFlags.isAIInterpretationEnabled  // NL → criteria
+FeatureFlags.isAIRankingEnabled         // optional batch rank (degrades to heuristic)
+FeatureFlags.isListingValidationEnabled // source availability pass
+```
+
+HTTP HEAD validation exists (`HTTPJobValidationService`) but is **not wired** in live DI yet — fixtures/source signals only, to avoid unsolicited Indeed probing before vendor expired-signal policy.
+
+## Acceptance checklist
+
+- [x] Duplicate listings collapsed by `indeedJobKey`
+- [x] Shared local filter for Indeed-oriented fields (incl. freshness / exclude / exact phrase best-effort)
+- [x] Open Indeed URLs remain regional Indeed hosts from fixtures/mapper
+- [x] OpenAI uses `gpt-6-luna` for interpretation + ranking
+- [x] Pipeline wired into `JobSearchViewModel`
+- [x] Unit tests for filter, dedupe, merge, ranking order, pipeline, cache

+ 50 - 10
indeed_ai_job_search/App/AppDependencies.swift

@@ -10,6 +10,7 @@ final class AppDependencies {
     let jobDataSource: JobDataSource
     let searchInterpreter: OpenAISearchService
     let validationService: JobValidationService
+    let searchPipeline: JobSearchPipeline
     let savedJobsStore: MockSavedJobsStore
     let alertsStore: MockAlertsStore
 
@@ -17,48 +18,87 @@ final class AppDependencies {
         jobDataSource: JobDataSource,
         searchInterpreter: OpenAISearchService,
         validationService: JobValidationService,
+        searchPipeline: JobSearchPipeline,
         savedJobsStore: MockSavedJobsStore,
         alertsStore: MockAlertsStore
     ) {
         self.jobDataSource = jobDataSource
         self.searchInterpreter = searchInterpreter
         self.validationService = validationService
+        self.searchPipeline = searchPipeline
         self.savedJobsStore = savedJobsStore
         self.alertsStore = alertsStore
     }
 
     /// Preview / SwiftUI canvas — mocks only, no network.
     static var preview: AppDependencies {
-        AppDependencies(
-            jobDataSource: MockJobDataSource(),
-            searchInterpreter: MockSearchInterpreter(),
-            validationService: MockJobValidationService(),
+        let interpreter = MockSearchInterpreter()
+        let dataSource = MockJobDataSource()
+        let validation = CompositeJobValidationService(
+            source: MockJobValidationService(),
+            http: nil
+        )
+        let pipeline = DefaultJobSearchPipeline(
+            interpreter: interpreter,
+            dataSource: JobDataSourceIndeedAdapter(base: dataSource),
+            rankingService: HeuristicJobRankingService(),
+            validationService: validation,
+            cache: NullJobListingCache()
+        )
+        return AppDependencies(
+            jobDataSource: dataSource,
+            searchInterpreter: interpreter,
+            validationService: validation,
+            searchPipeline: pipeline,
             savedJobsStore: MockSavedJobsStore(),
             alertsStore: MockAlertsStore()
         )
     }
 
-    /// App launch — OpenAI when configured; Indeed remains mock/fixture until vendor contract.
+    /// App launch — OpenAI (gpt-6-luna) when configured; Indeed fixtures until vendor contract.
     static var live: AppDependencies {
         let mockInterpreter = MockSearchInterpreter()
+        let heuristicRanking = HeuristicJobRankingService()
         let interpreter: OpenAISearchService
+        let ranking: OpenAIRankingService
 
-        if FeatureFlags.isAIInterpretationEnabled,
-           SecretsProvider.isOpenAIConfigured,
-           let apiKey = SecretsProvider.openAIAPIKey {
+        if SecretsProvider.isOpenAIConfigured, let apiKey = SecretsProvider.openAIAPIKey {
             let client = OpenAIClient(apiKey: apiKey)
             interpreter = FallbackOpenAISearchService(
                 primary: OpenAISearchInterpreter(client: client),
                 fallback: mockInterpreter
             )
+            ranking = FallbackOpenAIRankingService(
+                primary: OpenAIJobRankingService(client: client),
+                fallback: heuristicRanking
+            )
         } else {
             interpreter = mockInterpreter
+            ranking = heuristicRanking
         }
 
+        // Interim: diverse mock catalog with openable Indeed browse URLs.
+        // Swap to licensed IndeedJobDataSource when Option C vendor is live.
+        let mockSource = MockJobDataSource(delayNanoseconds: 450_000_000)
+        let indeedSource = JobDataSourceIndeedAdapter(base: mockSource)
+        let validation = CompositeJobValidationService(
+            source: SourceSignalJobValidationService(),
+            // HEAD against Indeed deferred until vendor expired-signal / Phase 5 revalidation policy.
+            http: nil
+        )
+        let pipeline = DefaultJobSearchPipeline(
+            interpreter: interpreter,
+            dataSource: indeedSource,
+            rankingService: ranking,
+            validationService: validation,
+            cache: InMemoryIndeedListingCache()
+        )
+
         return AppDependencies(
-            jobDataSource: MockJobDataSource(),
+            jobDataSource: mockSource,
             searchInterpreter: interpreter,
-            validationService: MockJobValidationService(),
+            validationService: validation,
+            searchPipeline: pipeline,
             savedJobsStore: MockSavedJobsStore(),
             alertsStore: MockAlertsStore()
         )

+ 2 - 2
indeed_ai_job_search/DesignSystem/Components/JobBadges.swift

@@ -164,11 +164,11 @@ struct SaveJobButton: View {
 }
 
 struct OpenIndeedLinkButton: View {
-    let url: URL
+    let listing: JobListing
 
     var body: some View {
         Button {
-            NSWorkspace.shared.open(url)
+            JobListingOpener.open(listing)
         } label: {
             Label("Open on Indeed", systemImage: "arrow.up.right.square")
                 .font(AppTypography.caption)

+ 43 - 0
indeed_ai_job_search/DesignSystem/Components/JobRowView.swift

@@ -9,6 +9,7 @@ struct JobRowView: View {
     let listing: JobListing
     let isSaved: Bool
     var onToggleSave: () -> Void
+    var onOpen: (() -> Void)? = nil
     var isCompact: Bool = false
     var showsCardChrome: Bool = true
 
@@ -16,6 +17,14 @@ struct JobRowView: View {
         listing.availability == .unavailable
     }
 
+    private func openListing() {
+        if let onOpen {
+            onOpen()
+        } else {
+            JobListingOpener.open(listing)
+        }
+    }
+
     var body: some View {
         HStack(alignment: .top, spacing: AppSpacing.md) {
             CompanyLogoView(listing: listing)
@@ -37,6 +46,7 @@ struct JobRowView: View {
 
                     Spacer(minLength: AppSpacing.sm)
 
+                    // Nested Button keeps save from triggering the card open.
                     SaveJobButton(isSaved: isSaved, action: onToggleSave)
                 }
 
@@ -55,9 +65,18 @@ struct JobRowView: View {
                     RelativeDateLabel(date: listing.postedAt)
                 }
                 .padding(.top, 2)
+
+                Text("Open on Indeed")
+                    .font(AppTypography.caption)
+                    .foregroundStyle(AppColors.brand)
+                    .padding(.top, 4)
+                    .accessibilityHidden(true)
             }
         }
         .padding(AppSpacing.md)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .contentShape(Rectangle())
+        .onTapGesture(perform: openListing)
         .background(showsCardChrome ? AppColors.surfaceCard : Color.clear)
         .clipShape(RoundedRectangle(cornerRadius: showsCardChrome ? AppSpacing.cardRadius : 0, style: .continuous))
         .overlay {
@@ -72,7 +91,11 @@ struct JobRowView: View {
             x: 0,
             y: showsCardChrome ? AppShadow.cardY : 0
         )
+        .help("Open \(listing.title) at \(listing.company) on Indeed")
         .accessibilityElement(children: .contain)
+        .accessibilityAddTraits(.isButton)
+        .accessibilityHint("Opens this job on Indeed")
+        .accessibilityAction(named: "Open on Indeed", openListing)
     }
 
     private var metaRow: some View {
@@ -97,6 +120,15 @@ struct JobGridCardView: View {
     let listing: JobListing
     let isSaved: Bool
     var onToggleSave: () -> Void
+    var onOpen: (() -> Void)? = nil
+
+    private func openListing() {
+        if let onOpen {
+            onOpen()
+        } else {
+            JobListingOpener.open(listing)
+        }
+    }
 
     var body: some View {
         VStack(alignment: .leading, spacing: AppSpacing.sm) {
@@ -129,9 +161,16 @@ struct JobGridCardView: View {
                 Spacer()
                 RelativeDateLabel(date: listing.postedAt)
             }
+
+            Text("Open on Indeed")
+                .font(AppTypography.caption)
+                .foregroundStyle(AppColors.brand)
+                .accessibilityHidden(true)
         }
         .padding(AppSpacing.md)
         .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
+        .contentShape(Rectangle())
+        .onTapGesture(perform: openListing)
         .background(AppColors.surfaceCard)
         .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
         .overlay(
@@ -139,5 +178,9 @@ struct JobGridCardView: View {
                 .stroke(AppColors.border, lineWidth: 1)
         )
         .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
+        .help("Open \(listing.title) at \(listing.company) on Indeed")
+        .accessibilityAddTraits(.isButton)
+        .accessibilityHint("Opens this job on Indeed")
+        .accessibilityAction(named: "Open on Indeed", openListing)
     }
 }

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

@@ -102,7 +102,7 @@ struct SearchComposerView: View {
                 TextField(
                     "",
                     text: $query,
-                    prompt: Text("Describe a job, e.g. Remote iOS developer jobs in Pakistan")
+                    prompt: Text("Describe a job, e.g. iOS developer or Swift engineer in Lahore")
                         .foregroundStyle(AppColors.textTertiary),
                     axis: .vertical
                 )

+ 2 - 1
indeed_ai_job_search/Domain/Models/SearchCriteria.swift

@@ -10,7 +10,8 @@ struct SearchCriteria: Hashable, Codable {
     var jobTitle: String = ""
     var exactPhrase: Bool = false
     var excludedKeywords: String = ""
-    var country: String = "Pakistan"
+    /// Empty = geo-neutral search (no country filter until user or AI sets one).
+    var country: String = ""
     var city: String = ""
     var remoteTypes: Set<RemoteType> = []
     var employmentTypes: Set<EmploymentType> = []

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

@@ -17,6 +17,35 @@ protocol JobValidationService: Sendable {
     func validate(listing: JobListing) async -> JobAvailability
 }
 
+/// End-to-end Phase 4 search: interpret → fetch → filter → validate → rank → dedupe.
+protocol JobSearchPipeline: Sendable {
+    func search(_ request: JobSearchPipelineRequest) async throws -> JobSearchPipelineResult
+}
+
+struct JobSearchPipelineRequest: Sendable {
+    var naturalLanguageQuery: String
+    var userCriteria: SearchCriteria
+    /// When false, skips OpenAI interpretation and uses `userCriteria` as-is.
+    var reinterpret: Bool
+
+    init(
+        naturalLanguageQuery: String,
+        userCriteria: SearchCriteria,
+        reinterpret: Bool = true
+    ) {
+        self.naturalLanguageQuery = naturalLanguageQuery
+        self.userCriteria = userCriteria
+        self.reinterpret = reinterpret
+    }
+}
+
+struct JobSearchPipelineResult: Sendable {
+    var interpreted: InterpretedSearch
+    var listings: [JobListing]
+    var totalEstimated: Int?
+    var usedCache: Bool
+}
+
 protocol SavedJobsStore: AnyObject {
     var savedJobs: [SavedJob] { get }
     func isSaved(_ listing: JobListing) -> Bool

+ 0 - 1
indeed_ai_job_search/Features/JobAlerts/ViewModels/JobAlertsViewModel.swift

@@ -35,7 +35,6 @@ final class JobAlertsViewModel {
         editingAlert = nil
         draftName = ""
         draftCriteria = SearchCriteria()
-        draftCriteria.country = "Pakistan"
         draftFrequency = .daily
         draftNotifyInApp = true
         draftNotifySystem = true

+ 30 - 50
indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift

@@ -23,22 +23,21 @@ final class JobSearchViewModel {
     private(set) var saveVersion = 0
 
     let suggestedPrompts = [
-        "Remote iOS developer jobs in Pakistan",
-        "Senior SwiftUI engineer, hybrid Lahore",
-        "Full-time junior iOS roles in Karachi",
-        "Contract mobile developer remote",
-        "Entry-level iOS internship Pakistan"
+        "iOS developer",
+        "Senior SwiftUI engineer remote",
+        "Full-time junior iOS roles",
+        "Contract mobile developer",
+        "iOS developer jobs in Pakistan",
+        "Swift engineer in Lahore"
     ]
 
-    private let dataSource: JobDataSource
-    private let interpreter: OpenAISearchService
+    private let pipeline: JobSearchPipeline
     private let savedStore: MockSavedJobsStore
     private var searchTask: Task<Void, Never>?
     private var filterDebounceTask: Task<Void, Never>?
 
     init(dependencies: AppDependencies) {
-        self.dataSource = dependencies.jobDataSource
-        self.interpreter = dependencies.searchInterpreter
+        self.pipeline = dependencies.searchPipeline
         self.savedStore = dependencies.savedJobsStore
     }
 
@@ -79,44 +78,19 @@ final class JobSearchViewModel {
             hasSearched = true
 
             do {
-                let interpreted = try await interpreter.interpret(query: trimmed)
+                let result = try await pipeline.search(
+                    JobSearchPipelineRequest(
+                        naturalLanguageQuery: trimmed,
+                        userCriteria: criteria,
+                        reinterpret: true
+                    )
+                )
                 if Task.isCancelled { return }
 
-                // Merge AI interpretation with any user-edited filters already set.
-                var merged = interpreted.criteria
-                if !criteria.excludedKeywords.isEmpty {
-                    merged.excludedKeywords = criteria.excludedKeywords
-                }
-                if criteria.salaryFloor != nil {
-                    merged.salaryFloor = criteria.salaryFloor
-                }
-                // Prefer explicit user filter edits when present.
-                if !criteria.employmentTypes.isEmpty {
-                    merged.employmentTypes = criteria.employmentTypes
-                }
-                if !criteria.experienceLevels.isEmpty {
-                    merged.experienceLevels = criteria.experienceLevels
-                }
-                if !criteria.remoteTypes.isEmpty {
-                    merged.remoteTypes = criteria.remoteTypes
-                }
-                if !criteria.city.isEmpty {
-                    merged.city = criteria.city
-                }
-                if !criteria.country.isEmpty {
-                    merged.country = criteria.country
-                }
-                if !criteria.jobTitle.isEmpty {
-                    merged.jobTitle = criteria.jobTitle
-                }
-
-                criteria = merged
-                let listings = try await dataSource.search(criteria: criteria, query: trimmed)
-                if Task.isCancelled { return }
-
-                results = listings
-                totalResultCount = max(listings.count, listings.isEmpty ? 0 : 1_248)
-                state = listings.isEmpty ? .empty : .success
+                criteria = result.interpreted.criteria
+                results = result.listings
+                totalResultCount = result.totalEstimated ?? result.listings.count
+                state = result.listings.isEmpty ? .empty : .success
             } catch is CancellationError {
                 return
             } catch {
@@ -145,18 +119,24 @@ final class JobSearchViewModel {
         applyFiltersAndRefresh()
     }
 
-    /// Re-runs the mock Indeed search with current criteria without re-interpreting the query.
+    /// Re-runs fetch/filter/rank with current criteria without re-interpreting the query.
     private func refreshResultsOnly() {
         let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
         searchTask?.cancel()
         searchTask = Task {
             state = .loading
             do {
-                let listings = try await dataSource.search(criteria: criteria, query: trimmed)
+                let result = try await pipeline.search(
+                    JobSearchPipelineRequest(
+                        naturalLanguageQuery: trimmed,
+                        userCriteria: criteria,
+                        reinterpret: false
+                    )
+                )
                 if Task.isCancelled { return }
-                results = listings
-                totalResultCount = listings.isEmpty ? 0 : max(listings.count, 1_248)
-                state = listings.isEmpty ? .empty : .success
+                results = result.listings
+                totalResultCount = result.totalEstimated ?? result.listings.count
+                state = result.listings.isEmpty ? .empty : .success
             } catch is CancellationError {
                 return
             } catch {

+ 9 - 2
indeed_ai_job_search/Features/JobSearch/Views/AIJobSearchView.swift

@@ -3,7 +3,6 @@
 //  indeed_ai_job_search
 //
 
-import AppKit
 import SwiftUI
 
 struct AIJobSearchView: View {
@@ -203,7 +202,7 @@ struct AIJobSearchView: View {
                             )
                             .contextMenu {
                                 Button("Open on Indeed") {
-                                    NSWorkspace.shared.open(listing.indeedURL)
+                                    JobListingOpener.open(listing)
                                 }
                                 Button(viewModel.isSaved(listing) ? "Unsave" : "Save") {
                                     viewModel.toggleSave(listing)
@@ -222,6 +221,14 @@ struct AIJobSearchView: View {
                                 isSaved: viewModel.isSaved(listing),
                                 onToggleSave: { viewModel.toggleSave(listing) }
                             )
+                            .contextMenu {
+                                Button("Open on Indeed") {
+                                    JobListingOpener.open(listing)
+                                }
+                                Button(viewModel.isSaved(listing) ? "Unsave" : "Save") {
+                                    viewModel.toggleSave(listing)
+                                }
+                            }
                         }
                     }
                 }

+ 2 - 3
indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift

@@ -3,7 +3,6 @@
 //  indeed_ai_job_search
 //
 
-import AppKit
 import SwiftUI
 
 struct SavedJobsView: View {
@@ -279,7 +278,7 @@ struct SavedJobsView: View {
 
                 Spacer(minLength: 0)
 
-                OpenIndeedLinkButton(url: saved.listing.indeedURL)
+                OpenIndeedLinkButton(listing: saved.listing)
             }
             .padding(.horizontal, AppSpacing.md)
             .padding(.vertical, 10)
@@ -303,7 +302,7 @@ struct SavedJobsView: View {
         .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
         .contextMenu {
             Button("Open on Indeed") {
-                NSWorkspace.shared.open(saved.listing.indeedURL)
+                JobListingOpener.open(saved.listing)
             }
             Button("Remove from saved", role: .destructive) {
                 requestUnsave(saved.listing)

+ 33 - 31
indeed_ai_job_search/PreviewSupport/MockData/MockJobListings.swift

@@ -10,8 +10,10 @@ enum MockJobListings {
         let now = Date()
         let day: TimeInterval = 86_400
 
-        func url(_ key: String) -> URL {
-            URL(string: "https://www.indeed.com/viewjob?jk=\(key)")!
+        /// Interim demo URLs — real `jk` keys come from a licensed feed later.
+        /// Browse/search URLs open live Indeed results instead of expired viewjob pages.
+        func url(title: String, location: String) -> URL {
+            IndeedBrowseURLBuilder.searchURL(query: title, location: location)
         }
 
         return [
@@ -19,14 +21,14 @@ enum MockJobListings {
                 indeedJobKey: "ios001",
                 title: "Senior iOS Developer",
                 company: "Apple",
-                location: "Lahore, Pakistan",
+                location: "Cupertino, CA",
                 remoteType: .hybrid,
                 employmentType: .fullTime,
                 experienceLevel: .senior,
-                salary: SalaryRange(min: 50_000, max: 80_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 160_000, max: 220_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-day),
                 snippet: "Build delightful iOS experiences with SwiftUI and UIKit for millions of users.",
-                indeedURL: url("ios001"),
+                indeedURL: url(title: "Senior iOS Developer", location: "Cupertino, CA"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "apple.logo"
@@ -35,14 +37,14 @@ enum MockJobListings {
                 indeedJobKey: "ios002",
                 title: "iOS Developer",
                 company: "Microsoft",
-                location: "Islamabad, Pakistan",
+                location: "Remote",
                 remoteType: .remote,
                 employmentType: .fullTime,
                 experienceLevel: .mid,
-                salary: SalaryRange(min: 45_000, max: 70_000, currency: "USD", period: .year),
+                salary: SalaryRange(min: 120_000, max: 160_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-2 * day),
                 snippet: "Join a distributed team shipping mobile productivity apps on iOS.",
-                indeedURL: url("ios002"),
+                indeedURL: url(title: "iOS Developer", location: "Remote"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "square.grid.2x2.fill"
@@ -58,7 +60,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 30_000, max: 48_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-3 * day),
                 snippet: "Develop and maintain customer-facing iOS applications.",
-                indeedURL: url("ios003"),
+                indeedURL: url(title: "Mobile Software Engineer (iOS)", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "phone.fill"
             ),
@@ -73,7 +75,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 40_000, max: 65_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-4 * day),
                 snippet: "Own features end-to-end for the Careem rider and captain apps.",
-                indeedURL: url("ios004"),
+                indeedURL: url(title: "iOS Engineer", location: "Lahore, Pakistan"),
                 isFeatured: true,
                 logoSymbol: "car.fill"
             ),
@@ -88,7 +90,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 55_000, max: 85_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-5 * day),
                 snippet: "Lead iOS architecture and mentor a growing mobile team.",
-                indeedURL: url("ios005"),
+                indeedURL: url(title: "Senior Mobile Developer", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "bicycle"
@@ -104,7 +106,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 48_000, max: 75_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-6 * day),
                 snippet: "Ship modern SwiftUI interfaces for enterprise AI products.",
-                indeedURL: url("ios006"),
+                indeedURL: url(title: "SwiftUI Developer", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "sparkles"
             ),
@@ -119,7 +121,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 18_000, max: 28_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-7 * day),
                 snippet: "Learn production iOS development with senior mentors.",
-                indeedURL: url("ios007"),
+                indeedURL: url(title: "Junior iOS Developer", location: "Lahore, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "desktopcomputer"
             ),
@@ -134,7 +136,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 35, max: 55, currency: "USD", period: .hour),
                 postedAt: now.addingTimeInterval(-8 * day),
                 snippet: "6-month contract building a consumer fintech iOS app.",
-                indeedURL: url("ios008"),
+                indeedURL: url(title: "iOS Contractor", location: "Pakistan"),
                 isFeatured: true,
                 logoSymbol: "globe"
             ),
@@ -149,7 +151,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 70_000, max: 95_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-9 * day),
                 snippet: "Drive mobile platform strategy for Pakistan’s top marketplace.",
-                indeedURL: url("ios009"),
+                indeedURL: url(title: "Lead iOS Engineer", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "cart.fill"
@@ -165,7 +167,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 12_000, max: 20_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-10 * day),
                 snippet: "Part-time role improving an education app for students.",
-                indeedURL: url("ios010"),
+                indeedURL: url(title: "iOS Developer (Part-time)", location: "Remote, Pakistan"),
                 logoSymbol: "book.fill"
             ),
             JobListing(
@@ -179,7 +181,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 90_000, max: 130_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-1.5 * day),
                 snippet: "Build native Apple platform tools with Swift concurrency.",
-                indeedURL: url("ios011"),
+                indeedURL: url(title: "macOS / iOS Engineer", location: "Remote"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "laptopcomputer"
@@ -195,7 +197,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 42_000, max: 60_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-11 * day),
                 snippet: "Secure mobile payments experience for millions of users.",
-                indeedURL: url("ios012"),
+                indeedURL: url(title: "iOS Developer", location: "Islamabad, Pakistan"),
                 isUrgentlyHiring: true,
                 logoSymbol: "creditcard.fill"
             ),
@@ -210,7 +212,7 @@ enum MockJobListings {
                 salary: nil,
                 postedAt: now.addingTimeInterval(-12 * day),
                 snippet: "Client projects spanning healthcare and fintech iOS apps.",
-                indeedURL: url("ios013"),
+                indeedURL: url(title: "Swift Developer", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "hammer.fill"
             ),
@@ -225,7 +227,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 400, max: 600, currency: "USD", period: .month),
                 postedAt: now.addingTimeInterval(-2 * day),
                 snippet: "Paid internship focused on Swift and SwiftUI fundamentals.",
-                indeedURL: url("ios014"),
+                indeedURL: url(title: "iOS Intern", location: "Lahore, Pakistan"),
                 logoSymbol: "graduationcap.fill"
             ),
             JobListing(
@@ -239,7 +241,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 60_000, max: 90_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-0.5 * day),
                 snippet: "Remote-first team building consumer apps for global clients.",
-                indeedURL: url("ios015"),
+                indeedURL: url(title: "Senior iOS Developer", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "antenna.radiowaves.left.and.right"
@@ -255,7 +257,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 38_000, max: 58_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-14 * day),
                 snippet: "Improve ordering flows and performance on iOS.",
-                indeedURL: url("ios016"),
+                indeedURL: url(title: "Mobile Engineer", location: "Lahore, Pakistan"),
                 logoSymbol: "fork.knife"
             ),
             JobListing(
@@ -269,7 +271,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 40_000, max: 55_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-30 * day),
                 snippet: "This listing is no longer accepting applications.",
-                indeedURL: url("ios017"),
+                indeedURL: url(title: "iOS Developer (Unavailable)", location: "Pakistan"),
                 availability: .unavailable,
                 logoSymbol: "xmark.circle.fill"
             ),
@@ -284,7 +286,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 25, max: 35, currency: "USD", period: .hour),
                 postedAt: now.addingTimeInterval(-3.5 * day),
                 snippet: "Short-term engagement supporting crash triage and hotfixes.",
-                indeedURL: url("ios018"),
+                indeedURL: url(title: "Temporary iOS Support Engineer", location: "Remote, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "wrench.and.screwdriver.fill"
             ),
@@ -299,7 +301,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 100_000, max: 140_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-15 * day),
                 snippet: "Define multi-app mobile architecture and CI/CD standards.",
-                indeedURL: url("ios019"),
+                indeedURL: url(title: "Principal iOS Architect", location: "Lahore, Pakistan"),
                 isFeatured: true,
                 logoSymbol: "building.columns.fill"
             ),
@@ -314,7 +316,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 35_000, max: 52_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-4.5 * day),
                 snippet: "Collaborate with product and design on high-quality apps.",
-                indeedURL: url("ios020"),
+                indeedURL: url(title: "iOS Developer", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 isUrgentlyHiring: true,
                 logoSymbol: "person.3.fill"
@@ -330,7 +332,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 40_000, max: 62_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-6.5 * day),
                 snippet: "Hybrid role with native iOS modules and React Native.",
-                indeedURL: url("ios021"),
+                indeedURL: url(title: "React Native / iOS Engineer", location: "Lahore, Pakistan"),
                 logoSymbol: "arrow.triangle.2.circlepath"
             ),
             JobListing(
@@ -344,7 +346,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 32_000, max: 48_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-7.5 * day),
                 snippet: "Automate XCUITest suites for mobile release quality.",
-                indeedURL: url("ios022"),
+                indeedURL: url(title: "iOS QA Automation Engineer", location: "Karachi, Pakistan"),
                 isEasyApply: true,
                 logoSymbol: "checkmark.seal.fill"
             ),
@@ -359,7 +361,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 85_000, max: 120_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-0.8 * day),
                 snippet: "Own fleet-management mobile experiences at scale.",
-                indeedURL: url("ios023"),
+                indeedURL: url(title: "Staff iOS Engineer", location: "Islamabad, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 logoSymbol: "truck.box.fill"
@@ -375,7 +377,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 22_000, max: 35_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-9.5 * day),
                 snippet: "Work on game-adjacent and consumer product iOS apps.",
-                indeedURL: url("ios024"),
+                indeedURL: url(title: "iOS Developer", location: "Lahore, Pakistan"),
                 logoSymbol: "gamecontroller.fill"
             ),
             JobListing(
@@ -389,7 +391,7 @@ enum MockJobListings {
                 salary: SalaryRange(min: 50_000, max: 78_000, currency: "USD", period: .year),
                 postedAt: now.addingTimeInterval(-1.2 * day),
                 snippet: "Fully remote role building banking apps with Swift.",
-                indeedURL: url("ios025"),
+                indeedURL: url(title: "Remote iOS Developer", location: "Remote, Pakistan"),
                 isEasyApply: true,
                 isFeatured: true,
                 isUrgentlyHiring: true,

+ 58 - 5
indeed_ai_job_search/Resources/IndeedFixtures/search_page_0.json

@@ -1,7 +1,7 @@
 {
   "pageIndex": 0,
   "pageSize": 25,
-  "totalEstimated": 3,
+  "totalEstimated": 8,
   "nextCursor": "page-1",
   "hasMore": true,
   "results": [
@@ -19,7 +19,7 @@
       "salaryPeriod": "year",
       "postedAt": "2026-10-08T09:30:00Z",
       "snippet": "Build SwiftUI features for a consumer productivity app. Collaboration with design and backend.",
-      "url": "https://pk.indeed.com/viewjob?jk=fixture-ios-lahore-001",
+      "url": "https://pk.indeed.com/jobs?q=iOS+Developer&l=Lahore%2C+Pakistan",
       "availability": "open",
       "isEasyApply": true,
       "isFeatured": false,
@@ -29,7 +29,7 @@
       "jobKey": "fixture-swift-remote-002",
       "title": "Senior Swift Engineer",
       "company": "Alpine Cloud",
-      "location": "Remote, Pakistan",
+      "location": "Remote",
       "remoteType": "remote",
       "employmentType": "full-time",
       "experienceLevel": "senior",
@@ -39,12 +39,65 @@
       "salaryPeriod": "year",
       "postedAt": "2026-10-10T14:00:00Z",
       "snippet": "Own macOS and iOS modules. Mentorship and architecture reviews expected.",
-      "url": "https://www.indeed.com/viewjob?jk=fixture-swift-remote-002",
+      "url": "https://www.indeed.com/jobs?q=Senior+Swift+Engineer&l=Remote",
       "availability": "open",
       "isEasyApply": false,
       "isFeatured": true,
       "isUrgentlyHiring": false
     },
+    {
+      "jobKey": "fixture-ios-sf-005",
+      "title": "iOS Engineer",
+      "company": "Bay Area Labs",
+      "location": "San Francisco, CA",
+      "remoteType": "hybrid",
+      "employmentType": "full-time",
+      "experienceLevel": "mid",
+      "salaryMin": 140000,
+      "salaryMax": 180000,
+      "salaryCurrency": "USD",
+      "salaryPeriod": "year",
+      "postedAt": "2026-10-09T16:00:00Z",
+      "snippet": "Ship SwiftUI features for a consumer mobile platform.",
+      "url": "https://www.indeed.com/jobs?q=iOS+Engineer&l=San+Francisco%2C+CA",
+      "availability": "open",
+      "isEasyApply": true
+    },
+    {
+      "jobKey": "fixture-ios-london-006",
+      "title": "iOS Developer",
+      "company": "Thames Mobile",
+      "location": "London, United Kingdom",
+      "remoteType": "onsite",
+      "employmentType": "full-time",
+      "experienceLevel": "junior",
+      "salaryMin": 45000,
+      "salaryMax": 60000,
+      "salaryCurrency": "GBP",
+      "salaryPeriod": "year",
+      "postedAt": "2026-10-07T10:00:00Z",
+      "snippet": "Junior iOS role working with UIKit and SwiftUI.",
+      "url": "https://uk.indeed.com/jobs?q=iOS+Developer&l=London%2C+United+Kingdom",
+      "availability": "open"
+    },
+    {
+      "jobKey": "fixture-swiftui-nyc-007",
+      "title": "SwiftUI Developer",
+      "company": "Hudson Apps",
+      "location": "New York, NY",
+      "remoteType": "remote",
+      "employmentType": "contract",
+      "experienceLevel": "senior",
+      "salaryMin": 70,
+      "salaryMax": 95,
+      "salaryCurrency": "USD",
+      "salaryPeriod": "hour",
+      "postedAt": "2026-10-11T12:00:00Z",
+      "snippet": "Contract SwiftUI engineer for a fintech iOS app.",
+      "url": "https://www.indeed.com/jobs?q=SwiftUI+Developer&l=New+York%2C+NY",
+      "availability": "open",
+      "isUrgentlyHiring": true
+    },
     {
       "title": "Android Developer",
       "company": "Karachi Labs",
@@ -54,7 +107,7 @@
       "experienceLevel": "junior",
       "postedAt": "2026-10-05T11:15:00Z",
       "snippet": "Kotlin multiplatform experiments for internal tools.",
-      "url": "https://pk.indeed.com/viewjob?jk=fixture-android-khi-003",
+      "url": "https://pk.indeed.com/jobs?q=Android+Developer&l=Karachi%2C+Pakistan",
       "availability": "unknown"
     }
   ]

+ 20 - 2
indeed_ai_job_search/Resources/IndeedFixtures/search_page_1.json

@@ -1,10 +1,28 @@
 {
   "pageIndex": 1,
   "pageSize": 25,
-  "totalEstimated": 3,
+  "totalEstimated": 8,
   "nextCursor": null,
   "hasMore": false,
   "results": [
+    {
+      "jobKey": "fixture-ios-toronto-008",
+      "title": "Mobile iOS Developer",
+      "company": "Maple Softworks",
+      "location": "Toronto, Canada",
+      "remoteType": "hybrid",
+      "employmentType": "full-time",
+      "experienceLevel": "mid",
+      "salaryMin": 95000,
+      "salaryMax": 125000,
+      "salaryCurrency": "CAD",
+      "salaryPeriod": "year",
+      "postedAt": "2026-10-06T09:00:00Z",
+      "snippet": "Build and maintain iOS apps for retail customers.",
+      "url": "https://ca.indeed.com/jobs?q=Mobile+iOS+Developer&l=Toronto%2C+Canada",
+      "availability": "open",
+      "isEasyApply": true
+    },
     {
       "jobKey": "fixture-expired-004",
       "title": "Mobile Engineer (Filled)",
@@ -15,7 +33,7 @@
       "experienceLevel": "mid",
       "postedAt": "2026-09-01T08:00:00Z",
       "snippet": "This role is no longer accepting applications.",
-      "url": "https://pk.indeed.com/viewjob?jk=fixture-expired-004",
+      "url": "https://pk.indeed.com/jobs?q=Mobile+Engineer&l=Islamabad%2C+Pakistan",
       "availability": "unavailable"
     }
   ]

+ 2 - 26
indeed_ai_job_search/Services/Indeed/FixtureIndeedJobDataSource.swift

@@ -40,32 +40,8 @@ struct FixtureIndeedJobDataSource: IndeedJobDataSource {
         let data = try Data(contentsOf: url)
         var result = try IndeedListingMapper.decodeFixture(from: data)
 
-        // Light client-side filter so fixtures still respect criteria in demos.
-        result.listings = filter(result.listings, criteria: criteria, query: query)
+        // Shared local filter so fixtures respect the same criteria as the pipeline.
+        result.listings = JobListingFilter.filter(result.listings, criteria: criteria, query: query)
         return result
     }
-
-    private func filter(
-        _ listings: [JobListing],
-        criteria: SearchCriteria,
-        query: String
-    ) -> [JobListing] {
-        let needle = (query + " " + criteria.jobTitle + " " + criteria.keywords)
-            .lowercased()
-            .trimmingCharacters(in: .whitespacesAndNewlines)
-
-        return listings.filter { job in
-            if !needle.isEmpty {
-                let haystack = "\(job.title) \(job.company) \(job.location) \(job.snippet)".lowercased()
-                let tokens = needle.split(separator: " ").map(String.init)
-                if !tokens.contains(where: { haystack.contains($0) }) {
-                    return false
-                }
-            }
-            if !criteria.remoteTypes.isEmpty, !criteria.remoteTypes.contains(job.remoteType) {
-                return false
-            }
-            return true
-        }
-    }
 }

+ 79 - 0
indeed_ai_job_search/Services/Indeed/InMemoryIndeedListingCache.swift

@@ -0,0 +1,79 @@
+//
+//  InMemoryIndeedListingCache.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+protocol JobListingCache: Sendable {
+    func get(query: String, criteria: SearchCriteria) async -> CachedJobSearchPage?
+    func set(_ page: CachedJobSearchPage, query: String, criteria: SearchCriteria) async
+    func invalidateAll() async
+}
+
+struct CachedJobSearchPage: Sendable {
+    var listings: [JobListing]
+    var totalEstimated: Int?
+    var storedAt: Date
+}
+
+/// In-memory TTL cache for search result metadata (default 15 minutes).
+actor InMemoryIndeedListingCache: JobListingCache {
+    private var storage: [String: CachedJobSearchPage] = [:]
+    private let ttl: TimeInterval
+    private let clock: @Sendable () -> Date
+
+    init(
+        ttl: TimeInterval = IndeedCachePolicy.listingMetadataTTL,
+        clock: @escaping @Sendable () -> Date = { Date() }
+    ) {
+        self.ttl = ttl
+        self.clock = clock
+    }
+
+    func get(query: String, criteria: SearchCriteria) -> CachedJobSearchPage? {
+        let key = Self.cacheKey(query: query, criteria: criteria)
+        guard let entry = storage[key] else { return nil }
+        if clock().timeIntervalSince(entry.storedAt) > ttl {
+            storage.removeValue(forKey: key)
+            return nil
+        }
+        return entry
+    }
+
+    func set(_ page: CachedJobSearchPage, query: String, criteria: SearchCriteria) {
+        let key = Self.cacheKey(query: query, criteria: criteria)
+        storage[key] = page
+    }
+
+    func invalidateAll() {
+        storage.removeAll()
+    }
+
+    nonisolated static func cacheKey(query: String, criteria: SearchCriteria) -> String {
+        let remote = criteria.remoteTypes.map(\.rawValue).sorted().joined(separator: ",")
+        let employment = criteria.employmentTypes.map(\.rawValue).sorted().joined(separator: ",")
+        let experience = criteria.experienceLevels.map(\.rawValue).sorted().joined(separator: ",")
+        return [
+            query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines),
+            criteria.jobTitle.lowercased(),
+            criteria.keywords.lowercased(),
+            criteria.excludedKeywords.lowercased(),
+            criteria.country.lowercased(),
+            criteria.city.lowercased(),
+            remote,
+            employment,
+            experience,
+            criteria.salaryFloor.map(String.init(describing:)) ?? "",
+            criteria.dateFreshnessDays.map(String.init) ?? "",
+            criteria.exactPhrase ? "1" : "0"
+        ].joined(separator: "|")
+    }
+}
+
+/// No-op cache for previews / tests.
+struct NullJobListingCache: JobListingCache {
+    func get(query: String, criteria: SearchCriteria) async -> CachedJobSearchPage? { nil }
+    func set(_ page: CachedJobSearchPage, query: String, criteria: SearchCriteria) async {}
+    func invalidateAll() async {}
+}

+ 90 - 0
indeed_ai_job_search/Services/Indeed/IndeedBrowseURLBuilder.swift

@@ -0,0 +1,90 @@
+//
+//  IndeedBrowseURLBuilder.swift
+//  indeed_ai_job_search
+//
+//  Builds openable Indeed URLs. Fake / fixture `jk=` values always 404 on Indeed;
+//  until a licensed feed supplies real keys, open a live Indeed search for the role.
+//
+
+import Foundation
+
+enum IndeedBrowseURLBuilder {
+    /// Real Indeed job keys are 16-character lowercase hex.
+    static func isLikelyRealJobKey(_ key: String) -> Bool {
+        let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard trimmed.count == 16 else { return false }
+        return trimmed.allSatisfy { $0.isHexDigit }
+    }
+
+    /// URL that should open a useful Indeed page for this listing.
+    static func openableURL(for listing: JobListing) -> (url: URL, usedBrowseFallback: Bool, reason: String?) {
+        let key = listing.indeedJobKey
+        let stored = listing.indeedURL
+
+        if listing.availability == .unavailable {
+            let browse = searchURL(
+                query: listing.title,
+                location: listing.location,
+                preferredHost: stored.host
+            )
+            return (
+                browse,
+                true,
+                "Listing marked unavailable (key=\(key)); opening Indeed search instead of expired viewjob."
+            )
+        }
+
+        if isLikelyRealJobKey(key),
+           stored.path.lowercased().contains("viewjob") {
+            return (stored, false, nil)
+        }
+
+        // Fixture / mock keys (`ios001`, `fixture-…`) are not real Indeed jobs.
+        let browse = searchURL(
+            query: listing.title,
+            location: listing.location,
+            preferredHost: stored.host
+        )
+        return (
+            browse,
+            true,
+            "Job key “\(key)” is not a real Indeed jk (expected 16-char hex). Stored URL \(stored.absoluteString) would show expired/not found; using live Indeed search."
+        )
+    }
+
+    /// Indeed jobs search for title (+ optional location). Always a valid Indeed page.
+    static func searchURL(
+        query: String,
+        location: String,
+        preferredHost: String? = nil
+    ) -> URL {
+        let host = regionalHost(for: location, preferredHost: preferredHost)
+        var components = URLComponents()
+        components.scheme = "https"
+        components.host = host
+        components.path = "/jobs"
+        components.queryItems = [
+            URLQueryItem(name: "q", value: query.trimmingCharacters(in: .whitespacesAndNewlines)),
+            URLQueryItem(name: "l", value: location.trimmingCharacters(in: .whitespacesAndNewlines))
+        ]
+        return components.url ?? URL(string: "https://www.indeed.com/jobs")!
+    }
+
+    /// Prefer regional TLD when location implies one; otherwise www.indeed.com.
+    static func regionalHost(for location: String, preferredHost: String?) -> String {
+        let lower = location.lowercased()
+        if lower.contains("pakistan") || lower.contains("lahore") || lower.contains("karachi") || lower.contains("islamabad") {
+            return "pk.indeed.com"
+        }
+        if lower.contains("united kingdom") || lower.contains("london") || lower.hasSuffix(", uk") || lower.contains(" england") {
+            return "uk.indeed.com"
+        }
+        if lower.contains("canada") || lower.contains("toronto") || lower.contains("vancouver") {
+            return "ca.indeed.com"
+        }
+        if let preferredHost, IndeedURLCanonicalizer.isIndeedHost(preferredHost) {
+            return preferredHost
+        }
+        return "www.indeed.com"
+    }
+}

+ 21 - 4
indeed_ai_job_search/Services/Indeed/IndeedListingMapper.swift

@@ -50,27 +50,44 @@ enum IndeedListingMapper {
     }()
 
     static func map(_ dto: IndeedJobDTO) throws -> JobListing {
-        let url = try IndeedURLCanonicalizer.canonicalize(dto.url)
-        let key = IndeedURLCanonicalizer.resolveJobKey(providerKey: dto.jobKey, url: url)
+        let canonical = try IndeedURLCanonicalizer.canonicalize(dto.url)
+        let key = IndeedURLCanonicalizer.resolveJobKey(providerKey: dto.jobKey, url: canonical)
 
         let title = dto.title.trimmingCharacters(in: .whitespacesAndNewlines)
         let company = dto.company.trimmingCharacters(in: .whitespacesAndNewlines)
+        let location = dto.location.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !title.isEmpty, !company.isEmpty else {
             throw IndeedDataError.invalidResponse("Missing title or company for job key \(key).")
         }
 
+        // Fake fixture keys (`fixture-…`) are not real Indeed jobs — store a live browse URL
+        // so Open on Indeed never lands on Indeed’s “expired / not available” page.
+        let openURL: URL
+        if IndeedBrowseURLBuilder.isLikelyRealJobKey(key),
+           canonical.path.lowercased().contains("viewjob") {
+            openURL = canonical
+        } else if canonical.path.lowercased().contains("/jobs") {
+            openURL = canonical
+        } else {
+            openURL = IndeedBrowseURLBuilder.searchURL(
+                query: title,
+                location: location,
+                preferredHost: canonical.host
+            )
+        }
+
         return JobListing(
             indeedJobKey: key,
             title: title,
             company: company,
-            location: dto.location.trimmingCharacters(in: .whitespacesAndNewlines),
+            location: location,
             remoteType: mapRemote(dto.remoteType),
             employmentType: mapEmployment(dto.employmentType),
             experienceLevel: mapExperience(dto.experienceLevel),
             salary: mapSalary(dto),
             postedAt: parseDate(dto.postedAt) ?? Date(timeIntervalSince1970: 0),
             snippet: (dto.snippet ?? "").trimmingCharacters(in: .whitespacesAndNewlines),
-            indeedURL: url,
+            indeedURL: openURL,
             availability: mapAvailability(dto.availability),
             isEasyApply: dto.isEasyApply ?? false,
             isFeatured: dto.isFeatured ?? false,

+ 50 - 0
indeed_ai_job_search/Services/Indeed/JobListingOpener.swift

@@ -0,0 +1,50 @@
+//
+//  JobListingOpener.swift
+//  indeed_ai_job_search
+//
+
+import AppKit
+import Foundation
+import OSLog
+
+enum JobListingOpener {
+    private static let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "JobOpen"
+    )
+
+    /// Opens the listing on Indeed (real viewjob when possible; otherwise live search).
+    @discardableResult
+    static func open(_ listing: JobListing) -> URL {
+        let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
+
+        log.info(
+            """
+            Opening Indeed listing
+              title=\(listing.title, privacy: .public)
+              company=\(listing.company, privacy: .public)
+              key=\(listing.indeedJobKey, privacy: .public)
+              availability=\(listing.availability.rawValue, privacy: .public)
+              storedURL=\(listing.indeedURL.absoluteString, privacy: .public)
+              openURL=\(resolved.url.absoluteString, privacy: .public)
+              usedBrowseFallback=\(resolved.usedBrowseFallback, privacy: .public)
+            """
+        )
+
+        if let reason = resolved.reason {
+            log.warning("\(reason, privacy: .public)")
+        }
+
+        if listing.availability == .unavailable {
+            log.error(
+                "Job marked unavailable before open — key=\(listing.indeedJobKey, privacy: .public) title=\(listing.title, privacy: .public)"
+            )
+        }
+
+        let opened = NSWorkspace.shared.open(resolved.url)
+        if !opened {
+            log.error("NSWorkspace failed to open \(resolved.url.absoluteString, privacy: .public)")
+        }
+        return resolved.url
+    }
+}

+ 17 - 64
indeed_ai_job_search/Services/Mock/MockServices.swift

@@ -18,69 +18,7 @@ struct MockJobDataSource: JobDataSource {
         if shouldFail {
             throw MockSearchError.network
         }
-
-        let needle = (query + " " + criteria.jobTitle + " " + criteria.keywords)
-            .lowercased()
-            .trimmingCharacters(in: .whitespacesAndNewlines)
-
-        var results = MockJobListings.all.filter { $0.availability != .unavailable }
-
-        if !needle.isEmpty {
-            results = results.filter { job in
-                let haystack = "\(job.title) \(job.company) \(job.location) \(job.snippet)".lowercased()
-                let tokens = needle.split(separator: " ").map(String.init)
-                return tokens.contains { haystack.contains($0) }
-            }
-        }
-
-        if !criteria.excludedKeywords.isEmpty {
-            let excluded = criteria.excludedKeywords.lowercased().split(separator: " ").map(String.init)
-            results = results.filter { job in
-                let haystack = "\(job.title) \(job.company) \(job.snippet)".lowercased()
-                return !excluded.contains { haystack.contains($0) }
-            }
-        }
-
-        if !criteria.remoteTypes.isEmpty {
-            results = results.filter { criteria.remoteTypes.contains($0.remoteType) }
-        }
-
-        if !criteria.employmentTypes.isEmpty {
-            results = results.filter { job in
-                guard let type = job.employmentType else { return false }
-                return criteria.employmentTypes.contains(type)
-            }
-        }
-
-        if !criteria.experienceLevels.isEmpty {
-            results = results.filter { job in
-                guard let level = job.experienceLevel else { return false }
-                return criteria.experienceLevels.contains(level)
-            }
-        }
-
-        if !criteria.country.isEmpty {
-            results = results.filter {
-                $0.location.localizedCaseInsensitiveContains(criteria.country)
-                    || $0.remoteType == .remote
-            }
-        }
-
-        if !criteria.city.isEmpty {
-            results = results.filter {
-                $0.location.localizedCaseInsensitiveContains(criteria.city)
-                    || $0.remoteType == .remote
-            }
-        }
-
-        if let floor = criteria.salaryFloor {
-            results = results.filter { job in
-                guard let max = job.salary?.max ?? job.salary?.min else { return true }
-                return max >= floor
-            }
-        }
-
-        return results
+        return JobListingFilter.filter(MockJobListings.all, criteria: criteria, query: query)
     }
 }
 
@@ -118,12 +56,27 @@ struct MockSearchInterpreter: OpenAISearchService {
         if lower.contains("hybrid") {
             criteria.remoteTypes.insert(.hybrid)
         }
+        // Geo only when the user names a place — stay neutral otherwise.
         if lower.contains("pakistan") {
             criteria.country = "Pakistan"
         }
+        if lower.contains("united states") || lower.contains("usa") || lower.contains("u.s.") {
+            criteria.country = "United States"
+        }
+        if lower.contains("united kingdom") || lower.contains(" uk") || lower.hasSuffix(" uk") {
+            criteria.country = "United Kingdom"
+        }
         if lower.contains("lahore") {
             criteria.city = "Lahore"
-            criteria.country = "Pakistan"
+            if criteria.country.isEmpty { criteria.country = "Pakistan" }
+        }
+        if lower.contains("karachi") {
+            criteria.city = "Karachi"
+            if criteria.country.isEmpty { criteria.country = "Pakistan" }
+        }
+        if lower.contains("london") {
+            criteria.city = "London"
+            if criteria.country.isEmpty { criteria.country = "United Kingdom" }
         }
         if lower.contains("full-time") || lower.contains("full time") {
             criteria.employmentTypes.insert(.fullTime)

+ 29 - 0
indeed_ai_job_search/Services/OpenAI/FeatureFlags.swift

@@ -7,6 +7,8 @@ import Foundation
 
 enum FeatureFlags {
     private static let aiInterpretationDefaultsKey = "feature.aiInterpretationEnabled"
+    private static let aiRankingDefaultsKey = "feature.aiRankingEnabled"
+    private static let listingValidationDefaultsKey = "feature.listingValidationEnabled"
 
     /// Runtime override (UserDefaults). When unset, falls back to bundle/build flag.
     static var isAIInterpretationEnabled: Bool {
@@ -20,4 +22,31 @@ enum FeatureFlags {
             UserDefaults.standard.set(newValue, forKey: aiInterpretationDefaultsKey)
         }
     }
+
+    /// Optional OpenAI relevance ranking after local filter/dedupe. Defaults on.
+    static var isAIRankingEnabled: Bool {
+        get {
+            if UserDefaults.standard.object(forKey: aiRankingDefaultsKey) == nil {
+                return true
+            }
+            return UserDefaults.standard.bool(forKey: aiRankingDefaultsKey)
+        }
+        set {
+            UserDefaults.standard.set(newValue, forKey: aiRankingDefaultsKey)
+        }
+    }
+
+    /// Optional listing availability checks (source signals + optional HEAD).
+    /// Defaults on for source signals; HTTP is only used when availability is unknown.
+    static var isListingValidationEnabled: Bool {
+        get {
+            if UserDefaults.standard.object(forKey: listingValidationDefaultsKey) == nil {
+                return true
+            }
+            return UserDefaults.standard.bool(forKey: listingValidationDefaultsKey)
+        }
+        set {
+            UserDefaults.standard.set(newValue, forKey: listingValidationDefaultsKey)
+        }
+    }
 }

+ 33 - 0
indeed_ai_job_search/Services/OpenAI/JobRankingDTO.swift

@@ -0,0 +1,33 @@
+//
+//  JobRankingDTO.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+struct JobRankingRequestItem: Encodable, Sendable {
+    var indeedJobKey: String
+    var title: String
+    var company: String
+    var location: String
+    var remoteType: String
+    var snippet: String
+}
+
+struct JobRankingDTO: Codable, Equatable, Sendable {
+    var orderedJobKeys: [String]
+}
+
+enum JobRankingSchema {
+    static let jsonSchemaObject: [String: Any] = [
+        "type": "object",
+        "additionalProperties": false,
+        "properties": [
+            "orderedJobKeys": [
+                "type": "array",
+                "items": ["type": "string"]
+            ]
+        ],
+        "required": ["orderedJobKeys"]
+    ]
+}

+ 10 - 3
indeed_ai_job_search/Services/OpenAI/OpenAIClient.swift

@@ -42,18 +42,21 @@ enum OpenAIClientError: LocalizedError, Equatable, Sendable {
 
 struct OpenAIClient: Sendable {
     var apiKey: String
-    var model: String = "gpt-4o-mini"
+    var model: String
+    var reasoningEffort: String
     var timeoutInterval: TimeInterval = 30
     var session: URLSession
 
     init(
         apiKey: String,
-        model: String = "gpt-4o-mini",
+        model: String = OpenAIModels.defaultChatModel,
+        reasoningEffort: String = OpenAIModels.defaultReasoningEffort,
         timeoutInterval: TimeInterval = 30,
         session: URLSession? = nil
     ) {
         self.apiKey = apiKey
         self.model = model
+        self.reasoningEffort = reasoningEffort
         self.timeoutInterval = timeoutInterval
         if let session {
             self.session = session
@@ -81,7 +84,7 @@ struct OpenAIClient: Sendable {
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.timeoutInterval = timeoutInterval
 
-        let body: [String: Any] = [
+        var body: [String: Any] = [
             "model": model,
             "temperature": 0.1,
             "messages": [
@@ -97,6 +100,10 @@ struct OpenAIClient: Sendable {
                 ]
             ]
         ]
+        // GPT-6 Luna: keep reasoning off for structured extraction (cost + Chat Completions constraints).
+        if !reasoningEffort.isEmpty {
+            body["reasoning_effort"] = reasoningEffort
+        }
 
         request.httpBody = try JSONSerialization.data(withJSONObject: body)
 

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

@@ -0,0 +1,18 @@
+//
+//  OpenAIModels.swift
+//  indeed_ai_job_search
+//
+//  Default model: GPT-6 Luna — OpenAI’s most efficient model for focused,
+//  high-volume structured tasks ($0.10 / $0.50 per 1M tokens).
+//
+
+import Foundation
+
+enum OpenAIModels {
+    /// Cheap, accurate default for interpretation + ranking batches.
+    static let defaultChatModel = "gpt-6-luna"
+
+    /// Chat Completions structured JSON works best with reasoning disabled
+    /// (lower latency/cost; required for function calling on this model).
+    static let defaultReasoningEffort = "none"
+}

+ 196 - 0
indeed_ai_job_search/Services/OpenAI/OpenAIRankingService.swift

@@ -0,0 +1,196 @@
+//
+//  OpenAIRankingService.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+protocol OpenAIRankingService: Sendable {
+    func rank(
+        listings: [JobListing],
+        query: String,
+        criteria: SearchCriteria
+    ) async throws -> [JobListing]
+}
+
+/// Batch-ranks listings with GPT-6 Luna. Falls back to heuristic order on failure
+/// when used via `FallbackOpenAIRankingService`.
+struct OpenAIJobRankingService: OpenAIRankingService {
+    private let client: OpenAIClient
+    var maxBatchSize: Int = 40
+
+    init(client: OpenAIClient, maxBatchSize: Int = 40) {
+        self.client = client
+        self.maxBatchSize = max(1, maxBatchSize)
+    }
+
+    func rank(
+        listings: [JobListing],
+        query: String,
+        criteria: SearchCriteria
+    ) async throws -> [JobListing] {
+        guard listings.count > 1 else { return listings }
+
+        var ranked: [JobListing] = []
+        ranked.reserveCapacity(listings.count)
+
+        var index = 0
+        while index < listings.count {
+            let end = min(index + maxBatchSize, listings.count)
+            let batch = Array(listings[index..<end])
+            let ordered = try await rankBatch(batch, query: query, criteria: criteria)
+            ranked.append(contentsOf: ordered)
+            index = end
+        }
+        return ranked
+    }
+
+    private func rankBatch(
+        _ batch: [JobListing],
+        query: String,
+        criteria: SearchCriteria
+    ) async throws -> [JobListing] {
+        let items = batch.map {
+            JobRankingRequestItem(
+                indeedJobKey: $0.indeedJobKey,
+                title: $0.title,
+                company: $0.company,
+                location: $0.location,
+                remoteType: $0.remoteType.rawValue,
+                snippet: String($0.snippet.prefix(220))
+            )
+        }
+
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.sortedKeys]
+        let itemsJSON = String(data: try encoder.encode(items), encoding: .utf8) ?? "[]"
+
+        let contentData = try await client.chatCompletionJSON(
+            systemPrompt: Self.systemPrompt,
+            userPrompt: Self.userPrompt(query: query, criteria: criteria, itemsJSON: itemsJSON),
+            jsonSchemaName: "job_ranking",
+            jsonSchema: JobRankingSchema.jsonSchemaObject
+        )
+
+        let dto: JobRankingDTO
+        do {
+            dto = try JSONDecoder().decode(JobRankingDTO.self, from: contentData)
+        } catch {
+            throw OpenAIClientError.decodingFailed
+        }
+
+        return Self.applyOrder(dto.orderedJobKeys, to: batch)
+    }
+
+    static func applyOrder(_ orderedKeys: [String], to listings: [JobListing]) -> [JobListing] {
+        var remaining = Dictionary(uniqueKeysWithValues: listings.map { ($0.indeedJobKey, $0) })
+        var result: [JobListing] = []
+        result.reserveCapacity(listings.count)
+
+        for key in orderedKeys {
+            if let listing = remaining.removeValue(forKey: key) {
+                result.append(listing)
+            }
+        }
+        // Preserve relative order for keys the model omitted.
+        for listing in listings where remaining[listing.indeedJobKey] != nil {
+            result.append(listing)
+            remaining.removeValue(forKey: listing.indeedJobKey)
+        }
+        return result
+    }
+
+    private static let systemPrompt = """
+    You rank Indeed job listings by relevance to the user's search intent.
+    Prefer strong title/skill match, then location/remote fit, then seniority/employment fit.
+    Return every provided indeedJobKey exactly once in orderedJobKeys (best first).
+    Do not invent keys. Do not include non-Indeed boards.
+    """
+
+    private static func userPrompt(query: String, criteria: SearchCriteria, itemsJSON: String) -> String {
+        """
+        User query: \(query)
+        Job title: \(criteria.jobTitle)
+        Keywords: \(criteria.keywords)
+        Country: \(criteria.country)
+        City: \(criteria.city)
+        Remote: \(criteria.remoteTypes.map(\.rawValue).sorted().joined(separator: ","))
+        Employment: \(criteria.employmentTypes.map(\.rawValue).sorted().joined(separator: ","))
+        Experience: \(criteria.experienceLevels.map(\.rawValue).sorted().joined(separator: ","))
+
+        Candidates JSON:
+        \(itemsJSON)
+        """
+    }
+}
+
+/// Local heuristic ranking when AI ranking is off or fails.
+struct HeuristicJobRankingService: OpenAIRankingService {
+    func rank(
+        listings: [JobListing],
+        query: String,
+        criteria: SearchCriteria
+    ) async throws -> [JobListing] {
+        let tokens = Self.tokens(from: query, criteria: criteria)
+        guard !tokens.isEmpty else { return listings }
+
+        return listings.sorted { lhs, rhs in
+            let left = Self.score(lhs, tokens: tokens, criteria: criteria)
+            let right = Self.score(rhs, tokens: tokens, criteria: criteria)
+            if left == right {
+                return lhs.postedAt > rhs.postedAt
+            }
+            return left > right
+        }
+    }
+
+    private static func tokens(from query: String, criteria: SearchCriteria) -> [String] {
+        [query, criteria.jobTitle, criteria.keywords]
+            .joined(separator: " ")
+            .lowercased()
+            .split(separator: " ")
+            .map(String.init)
+            .filter { $0.count > 1 }
+    }
+
+    private static func score(_ job: JobListing, tokens: [String], criteria: SearchCriteria) -> Int {
+        let haystack = "\(job.title) \(job.company) \(job.snippet)".lowercased()
+        var value = tokens.reduce(0) { partial, token in
+            partial + (haystack.contains(token) ? 2 : 0)
+        }
+        if !criteria.jobTitle.isEmpty,
+           job.title.localizedCaseInsensitiveContains(criteria.jobTitle) {
+            value += 5
+        }
+        if !criteria.remoteTypes.isEmpty, criteria.remoteTypes.contains(job.remoteType) {
+            value += 2
+        }
+        if job.availability == .open {
+            value += 1
+        }
+        return value
+    }
+}
+
+struct FallbackOpenAIRankingService: OpenAIRankingService {
+    let primary: OpenAIRankingService?
+    let fallback: OpenAIRankingService
+
+    func rank(
+        listings: [JobListing],
+        query: String,
+        criteria: SearchCriteria
+    ) async throws -> [JobListing] {
+        guard FeatureFlags.isAIRankingEnabled, let primary else {
+            return try await fallback.rank(listings: listings, query: query, criteria: criteria)
+        }
+        do {
+            return try await primary.rank(listings: listings, query: query, criteria: criteria)
+        } catch OpenAIClientError.notConfigured, OpenAIClientError.disabled {
+            return try await fallback.rank(listings: listings, query: query, criteria: criteria)
+        } catch {
+            // Ranking is optional — degrade to heuristic instead of failing the search.
+            return try await fallback.rank(listings: listings, query: query, criteria: criteria)
+        }
+    }
+}

+ 7 - 3
indeed_ai_job_search/Services/OpenAI/OpenAISearchInterpreter.swift

@@ -48,7 +48,8 @@ struct OpenAISearchInterpreter: OpenAISearchService {
     private static let systemPrompt = """
     You extract structured Indeed job-search criteria from a natural-language query.
     Return only fields that are clearly implied. Prefer Indeed-friendly filters.
-    Use country/city when mentioned; default country to Pakistan if the user implies local jobs without naming a country.
+    Keep the search geo-neutral: leave country and city null/empty unless the user explicitly names a country, city, or region.
+    Do not invent a default country. Examples: "iOS developer" → no country; "iOS developer in Pakistan" → country Pakistan; "in Lahore" → city Lahore (and country if clear).
     remoteTypes values: remote, hybrid, onsite, unknown.
     employmentTypes values: fullTime, partTime, contract, temporary, internship.
     experienceLevels values: entry, junior, mid, senior.
@@ -56,11 +57,14 @@ struct OpenAISearchInterpreter: OpenAISearchService {
     """
 
     private static func userPrompt(for query: String, defaults: SearchCriteria) -> String {
-        """
+        let defaultCountryNote = defaults.country.isEmpty
+            ? "No default country — leave country null unless the user named one."
+            : "User filter country already set: \(defaults.country) (only override if the query names a different place)."
+        return """
         User query:
         \(query)
 
-        Default country if unspecified: \(defaults.country)
+        \(defaultCountryNote)
         Extract SearchCriteria JSON per schema.
         """
     }

+ 31 - 0
indeed_ai_job_search/Services/Search/JobListingDeduper.swift

@@ -0,0 +1,31 @@
+//
+//  JobListingDeduper.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+enum JobListingDeduper {
+    /// Collapses duplicate Indeed listings by `indeedJobKey`.
+    /// Keeps the newest `postedAt`, breaking ties by first occurrence.
+    static func dedupe(_ listings: [JobListing]) -> [JobListing] {
+        var bestByKey: [String: JobListing] = [:]
+        var order: [String] = []
+
+        for listing in listings {
+            let key = listing.indeedJobKey
+            guard !key.isEmpty else { continue }
+
+            if let existing = bestByKey[key] {
+                if listing.postedAt > existing.postedAt {
+                    bestByKey[key] = listing
+                }
+            } else {
+                bestByKey[key] = listing
+                order.append(key)
+            }
+        }
+
+        return order.compactMap { bestByKey[$0] }
+    }
+}

+ 118 - 0
indeed_ai_job_search/Services/Search/JobListingFilter.swift

@@ -0,0 +1,118 @@
+//
+//  JobListingFilter.swift
+//  indeed_ai_job_search
+//
+//  Shared local filter pass for mock, fixture, and pipeline stages.
+//
+
+import Foundation
+
+enum JobListingFilter {
+    /// Filters listings to Indeed-supported criteria fields. Best-effort for
+    /// fields the source may not fully enforce (freshness, exact phrase).
+    static func filter(
+        _ listings: [JobListing],
+        criteria: SearchCriteria,
+        query: String
+    ) -> [JobListing] {
+        listings.filter { matches($0, criteria: criteria, query: query) }
+    }
+
+    static func matches(
+        _ job: JobListing,
+        criteria: SearchCriteria,
+        query: String
+    ) -> Bool {
+        // Drop clearly closed listings from active search results.
+        if job.availability == .unavailable {
+            return false
+        }
+
+        let titleNeedle = criteria.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
+        let keywordsNeedle = criteria.keywords.trimmingCharacters(in: .whitespacesAndNewlines)
+        let queryNeedle = query.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        let haystack = "\(job.title) \(job.company) \(job.location) \(job.snippet)".lowercased()
+
+        if criteria.exactPhrase {
+            let phrase = [titleNeedle, keywordsNeedle, queryNeedle]
+                .first { !$0.isEmpty }
+                .map { $0.lowercased() }
+            if let phrase, !phrase.isEmpty, !haystack.contains(phrase) {
+                return false
+            }
+        } else {
+            let combined = [queryNeedle, titleNeedle, keywordsNeedle]
+                .filter { !$0.isEmpty }
+                .joined(separator: " ")
+                .lowercased()
+            if !combined.isEmpty {
+                let tokens = combined.split(separator: " ").map(String.init)
+                if !tokens.contains(where: { haystack.contains($0) }) {
+                    return false
+                }
+            }
+        }
+
+        let excluded = criteria.excludedKeywords
+            .lowercased()
+            .split(separator: " ")
+            .map(String.init)
+            .filter { !$0.isEmpty }
+        if !excluded.isEmpty {
+            let excludeHaystack = "\(job.title) \(job.company) \(job.snippet)".lowercased()
+            if excluded.contains(where: { excludeHaystack.contains($0) }) {
+                return false
+            }
+        }
+
+        if !criteria.remoteTypes.isEmpty,
+           !criteria.remoteTypes.contains(job.remoteType) {
+            return false
+        }
+
+        if !criteria.employmentTypes.isEmpty {
+            guard let type = job.employmentType,
+                  criteria.employmentTypes.contains(type) else {
+                return false
+            }
+        }
+
+        if !criteria.experienceLevels.isEmpty {
+            guard let level = job.experienceLevel,
+                  criteria.experienceLevels.contains(level) else {
+                return false
+            }
+        }
+
+        if !criteria.country.isEmpty {
+            let countryMatch = job.location.localizedCaseInsensitiveContains(criteria.country)
+            if !countryMatch && job.remoteType != .remote {
+                return false
+            }
+        }
+
+        if !criteria.city.isEmpty {
+            let cityMatch = job.location.localizedCaseInsensitiveContains(criteria.city)
+            if !cityMatch && job.remoteType != .remote {
+                return false
+            }
+        }
+
+        if let floor = criteria.salaryFloor {
+            // Keep listings with unknown salary (best effort); drop when max is below floor.
+            if let max = job.salary?.max ?? job.salary?.min, max < floor {
+                return false
+            }
+        }
+
+        if let freshness = criteria.dateFreshnessDays, freshness > 0 {
+            let cutoff = Date().addingTimeInterval(-TimeInterval(freshness) * 86_400)
+            if job.postedAt < cutoff {
+                return false
+            }
+        }
+
+        return true
+    }
+}

+ 216 - 0
indeed_ai_job_search/Services/Search/JobSearchPipeline.swift

@@ -0,0 +1,216 @@
+//
+//  JobSearchPipeline.swift
+//  indeed_ai_job_search
+//
+//  Phase 4 orchestrator: NL → criteria → Indeed fetch → filter → validate →
+//  rank → dedupe (with optional in-memory metadata cache).
+//
+
+import Foundation
+import OSLog
+
+struct DefaultJobSearchPipeline: JobSearchPipeline {
+    private static let log = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "indeed_ai_job_search",
+        category: "SearchPipeline"
+    )
+
+    var interpreter: OpenAISearchService
+    var dataSource: IndeedJobDataSource
+    var rankingService: OpenAIRankingService
+    var validationService: CompositeJobValidationService
+    var cache: any JobListingCache
+    var maxPages: Int
+
+    init(
+        interpreter: OpenAISearchService,
+        dataSource: IndeedJobDataSource,
+        rankingService: OpenAIRankingService,
+        validationService: CompositeJobValidationService,
+        cache: any JobListingCache = NullJobListingCache(),
+        maxPages: Int = IndeedCachePolicy.maxPagesPerSearchUI
+    ) {
+        self.interpreter = interpreter
+        self.dataSource = dataSource
+        self.rankingService = rankingService
+        self.validationService = validationService
+        self.cache = cache
+        self.maxPages = max(1, maxPages)
+    }
+
+    func search(_ request: JobSearchPipelineRequest) async throws -> JobSearchPipelineResult {
+        let trimmed = request.naturalLanguageQuery.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        let interpreted: InterpretedSearch
+        let criteria: SearchCriteria
+
+        if request.reinterpret {
+            interpreted = try await interpreter.interpret(query: trimmed)
+            criteria = SearchCriteriaMerge.merge(
+                interpreted: interpreted.criteria,
+                userEdited: request.userCriteria
+            )
+        } else {
+            criteria = request.userCriteria
+            interpreted = InterpretedSearch(
+                naturalLanguageQuery: trimmed,
+                criteria: criteria,
+                confidenceNote: "Using current filters without re-interpretation."
+            )
+        }
+
+        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)
+            """
+        )
+
+        if let cached = await cache.get(query: trimmed, criteria: criteria) {
+            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
+            )
+        }
+
+        let fetched = try await fetchPages(criteria: criteria, query: trimmed)
+        Self.log.info("Fetched \(fetched.listings.count, privacy: .public) raw listings")
+
+        for listing in fetched.listings where listing.availability == .unavailable {
+            Self.log.warning(
+                "Source marked unavailable — key=\(listing.indeedJobKey, privacy: .public) title=\(listing.title, privacy: .public) url=\(listing.indeedURL.absoluteString, privacy: .public)"
+            )
+        }
+
+        var listings = JobListingFilter.filter(fetched.listings, criteria: criteria, query: trimmed)
+        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"
+            )
+        }
+
+        listings = try await rankingService.rank(
+            listings: listings,
+            query: trimmed,
+            criteria: criteria
+        )
+        // Final dedupe after ranking in case batches reintroduced keys.
+        listings = JobListingDeduper.dedupe(listings)
+
+        for listing in listings {
+            let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
+            if resolved.usedBrowseFallback, let reason = resolved.reason {
+                Self.log.info(
+                    "Open URL note key=\(listing.indeedJobKey, privacy: .public): \(reason, privacy: .public)"
+                )
+            }
+        }
+
+        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))")
+
+        return JobSearchPipelineResult(
+            interpreted: InterpretedSearch(
+                naturalLanguageQuery: trimmed,
+                criteria: criteria,
+                confidenceNote: interpreted.confidenceNote
+            ),
+            listings: listings,
+            totalEstimated: total,
+            usedCache: false
+        )
+    }
+
+    private func fetchPages(
+        criteria: SearchCriteria,
+        query: String
+    ) async throws -> (listings: [JobListing], totalEstimated: Int?) {
+        var all: [JobListing] = []
+        var totalEstimated: Int?
+        var cursor: String?
+        var pageIndex = 0
+
+        while pageIndex < maxPages {
+            let page = try await dataSource.searchPage(
+                criteria: criteria,
+                query: query,
+                page: IndeedSearchPageRequest(
+                    pageIndex: pageIndex,
+                    pageSize: IndeedSearchPageRequest.defaultPageSize,
+                    cursor: cursor
+                )
+            )
+
+            all.append(contentsOf: page.listings)
+            if totalEstimated == nil {
+                totalEstimated = page.totalEstimated
+            }
+
+            guard page.hasMore else { break }
+            cursor = page.nextCursor
+            pageIndex += 1
+        }
+
+        return (all, totalEstimated)
+    }
+}
+
+/// Adapts a plain `JobDataSource` (e.g. mock) into the paginated Indeed surface.
+struct JobDataSourceIndeedAdapter: IndeedJobDataSource {
+    let base: JobDataSource
+
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        // Non-paginated sources only serve page 0.
+        if page.pageIndex > 0 {
+            return IndeedSearchPage(
+                listings: [],
+                pageIndex: page.pageIndex,
+                pageSize: page.pageSize,
+                totalEstimated: 0,
+                nextCursor: nil,
+                hasMore: false
+            )
+        }
+
+        let listings = try await base.search(criteria: criteria, query: query)
+        return IndeedSearchPage(
+            listings: listings,
+            pageIndex: 0,
+            pageSize: page.pageSize,
+            totalEstimated: listings.count,
+            nextCursor: nil,
+            hasMore: false
+        )
+    }
+}

+ 49 - 0
indeed_ai_job_search/Services/Search/SearchCriteriaMerge.swift

@@ -0,0 +1,49 @@
+//
+//  SearchCriteriaMerge.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+enum SearchCriteriaMerge {
+    /// Prefer explicit user filter edits over AI-extracted values when present.
+    static func merge(interpreted: SearchCriteria, userEdited: SearchCriteria) -> SearchCriteria {
+        var merged = interpreted
+
+        if !userEdited.excludedKeywords.isEmpty {
+            merged.excludedKeywords = userEdited.excludedKeywords
+        }
+        if userEdited.salaryFloor != nil {
+            merged.salaryFloor = userEdited.salaryFloor
+        }
+        if !userEdited.employmentTypes.isEmpty {
+            merged.employmentTypes = userEdited.employmentTypes
+        }
+        if !userEdited.experienceLevels.isEmpty {
+            merged.experienceLevels = userEdited.experienceLevels
+        }
+        if !userEdited.remoteTypes.isEmpty {
+            merged.remoteTypes = userEdited.remoteTypes
+        }
+        if !userEdited.city.isEmpty {
+            merged.city = userEdited.city
+        }
+        if !userEdited.country.isEmpty {
+            merged.country = userEdited.country
+        }
+        if !userEdited.jobTitle.isEmpty {
+            merged.jobTitle = userEdited.jobTitle
+        }
+        if !userEdited.keywords.isEmpty {
+            merged.keywords = userEdited.keywords
+        }
+        if userEdited.exactPhrase {
+            merged.exactPhrase = true
+        }
+        if let days = userEdited.dateFreshnessDays {
+            merged.dateFreshnessDays = days
+        }
+
+        return merged
+    }
+}

+ 114 - 0
indeed_ai_job_search/Services/Validation/ListingValidationServices.swift

@@ -0,0 +1,114 @@
+//
+//  ListingValidationServices.swift
+//  indeed_ai_job_search
+//
+//  Prefer source availability signals; optional lightweight HEAD when enabled.
+//
+
+import Foundation
+
+/// Uses provider/fixture availability. Marks unknown when signal is missing.
+struct SourceSignalJobValidationService: JobValidationService {
+    func validate(listing: JobListing) async -> JobAvailability {
+        listing.availability
+    }
+}
+
+/// Best-effort HEAD against the Indeed URL. Conservative on network errors.
+struct HTTPJobValidationService: JobValidationService {
+    var session: URLSession
+    var timeoutInterval: TimeInterval
+
+    init(timeoutInterval: TimeInterval = 8, session: URLSession? = nil) {
+        self.timeoutInterval = timeoutInterval
+        if let session {
+            self.session = session
+        } else {
+            let config = URLSessionConfiguration.ephemeral
+            config.timeoutIntervalForRequest = timeoutInterval
+            config.timeoutIntervalForResource = timeoutInterval
+            config.httpShouldSetCookies = false
+            self.session = URLSession(configuration: config)
+        }
+    }
+
+    func validate(listing: JobListing) async -> JobAvailability {
+        if listing.availability == .unavailable {
+            return .unavailable
+        }
+
+        var request = URLRequest(url: listing.indeedURL)
+        request.httpMethod = "HEAD"
+        request.timeoutInterval = timeoutInterval
+        request.setValue("IndeedAIJobs/1.0", forHTTPHeaderField: "User-Agent")
+
+        do {
+            let (_, response) = try await session.data(for: request)
+            guard let http = response as? HTTPURLResponse else {
+                return .unknown
+            }
+            switch http.statusCode {
+            case 200..<300:
+                return .open
+            case 404, 410, 451:
+                return .unavailable
+            case 401, 403, 429:
+                // Auth / rate-limit — do not claim closed.
+                return listing.availability == .open ? .open : .unknown
+            default:
+                return .unknown
+            }
+        } catch is CancellationError {
+            return listing.availability
+        } catch {
+            return .unknown
+        }
+    }
+}
+
+/// Source signal first; optional HTTP only when availability is still unknown.
+struct CompositeJobValidationService: JobValidationService {
+    let source: JobValidationService
+    let http: JobValidationService?
+    var maxConcurrent: Int = 6
+
+    func validate(listing: JobListing) async -> JobAvailability {
+        let fromSource = await source.validate(listing: listing)
+        if fromSource == .unavailable || fromSource == .open {
+            return fromSource
+        }
+        guard FeatureFlags.isListingValidationEnabled, let http else {
+            return fromSource
+        }
+        return await http.validate(listing: listing)
+    }
+
+    func validateAll(_ listings: [JobListing]) async -> [JobListing] {
+        guard !listings.isEmpty else { return listings }
+
+        var results = listings
+        let chunkSize = max(1, maxConcurrent)
+        var start = 0
+
+        while start < results.count {
+            let end = min(start + chunkSize, results.count)
+            await withTaskGroup(of: (Int, JobAvailability).self) { group in
+                for index in start..<end {
+                    let listing = results[index]
+                    group.addTask {
+                        let availability = await self.validate(listing: listing)
+                        return (index, availability)
+                    }
+                }
+                for await (index, availability) in group {
+                    results[index].availability = availability
+                }
+            }
+            start = end
+        }
+
+        // Keep unavailable rows visible but deprioritized by caller if needed;
+        // search pipeline drops them via JobListingFilter.
+        return results
+    }
+}

+ 8 - 5
indeed_ai_job_searchTests/IndeedListingMapperTests.swift

@@ -28,7 +28,7 @@ private let searchPage0JSON = """
       "salaryPeriod": "year",
       "postedAt": "2026-10-08T09:30:00Z",
       "snippet": "Build SwiftUI features for a consumer productivity app.",
-      "url": "https://pk.indeed.com/viewjob?jk=fixture-ios-lahore-001",
+      "url": "https://pk.indeed.com/jobs?q=iOS+Developer&l=Lahore%2C+Pakistan",
       "availability": "open",
       "isEasyApply": true
     },
@@ -46,7 +46,7 @@ private let searchPage0JSON = """
       "salaryPeriod": "year",
       "postedAt": "2026-10-10T14:00:00Z",
       "snippet": "Own macOS and iOS modules.",
-      "url": "https://www.indeed.com/viewjob?jk=fixture-swift-remote-002",
+      "url": "https://www.indeed.com/jobs?q=Senior+Swift+Engineer&l=Remote",
       "availability": "open"
     },
     {
@@ -54,7 +54,7 @@ private let searchPage0JSON = """
       "company": "Karachi Labs",
       "location": "Karachi, Pakistan",
       "remoteType": "onsite",
-      "url": "https://pk.indeed.com/viewjob?jk=fixture-android-khi-003",
+      "url": "https://pk.indeed.com/jobs?q=Android+Developer&l=Karachi%2C+Pakistan",
       "availability": "unknown",
       "snippet": "Kotlin multiplatform experiments."
     }
@@ -82,10 +82,13 @@ final class IndeedListingMapperTests: XCTestCase {
         XCTAssertTrue(IndeedURLCanonicalizer.isIndeedHost(first.indeedURL.host ?? ""))
     }
 
-    func testExtractsJobKeyFromURLWhenProviderKeyMissing() throws {
+    func testMapsBrowseURLWhenProviderKeyMissing() throws {
         let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
         let android = try XCTUnwrap(page.listings.first { $0.title.contains("Android") })
-        XCTAssertEqual(android.indeedJobKey, "fixture-android-khi-003")
+        // No provider key + browse URL → stable hash key, openable /jobs URL.
+        XCTAssertTrue(android.indeedJobKey.hasPrefix("url-"))
+        XCTAssertTrue(android.indeedURL.path.contains("/jobs"))
+        XCTAssertTrue(IndeedURLCanonicalizer.isIndeedHost(android.indeedURL.host ?? ""))
     }
 
     func testRejectsNonIndeedURL() {

+ 314 - 0
indeed_ai_job_searchTests/JobSearchPipelineTests.swift

@@ -0,0 +1,314 @@
+//
+//  JobSearchPipelineTests.swift
+//  indeed_ai_job_searchTests
+//
+
+import XCTest
+@testable import indeed_ai_job_search
+
+final class JobListingFilterTests: XCTestCase {
+    func testExcludesUnavailableAndExcludedKeywords() {
+        let open = makeListing(key: "a", title: "iOS Developer", snippet: "SwiftUI")
+        var closed = makeListing(key: "b", title: "iOS Engineer", snippet: "UIKit")
+        closed.availability = .unavailable
+        let excluded = makeListing(key: "c", title: "iOS Developer", snippet: "agency contract")
+
+        var criteria = SearchCriteria()
+        criteria.jobTitle = "iOS"
+        criteria.excludedKeywords = "agency"
+
+        let filtered = JobListingFilter.filter(
+            [open, closed, excluded],
+            criteria: criteria,
+            query: "iOS"
+        )
+
+        XCTAssertEqual(filtered.map(\.indeedJobKey), ["a"])
+    }
+
+    func testAppliesDateFreshness() {
+        let recent = makeListing(
+            key: "new",
+            title: "Swift Developer",
+            postedAt: Date().addingTimeInterval(-86_400)
+        )
+        let stale = makeListing(
+            key: "old",
+            title: "Swift Developer",
+            postedAt: Date().addingTimeInterval(-86_400 * 40)
+        )
+        var criteria = SearchCriteria()
+        criteria.keywords = "Swift"
+        criteria.dateFreshnessDays = 14
+
+        let filtered = JobListingFilter.filter([recent, stale], criteria: criteria, query: "")
+        XCTAssertEqual(filtered.map(\.indeedJobKey), ["new"])
+    }
+}
+
+final class JobListingDeduperTests: XCTestCase {
+    func testCollapsesByIndeedJobKeyKeepingNewest() {
+        let older = makeListing(
+            key: "jk1",
+            title: "Old title",
+            postedAt: Date().addingTimeInterval(-10_000)
+        )
+        let newer = makeListing(
+            key: "jk1",
+            title: "New title",
+            postedAt: Date()
+        )
+        let other = makeListing(key: "jk2", title: "Other")
+
+        let result = JobListingDeduper.dedupe([older, other, newer])
+        XCTAssertEqual(result.count, 2)
+        XCTAssertEqual(result.first?.title, "New title")
+        XCTAssertEqual(result.map(\.indeedJobKey), ["jk1", "jk2"])
+    }
+}
+
+final class JobRankingApplyOrderTests: XCTestCase {
+    func testApplyOrderPreservesOmittedKeys() {
+        let a = makeListing(key: "a", title: "A")
+        let b = makeListing(key: "b", title: "B")
+        let c = makeListing(key: "c", title: "C")
+        let ordered = OpenAIJobRankingService.applyOrder(["c", "a"], to: [a, b, c])
+        XCTAssertEqual(ordered.map(\.indeedJobKey), ["c", "a", "b"])
+    }
+}
+
+final class SearchCriteriaMergeTests: XCTestCase {
+    func testUserEditsWinOverInterpretation() {
+        var interpreted = SearchCriteria()
+        interpreted.jobTitle = "Android"
+        interpreted.country = "USA"
+
+        var user = SearchCriteria()
+        user.country = "" // unset so AI country can apply
+        user.jobTitle = "iOS Developer"
+        user.remoteTypes = [.remote]
+
+        let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
+        XCTAssertEqual(merged.jobTitle, "iOS Developer")
+        XCTAssertEqual(merged.country, "USA")
+        XCTAssertEqual(merged.remoteTypes, [.remote])
+    }
+
+    func testEmptyUserCountryKeepsAICountry() {
+        var interpreted = SearchCriteria()
+        interpreted.country = "United Kingdom"
+
+        // No country default — empty user filter must not overwrite AI geo from the query.
+        let user = SearchCriteria()
+        XCTAssertTrue(user.country.isEmpty)
+        let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
+        XCTAssertEqual(merged.country, "United Kingdom")
+    }
+
+    func testUserCountryFilterOverridesAI() {
+        var interpreted = SearchCriteria()
+        interpreted.country = "United Kingdom"
+
+        var user = SearchCriteria()
+        user.country = "Pakistan"
+        let merged = SearchCriteriaMerge.merge(interpreted: interpreted, userEdited: user)
+        XCTAssertEqual(merged.country, "Pakistan")
+    }
+}
+
+final class IndeedBrowseURLBuilderTests: XCTestCase {
+    func testFakeJobKeyUsesBrowseSearchURL() {
+        let listing = JobListing(
+            indeedJobKey: "fixture-ios-001",
+            title: "iOS Developer",
+            company: "Acme",
+            location: "Lahore, Pakistan",
+            remoteType: .remote,
+            postedAt: Date(),
+            snippet: "Swift",
+            indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=fixture-ios-001")!,
+            availability: .open
+        )
+        let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
+        XCTAssertTrue(resolved.usedBrowseFallback)
+        XCTAssertTrue(resolved.url.path.contains("/jobs"))
+        XCTAssertEqual(resolved.url.host, "pk.indeed.com")
+        XCTAssertFalse(resolved.url.absoluteString.contains("viewjob"))
+    }
+
+    func testRealHexJobKeyKeepsViewJob() {
+        let listing = JobListing(
+            indeedJobKey: "5185d0dd8c286093",
+            title: "Senior Software Engineer",
+            company: "Acme",
+            location: "New York, NY",
+            remoteType: .remote,
+            postedAt: Date(),
+            snippet: "Swift",
+            indeedURL: URL(string: "https://www.indeed.com/viewjob?jk=5185d0dd8c286093")!,
+            availability: .open
+        )
+        let resolved = IndeedBrowseURLBuilder.openableURL(for: listing)
+        XCTAssertFalse(resolved.usedBrowseFallback)
+        XCTAssertTrue(resolved.url.absoluteString.contains("viewjob"))
+    }
+}
+
+final class DefaultJobSearchPipelineTests: XCTestCase {
+    func testPipelineFiltersDedupesAndRanks() async throws {
+        let duplicateOlder = makeListing(
+            key: "dup",
+            title: "iOS Developer",
+            postedAt: Date().addingTimeInterval(-5_000)
+        )
+        let duplicateNewer = makeListing(
+            key: "dup",
+            title: "iOS Developer (Updated)",
+            postedAt: Date()
+        )
+        let android = makeListing(key: "and", title: "Android Developer")
+
+        let source = InlinePipelineDataSource(listings: [duplicateOlder, android, duplicateNewer])
+        let pipeline = DefaultJobSearchPipeline(
+            interpreter: StaticInterpreter(
+                InterpretedSearch(
+                    naturalLanguageQuery: "iOS",
+                    criteria: {
+                        var c = SearchCriteria()
+                        c.jobTitle = "iOS"
+                        return c
+                    }(),
+                    confidenceNote: "test"
+                )
+            ),
+            dataSource: source,
+            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
+        }
+
+        let result = try await pipeline.search(
+            JobSearchPipelineRequest(
+                naturalLanguageQuery: "iOS jobs",
+                userCriteria: SearchCriteria(),
+                reinterpret: true
+            )
+        )
+
+        XCTAssertEqual(result.listings.count, 1)
+        XCTAssertEqual(result.listings.first?.indeedJobKey, "dup")
+        XCTAssertEqual(result.listings.first?.title, "iOS Developer (Updated)")
+        XCTAssertFalse(result.usedCache)
+    }
+
+    func testCacheHitSkipsRefetch() async throws {
+        let listing = makeListing(key: "cached", title: "iOS Developer")
+        let source = InlinePipelineDataSource(listings: [listing])
+        let cache = InMemoryIndeedListingCache(ttl: 60)
+        let pipeline = DefaultJobSearchPipeline(
+            interpreter: StaticInterpreter(
+                InterpretedSearch(
+                    naturalLanguageQuery: "iOS",
+                    criteria: {
+                        var c = SearchCriteria()
+                        c.keywords = "iOS"
+                        return c
+                    }(),
+                    confidenceNote: "test"
+                )
+            ),
+            dataSource: source,
+            rankingService: HeuristicJobRankingService(),
+            validationService: CompositeJobValidationService(
+                source: SourceSignalJobValidationService(),
+                http: nil
+            ),
+            cache: cache
+        )
+
+        let request = JobSearchPipelineRequest(
+            naturalLanguageQuery: "iOS",
+            userCriteria: SearchCriteria(),
+            reinterpret: true
+        )
+        _ = try await pipeline.search(request)
+        let second = try await pipeline.search(request)
+        XCTAssertTrue(second.usedCache)
+        XCTAssertEqual(source.fetchCount, 1)
+    }
+}
+
+final class OpenAIModelsTests: XCTestCase {
+    func testDefaultModelIsGPT6Luna() {
+        XCTAssertEqual(OpenAIModels.defaultChatModel, "gpt-6-luna")
+        let client = OpenAIClient(apiKey: "sk-test")
+        XCTAssertEqual(client.model, "gpt-6-luna")
+        XCTAssertEqual(client.reasoningEffort, "none")
+    }
+}
+
+// MARK: - Helpers
+
+private func makeListing(
+    key: String,
+    title: String,
+    snippet: String = "Build apps",
+    postedAt: Date = Date()
+) -> JobListing {
+    JobListing(
+        indeedJobKey: key,
+        title: title,
+        company: "Acme",
+        location: "Lahore, Pakistan",
+        remoteType: .remote,
+        employmentType: .fullTime,
+        experienceLevel: .mid,
+        postedAt: postedAt,
+        snippet: snippet,
+        indeedURL: URL(string: "https://pk.indeed.com/viewjob?jk=\(key)")!,
+        availability: .open
+    )
+}
+
+private struct StaticInterpreter: OpenAISearchService {
+    let value: InterpretedSearch
+    init(_ value: InterpretedSearch) { self.value = value }
+    func interpret(query: String) async throws -> InterpretedSearch { value }
+}
+
+private final class InlinePipelineDataSource: IndeedJobDataSource, @unchecked Sendable {
+    let listings: [JobListing]
+    private(set) var fetchCount = 0
+
+    init(listings: [JobListing]) {
+        self.listings = listings
+    }
+
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        fetchCount += 1
+        return IndeedSearchPage(
+            listings: listings,
+            pageIndex: page.pageIndex,
+            pageSize: page.pageSize,
+            totalEstimated: listings.count,
+            nextCursor: nil,
+            hasMore: false
+        )
+    }
+}