Procházet zdrojové kódy

Add Phase 3 OpenAI interpretation and fix input text contrast.

Wire obfuscated build-time OpenAI key with schema-validated search criteria, approve Option C in the DADR, and force readable typed text on light search/filter fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali před 19 hodinami
rodič
revize
67471ee661

+ 8 - 7
Docs/Phase2/DADR.md

@@ -2,8 +2,9 @@
 
 **Product:** Indeed AI Jobs (macOS)  
 **Phase:** 2 — Indeed retrieval feasibility  
-**Status:** Proposed — awaiting product owner go / no-go  
-**Date:** 2026-10-12
+**Status:** Approved — Option C  
+**Date:** 2026-10-12  
+**Approved:** 2026-10-12 (product owner)
 
 ---
 
@@ -11,11 +12,11 @@
 
 | Field | Value |
 |-------|--------|
-| **Chosen production source (recommended)** | **Licensed Indeed-index provider** (or Indeed partner search channel) that returns **Indeed-only** listings with permitted desktop redistribution and link-back |
-| **Interim implementation** | `FixtureIndeedJobDataSource` + mapper (no production network traffic) |
+| **Chosen production source** | **Licensed Indeed-index provider** (or Indeed partner search channel) that returns **Indeed-only** listings with permitted desktop redistribution and link-back |
+| **Interim implementation** | `FixtureIndeedJobDataSource` + mapper (no production network traffic) until vendor contract is live |
 | **Rejected for production** | Client-side scraping / unauthorized automated HTML collection |
 | **Rejected as seeker search API** | Indeed Job Sync API (employer/partner posting sync — not job-seeker search) |
-| **Go / no-go gate** | **No live Indeed fetch ships** until this DADR is explicitly approved **and** a permitted contract is in place |
+| **Go / no-go gate** | **Go on Option C.** Live Indeed fetch still waits on a permitted vendor contract; OpenAI (Phase 3) proceeds against mocks/fixtures. |
 
 ---
 
@@ -94,6 +95,6 @@ Update Privacy Policy before live fetch ships.
 
 | Role | Name | Decision | Date |
 |------|------|----------|------|
-| Product owner | _pending_ | Approve recommended Option C / request alternate / no-go | |
+| Product owner | Product owner | **Approved Option C** (licensed/partner Indeed-only path) | 2026-10-12 |
 
-**Sign-off required before Phase 3+ live Indeed traffic.** OpenAI work may proceed in parallel against mocks/fixtures.
+**Live Indeed traffic** still requires a signed vendor/partner contract (section 5). **OpenAI Phase 3** may proceed against mocks/fixtures.

+ 38 - 0
Docs/Phase3/OpenAI_Integration.md

@@ -0,0 +1,38 @@
+# Phase 3 — OpenAI integration
+
+**Status:** Implemented (local key via gitignored xcconfig)  
+**Date:** 2026-10-12
+
+## What shipped
+
+- `OpenAIClient` — async chat completions with JSON Schema response format, 30s timeout
+- `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)
+
+## Local setup
+
+1. Copy `Config/Secrets.xcconfig.example` → `Config/Secrets.xcconfig`
+2. Set `OPENAI_API_KEY_OBFUSCATED` using:
+
+```swift
+SecretsProvider.obfuscateForConfig("sk-...")
+```
+
+3. Build & run. App uses `AppDependencies.live`.
+
+`Secrets.xcconfig` is **gitignored**. Never commit plaintext or obfuscated production keys.
+
+## Security limits
+
+- Obfuscation is **not** encryption; a determined attacker can recover the key from the binary.
+- Do not log prompts/responses that contain PII in production builds.
+- Rotate keys if exposed in chat, screenshots, or commits.
+
+## Feature flag
+
+```swift
+FeatureFlags.isAIInterpretationEnabled = false // mock interpretation only
+```

+ 31 - 0
indeed_ai_job_search.xcodeproj/project.pbxproj

@@ -19,11 +19,25 @@
 /* Begin PBXFileReference section */
 		A7B63BDB307CA8F5003F0D4E /* indeed_ai_job_search.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = indeed_ai_job_search.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		A7B63C0F307CB001003F0D4E /* indeed_ai_job_searchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = indeed_ai_job_searchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		A7B63C20307CB100003F0D4E /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+		A7B63C30307CB200003F0D4E /* Exceptions for "indeed_ai_job_search" folder in "indeed_ai_job_search" target */ = {
+			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+			membershipExceptions = (
+				Info.plist,
+			);
+			target = A7B63BDA307CA8F5003F0D4E /* indeed_ai_job_search */;
+		};
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
 /* Begin PBXFileSystemSynchronizedRootGroup section */
 		A7B63BDD307CA8F5003F0D4E /* indeed_ai_job_search */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
+			exceptions = (
+				A7B63C30307CB200003F0D4E /* Exceptions for "indeed_ai_job_search" folder in "indeed_ai_job_search" target */,
+			);
 			path = indeed_ai_job_search;
 			sourceTree = "<group>";
 		};
@@ -51,6 +65,7 @@
 		A7B63BD2307CA8F5003F0D4E = {
 			isa = PBXGroup;
 			children = (
+				A7B63C21307CB100003F0D4E /* Config */,
 				A7B63BDD307CA8F5003F0D4E /* indeed_ai_job_search */,
 				A7B63C11307CB001003F0D4E /* indeed_ai_job_searchTests */,
 				A7B63BDC307CA8F5003F0D4E /* Products */,
@@ -66,6 +81,14 @@
 			name = Products;
 			sourceTree = "<group>";
 		};
+		A7B63C21307CB100003F0D4E /* Config */ = {
+			isa = PBXGroup;
+			children = (
+				A7B63C20307CB100003F0D4E /* Shared.xcconfig */,
+			);
+			path = Config;
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -302,6 +325,7 @@
 		};
 		A7B63BE7307CA8F7003F0D4E /* Debug configuration for PBXNativeTarget "indeed_ai_job_search" */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = A7B63C20307CB100003F0D4E /* Shared.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -309,10 +333,13 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				ENABLE_APP_SANDBOX = YES;
+				ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
 				ENABLE_PREVIEWS = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = indeed_ai_job_search/Info.plist;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_PREPROCESS = YES;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
@@ -332,6 +359,7 @@
 		};
 		A7B63BE8307CA8F7003F0D4E /* Release configuration for PBXNativeTarget "indeed_ai_job_search" */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = A7B63C20307CB100003F0D4E /* Shared.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -339,10 +367,13 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				ENABLE_APP_SANDBOX = YES;
+				ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
 				ENABLE_PREVIEWS = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = indeed_ai_job_search/Info.plist;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_PREPROCESS = YES;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",

+ 27 - 0
indeed_ai_job_search/App/AppDependencies.swift

@@ -27,6 +27,7 @@ final class AppDependencies {
         self.alertsStore = alertsStore
     }
 
+    /// Preview / SwiftUI canvas — mocks only, no network.
     static var preview: AppDependencies {
         AppDependencies(
             jobDataSource: MockJobDataSource(),
@@ -36,4 +37,30 @@ final class AppDependencies {
             alertsStore: MockAlertsStore()
         )
     }
+
+    /// App launch — OpenAI when configured; Indeed remains mock/fixture until vendor contract.
+    static var live: AppDependencies {
+        let mockInterpreter = MockSearchInterpreter()
+        let interpreter: OpenAISearchService
+
+        if FeatureFlags.isAIInterpretationEnabled,
+           SecretsProvider.isOpenAIConfigured,
+           let apiKey = SecretsProvider.openAIAPIKey {
+            let client = OpenAIClient(apiKey: apiKey)
+            interpreter = FallbackOpenAISearchService(
+                primary: OpenAISearchInterpreter(client: client),
+                fallback: mockInterpreter
+            )
+        } else {
+            interpreter = mockInterpreter
+        }
+
+        return AppDependencies(
+            jobDataSource: MockJobDataSource(),
+            searchInterpreter: interpreter,
+            validationService: MockJobValidationService(),
+            savedJobsStore: MockSavedJobsStore(),
+            alertsStore: MockAlertsStore()
+        )
+    }
 }

+ 1 - 1
indeed_ai_job_search/App/indeed_ai_job_searchApp.swift

@@ -7,7 +7,7 @@ import SwiftUI
 
 @main
 struct indeed_ai_job_searchApp: App {
-    @State private var dependencies = AppDependencies.preview
+    @State private var dependencies = AppDependencies.live
 
     var body: some Scene {
         WindowGroup {

+ 22 - 3
indeed_ai_job_search/DesignSystem/Components/FilterControls.swift

@@ -5,6 +5,26 @@
 
 import SwiftUI
 
+/// Styles plain `TextField`s that sit on light custom surfaces.
+/// macOS otherwise uses the system (often white) text color when Dark appearance is on.
+struct AppPlainTextFieldModifier: ViewModifier {
+    func body(content: Content) -> some View {
+        content
+            .textFieldStyle(.plain)
+            .font(AppTypography.body)
+            .foregroundStyle(AppColors.textPrimary)
+            .tint(AppColors.brand)
+            // Keep control chrome/text readable on fixed light input backgrounds.
+            .environment(\.colorScheme, .light)
+    }
+}
+
+extension View {
+    func appPlainTextField() -> some View {
+        modifier(AppPlainTextFieldModifier())
+    }
+}
+
 /// Left-aligned single-line filter text field (avoids macOS plain TextField centering).
 struct FilterTextField: View {
     let title: String?
@@ -20,9 +40,7 @@ struct FilterTextField: View {
             }
 
             TextField("", text: $text, prompt: Text(prompt).foregroundStyle(AppColors.textTertiary))
-                .textFieldStyle(.plain)
-                .font(AppTypography.body)
-                .foregroundStyle(AppColors.textPrimary)
+                .appPlainTextField()
                 .multilineTextAlignment(.leading)
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .frame(height: 18, alignment: .leading)
@@ -34,6 +52,7 @@ struct FilterTextField: View {
                     RoundedRectangle(cornerRadius: 8, style: .continuous)
                         .stroke(AppColors.borderStrong, lineWidth: 1)
                 )
+                .environment(\.colorScheme, .light)
         }
     }
 }

+ 12 - 6
indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift

@@ -99,12 +99,17 @@ struct SearchComposerView: View {
                 Image(systemName: "magnifyingglass")
                     .foregroundStyle(AppColors.textTertiary)
 
-                TextField("Describe a job, e.g. Remote iOS developer jobs in Pakistan", text: $query, axis: .vertical)
-                    .textFieldStyle(.plain)
-                    .font(AppTypography.body)
-                    .lineLimit(mode == .hero ? 2...4 : 1...2)
-                    .focused($isFocused)
-                    .onSubmit(onSubmit)
+                TextField(
+                    "",
+                    text: $query,
+                    prompt: Text("Describe a job, e.g. Remote iOS developer jobs in Pakistan")
+                        .foregroundStyle(AppColors.textTertiary),
+                    axis: .vertical
+                )
+                .appPlainTextField()
+                .lineLimit(mode == .hero ? 2...4 : 1...2)
+                .focused($isFocused)
+                .onSubmit(onSubmit)
 
                 if !query.isEmpty {
                     Button {
@@ -125,6 +130,7 @@ struct SearchComposerView: View {
                 RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous)
                     .stroke(isFocused ? AppColors.brand : AppColors.borderStrong, lineWidth: isFocused ? 1.5 : 1)
             )
+            .environment(\.colorScheme, .light)
 
             Button {
                 onSubmit()

+ 3 - 0
indeed_ai_job_search/Features/JobAlerts/Views/JobAlertsView.swift

@@ -187,6 +187,9 @@ struct AlertEditorSheet: View {
             }
             .formStyle(.grouped)
             .padding(.horizontal, AppSpacing.sm)
+            // Form fields use system text color; keep them readable on light sheet surfaces.
+            .environment(\.colorScheme, .light)
+            .foregroundStyle(AppColors.textPrimary)
 
             HStack {
                 Spacer()

+ 10 - 7
indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift

@@ -133,13 +133,15 @@ struct SavedJobsView: View {
                 .font(.system(size: 13, weight: .medium))
                 .foregroundStyle(AppColors.textTertiary)
 
-            TextField("", text: $viewModel.searchText, prompt: Text("Search title, company, location").foregroundStyle(AppColors.textTertiary))
-                .textFieldStyle(.plain)
-                .font(AppTypography.body)
-                .foregroundStyle(AppColors.textPrimary)
-                .multilineTextAlignment(.leading)
-                .frame(maxWidth: .infinity, alignment: .leading)
-                .frame(height: 18, alignment: .leading)
+            TextField(
+                "",
+                text: $viewModel.searchText,
+                prompt: Text("Search title, company, location").foregroundStyle(AppColors.textTertiary)
+            )
+            .appPlainTextField()
+            .multilineTextAlignment(.leading)
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .frame(height: 18, alignment: .leading)
 
             if !viewModel.searchText.isEmpty {
                 Button {
@@ -160,6 +162,7 @@ struct SavedJobsView: View {
             RoundedRectangle(cornerRadius: 8, style: .continuous)
                 .stroke(AppColors.borderStrong, lineWidth: 1)
         )
+        .environment(\.colorScheme, .light)
     }
 
     private var workTypeDropdown: some View {

+ 10 - 0
indeed_ai_job_search/Info.plist

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>OpenAIAPIKeyObfuscated</key>
+	<string>$(OPENAI_API_KEY_OBFUSCATED)</string>
+	<key>FeatureAIInterpretationEnabled</key>
+	<string>$(FEATURE_AI_INTERPRETATION_ENABLED)</string>
+</dict>
+</plist>

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

@@ -0,0 +1,23 @@
+//
+//  FeatureFlags.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+enum FeatureFlags {
+    private static let aiInterpretationDefaultsKey = "feature.aiInterpretationEnabled"
+
+    /// Runtime override (UserDefaults). When unset, falls back to bundle/build flag.
+    static var isAIInterpretationEnabled: Bool {
+        get {
+            if UserDefaults.standard.object(forKey: aiInterpretationDefaultsKey) == nil {
+                return SecretsProvider.isAIInterpretationEnabledInBundle
+            }
+            return UserDefaults.standard.bool(forKey: aiInterpretationDefaultsKey)
+        }
+        set {
+            UserDefaults.standard.set(newValue, forKey: aiInterpretationDefaultsKey)
+        }
+    }
+}

+ 153 - 0
indeed_ai_job_search/Services/OpenAI/OpenAIClient.swift

@@ -0,0 +1,153 @@
+//
+//  OpenAIClient.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+enum OpenAIClientError: LocalizedError, Equatable, Sendable {
+    case notConfigured
+    case disabled
+    case invalidResponse
+    case decodingFailed
+    case httpStatus(Int, String?)
+    case timeout
+    case network(String)
+    case cancelled
+
+    var errorDescription: String? {
+        switch self {
+        case .notConfigured:
+            return "OpenAI is not configured for this build."
+        case .disabled:
+            return "AI interpretation is turned off. Using local filters only."
+        case .invalidResponse:
+            return "Received an unexpected response from OpenAI."
+        case .decodingFailed:
+            return "Could not understand the AI search interpretation."
+        case .httpStatus(let code, let detail):
+            if let detail, !detail.isEmpty {
+                return "OpenAI request failed (\(code)): \(detail)"
+            }
+            return "OpenAI request failed (HTTP \(code))."
+        case .timeout:
+            return "The AI request timed out. Try again."
+        case .network(let detail):
+            return "Could not reach OpenAI. \(detail)"
+        case .cancelled:
+            return "The AI request was cancelled."
+        }
+    }
+}
+
+struct OpenAIClient: Sendable {
+    var apiKey: String
+    var model: String = "gpt-4o-mini"
+    var timeoutInterval: TimeInterval = 30
+    var session: URLSession
+
+    init(
+        apiKey: String,
+        model: String = "gpt-4o-mini",
+        timeoutInterval: TimeInterval = 30,
+        session: URLSession? = nil
+    ) {
+        self.apiKey = apiKey
+        self.model = model
+        self.timeoutInterval = timeoutInterval
+        if let session {
+            self.session = session
+        } else {
+            let config = URLSessionConfiguration.ephemeral
+            config.timeoutIntervalForRequest = timeoutInterval
+            config.timeoutIntervalForResource = timeoutInterval
+            self.session = URLSession(configuration: config)
+        }
+    }
+
+    func chatCompletionJSON(
+        systemPrompt: String,
+        userPrompt: String,
+        jsonSchemaName: String,
+        jsonSchema: [String: Any]
+    ) async throws -> Data {
+        guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else {
+            throw OpenAIClientError.invalidResponse
+        }
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.timeoutInterval = timeoutInterval
+
+        let body: [String: Any] = [
+            "model": model,
+            "temperature": 0.1,
+            "messages": [
+                ["role": "system", "content": systemPrompt],
+                ["role": "user", "content": userPrompt]
+            ],
+            "response_format": [
+                "type": "json_schema",
+                "json_schema": [
+                    "name": jsonSchemaName,
+                    "strict": true,
+                    "schema": jsonSchema
+                ]
+            ]
+        ]
+
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+        do {
+            let (data, response) = try await session.data(for: request)
+            guard let http = response as? HTTPURLResponse else {
+                throw OpenAIClientError.invalidResponse
+            }
+
+            guard (200..<300).contains(http.statusCode) else {
+                let message = Self.redactedErrorMessage(from: data)
+                throw OpenAIClientError.httpStatus(http.statusCode, message)
+            }
+
+            guard
+                let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
+                let choices = root["choices"] as? [[String: Any]],
+                let first = choices.first,
+                let message = first["message"] as? [String: Any],
+                let content = message["content"] as? String,
+                let contentData = content.data(using: .utf8)
+            else {
+                throw OpenAIClientError.invalidResponse
+            }
+
+            return contentData
+        } catch let error as OpenAIClientError {
+            throw error
+        } catch is CancellationError {
+            throw OpenAIClientError.cancelled
+        } catch let urlError as URLError where urlError.code == .timedOut {
+            throw OpenAIClientError.timeout
+        } catch {
+            // Never include authorization material; use generic transport text only.
+            throw OpenAIClientError.network(error.localizedDescription)
+        }
+    }
+
+    /// Extract a short error string without echoing prompt/PII payloads.
+    private static func redactedErrorMessage(from data: Data) -> String? {
+        guard
+            let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+            let error = root["error"] as? [String: Any],
+            let message = error["message"] as? String
+        else {
+            return nil
+        }
+        let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.count > 180 {
+            return String(trimmed.prefix(180)) + "…"
+        }
+        return trimmed
+    }
+}

+ 90 - 0
indeed_ai_job_search/Services/OpenAI/OpenAISearchInterpreter.swift

@@ -0,0 +1,90 @@
+//
+//  OpenAISearchInterpreter.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+struct OpenAISearchInterpreter: OpenAISearchService {
+    private let client: OpenAIClient
+    private let defaults: SearchCriteria
+
+    init(client: OpenAIClient, defaults: SearchCriteria = SearchCriteria()) {
+        self.client = client
+        self.defaults = defaults
+    }
+
+    func interpret(query: String) async throws -> InterpretedSearch {
+        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            return InterpretedSearch(
+                naturalLanguageQuery: query,
+                criteria: defaults,
+                confidenceNote: "Empty query."
+            )
+        }
+
+        let contentData = try await client.chatCompletionJSON(
+            systemPrompt: Self.systemPrompt,
+            userPrompt: Self.userPrompt(for: trimmed, defaults: defaults),
+            jsonSchemaName: "search_criteria_interpretation",
+            jsonSchema: SearchCriteriaInterpretationSchema.jsonSchemaObject
+        )
+
+        do {
+            let dto = try JSONDecoder().decode(SearchCriteriaInterpretationDTO.self, from: contentData)
+            let criteria = dto.toSearchCriteria(defaults: defaults)
+            let note = dto.confidenceNote?.trimmingCharacters(in: .whitespacesAndNewlines)
+            return InterpretedSearch(
+                naturalLanguageQuery: trimmed,
+                criteria: criteria,
+                confidenceNote: (note?.isEmpty == false) ? note! : "AI-extracted Indeed search criteria."
+            )
+        } catch {
+            throw OpenAIClientError.decodingFailed
+        }
+    }
+
+    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.
+    remoteTypes values: remote, hybrid, onsite, unknown.
+    employmentTypes values: fullTime, partTime, contract, temporary, internship.
+    experienceLevels values: entry, junior, mid, senior.
+    Do not invent salary floors unless the user stated one. Do not include non-Indeed boards.
+    """
+
+    private static func userPrompt(for query: String, defaults: SearchCriteria) -> String {
+        """
+        User query:
+        \(query)
+
+        Default country if unspecified: \(defaults.country)
+        Extract SearchCriteria JSON per schema.
+        """
+    }
+}
+
+/// Falls back to mock interpretation when AI is disabled or not configured.
+struct FallbackOpenAISearchService: OpenAISearchService {
+    let primary: OpenAISearchService?
+    let fallback: OpenAISearchService
+
+    func interpret(query: String) async throws -> InterpretedSearch {
+        guard FeatureFlags.isAIInterpretationEnabled else {
+            return try await fallback.interpret(query: query)
+        }
+        guard let primary else {
+            return try await fallback.interpret(query: query)
+        }
+        do {
+            return try await primary.interpret(query: query)
+        } catch OpenAIClientError.notConfigured, OpenAIClientError.disabled {
+            return try await fallback.interpret(query: query)
+        } catch {
+            // Surface real API failures to the UI (timeouts, auth, etc.).
+            throw error
+        }
+    }
+}

+ 2 - 2
indeed_ai_job_search/Services/OpenAI/OpenAIServicePlaceholder.swift

@@ -2,11 +2,11 @@
 //  OpenAIServicePlaceholder.swift
 //  indeed_ai_job_search
 //
-//  Phase 3 will replace this stub with OpenAIClient + SecretsProvider.
+//  Phase 3: see OpenAIClient, OpenAISearchInterpreter, SecretsProvider.
 //
 
 import Foundation
 
 enum OpenAIServicePlaceholder {
-    static let phaseNote = "OpenAI interpretation lands in Phase 3."
+    static let phaseNote = "OpenAI interpretation is implemented via OpenAISearchInterpreter."
 }

+ 139 - 0
indeed_ai_job_search/Services/OpenAI/SearchCriteriaInterpretationDTO.swift

@@ -0,0 +1,139 @@
+//
+//  SearchCriteriaInterpretationDTO.swift
+//  indeed_ai_job_search
+//
+//  Schema-validated JSON shape returned by OpenAI query interpretation.
+//
+
+import Foundation
+
+struct SearchCriteriaInterpretationDTO: Codable, Equatable, Sendable {
+    var jobTitle: String? = nil
+    var keywords: String? = nil
+    var exactPhrase: Bool? = nil
+    var excludedKeywords: String? = nil
+    var country: String? = nil
+    var city: String? = nil
+    var remoteTypes: [String]? = nil
+    var employmentTypes: [String]? = nil
+    var experienceLevels: [String]? = nil
+    var salaryFloor: Double? = nil
+    var dateFreshnessDays: Int? = nil
+    var confidenceNote: String? = nil
+
+    func toSearchCriteria(defaults: SearchCriteria = SearchCriteria()) -> SearchCriteria {
+        var criteria = defaults
+
+        if let jobTitle, !jobTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            criteria.jobTitle = jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+        if let keywords, !keywords.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            criteria.keywords = keywords.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+        if let exactPhrase {
+            criteria.exactPhrase = exactPhrase
+        }
+        if let excludedKeywords {
+            criteria.excludedKeywords = excludedKeywords.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+        if let country, !country.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            criteria.country = country.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+        if let city {
+            criteria.city = city.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+        if let remoteTypes {
+            criteria.remoteTypes = Set(remoteTypes.compactMap(Self.mapRemote))
+        }
+        if let employmentTypes {
+            criteria.employmentTypes = Set(employmentTypes.compactMap(Self.mapEmployment))
+        }
+        if let experienceLevels {
+            criteria.experienceLevels = Set(experienceLevels.compactMap(Self.mapExperience))
+        }
+        if let salaryFloor, salaryFloor > 0 {
+            criteria.salaryFloor = salaryFloor
+        }
+        if let dateFreshnessDays, dateFreshnessDays > 0 {
+            criteria.dateFreshnessDays = dateFreshnessDays
+        }
+
+        return criteria
+    }
+
+    private static func mapRemote(_ raw: String) -> RemoteType? {
+        switch raw.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "") {
+        case "remote": return .remote
+        case "hybrid": return .hybrid
+        case "onsite", "onsiteonly": return .onsite
+        case "unknown", "any": return .unknown
+        default: return nil
+        }
+    }
+
+    private static func mapEmployment(_ raw: String) -> EmploymentType? {
+        switch raw.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "") {
+        case "fulltime": return .fullTime
+        case "parttime": return .partTime
+        case "contract": return .contract
+        case "temporary", "temp": return .temporary
+        case "internship", "intern": return .internship
+        default: return nil
+        }
+    }
+
+    private static func mapExperience(_ raw: String) -> ExperienceLevel? {
+        switch raw.lowercased().replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "") {
+        case "entry", "entrylevel": return .entry
+        case "junior": return .junior
+        case "mid", "midlevel": return .mid
+        case "senior": return .senior
+        default: return nil
+        }
+    }
+}
+
+enum SearchCriteriaInterpretationSchema {
+    /// JSON Schema fragment sent to OpenAI `response_format.json_schema`.
+    static let jsonSchemaObject: [String: Any] = [
+        "type": "object",
+        "additionalProperties": false,
+        "properties": [
+            "jobTitle": ["type": ["string", "null"]],
+            "keywords": ["type": ["string", "null"]],
+            "exactPhrase": ["type": ["boolean", "null"]],
+            "excludedKeywords": ["type": ["string", "null"]],
+            "country": ["type": ["string", "null"]],
+            "city": ["type": ["string", "null"]],
+            "remoteTypes": [
+                "type": ["array", "null"],
+                "items": ["type": "string", "enum": ["remote", "hybrid", "onsite", "unknown"]]
+            ],
+            "employmentTypes": [
+                "type": ["array", "null"],
+                "items": ["type": "string", "enum": ["fullTime", "partTime", "contract", "temporary", "internship"]]
+            ],
+            "experienceLevels": [
+                "type": ["array", "null"],
+                "items": ["type": "string", "enum": ["entry", "junior", "mid", "senior"]]
+            ],
+            "salaryFloor": ["type": ["number", "null"]],
+            "dateFreshnessDays": ["type": ["integer", "null"]],
+            "confidenceNote": ["type": ["string", "null"]]
+        ],
+        "required": [
+            "jobTitle",
+            "keywords",
+            "exactPhrase",
+            "excludedKeywords",
+            "country",
+            "city",
+            "remoteTypes",
+            "employmentTypes",
+            "experienceLevels",
+            "salaryFloor",
+            "dateFreshnessDays",
+            "confidenceNote"
+        ]
+    ]
+}

+ 55 - 0
indeed_ai_job_search/Services/OpenAI/SecretsProvider.swift

@@ -0,0 +1,55 @@
+//
+//  SecretsProvider.swift
+//  indeed_ai_job_search
+//
+//  Reads build-injected obfuscated secrets. Values remain extractable from the
+//  app binary — document this in Privacy copy. Never log the resolved key.
+//
+
+import Foundation
+
+enum SecretsProvider {
+    private static let xorSalt = Array("IndeedAIJobsPhase3".utf8)
+
+    /// Resolved OpenAI API key, or `nil` when not configured at build time.
+    static var openAIAPIKey: String? {
+        guard let obfuscated = Bundle.main.object(forInfoDictionaryKey: "OpenAIAPIKeyObfuscated") as? String else {
+            return nil
+        }
+        let trimmed = obfuscated.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return nil }
+        return deobfuscate(trimmed)
+    }
+
+    static var isOpenAIConfigured: Bool {
+        guard let key = openAIAPIKey else { return false }
+        return key.hasPrefix("sk-")
+    }
+
+    /// Build-time / Info.plist feature flag. Defaults to enabled when missing.
+    static var isAIInterpretationEnabledInBundle: Bool {
+        guard let raw = Bundle.main.object(forInfoDictionaryKey: "FeatureAIInterpretationEnabled") as? String else {
+            return true
+        }
+        let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+        if normalized.isEmpty { return true }
+        return normalized == "YES" || normalized == "TRUE" || normalized == "1"
+    }
+
+    static func deobfuscate(_ base64: String) -> String? {
+        guard let data = Data(base64Encoded: base64) else { return nil }
+        let decoded = data.enumerated().map { index, byte in
+            byte ^ xorSalt[index % xorSalt.count]
+        }
+        return String(bytes: decoded, encoding: .utf8)
+    }
+
+    /// Dev helper — produce a value for `OPENAI_API_KEY_OBFUSCATED` in Secrets.xcconfig.
+    static func obfuscateForConfig(_ plaintext: String) -> String {
+        let bytes = Array(plaintext.utf8)
+        let mixed = bytes.enumerated().map { index, byte in
+            byte ^ xorSalt[index % xorSalt.count]
+        }
+        return Data(mixed).base64EncodedString()
+    }
+}

+ 60 - 0
indeed_ai_job_searchTests/SearchCriteriaInterpretationTests.swift

@@ -0,0 +1,60 @@
+//
+//  SearchCriteriaInterpretationTests.swift
+//  indeed_ai_job_searchTests
+//
+
+import XCTest
+@testable import indeed_ai_job_search
+
+final class SearchCriteriaInterpretationTests: XCTestCase {
+    func testDecodesStructuredCriteriaJSON() throws {
+        let json = """
+        {
+          "jobTitle": "iOS Developer",
+          "keywords": "SwiftUI",
+          "exactPhrase": false,
+          "excludedKeywords": "Flutter",
+          "country": "Pakistan",
+          "city": "Lahore",
+          "remoteTypes": ["remote", "hybrid"],
+          "employmentTypes": ["fullTime"],
+          "experienceLevels": ["senior"],
+          "salaryFloor": 2000000,
+          "dateFreshnessDays": 7,
+          "confidenceNote": "Clear remote senior iOS intent."
+        }
+        """
+        let dto = try JSONDecoder().decode(
+            SearchCriteriaInterpretationDTO.self,
+            from: Data(json.utf8)
+        )
+        let criteria = dto.toSearchCriteria()
+
+        XCTAssertEqual(criteria.jobTitle, "iOS Developer")
+        XCTAssertEqual(criteria.keywords, "SwiftUI")
+        XCTAssertEqual(criteria.excludedKeywords, "Flutter")
+        XCTAssertEqual(criteria.country, "Pakistan")
+        XCTAssertEqual(criteria.city, "Lahore")
+        XCTAssertEqual(criteria.remoteTypes, Set([.remote, .hybrid]))
+        XCTAssertEqual(criteria.employmentTypes, Set([.fullTime]))
+        XCTAssertEqual(criteria.experienceLevels, Set([.senior]))
+        XCTAssertEqual(criteria.salaryFloor, 2_000_000)
+        XCTAssertEqual(criteria.dateFreshnessDays, 7)
+        XCTAssertEqual(dto.confidenceNote, "Clear remote senior iOS intent.")
+    }
+
+    func testMapsAlternateRemoteSpelling() {
+        let dto = SearchCriteriaInterpretationDTO(
+            remoteTypes: ["on-site", "Remote"]
+        )
+        let criteria = dto.toSearchCriteria()
+        XCTAssertEqual(criteria.remoteTypes, Set([.onsite, .remote]))
+    }
+
+    func testObfuscationRoundTrip() {
+        let sample = "sk-proj-test-key-not-real"
+        let obfuscated = SecretsProvider.obfuscateForConfig(sample)
+        let restored = SecretsProvider.deobfuscate(obfuscated)
+        XCTAssertEqual(restored, sample)
+    }
+}