Просмотр исходного кода

Fix job search by enforcing JSON schema on Responses API

Use text.format json_schema with strict mode so web search replies
always include a jobs array. Split developer instructions from user
input, normalize empty URLs, and try direct JSON decode when the
model returns a single object.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 недель назад: 3
Родитель
Сommit
e117b4c19d
1 измененных файлов с 129 добавлено и 15 удалено
  1. 129 15
      App for Indeed/Views/DashboardView.swift

+ 129 - 15
App for Indeed/Views/DashboardView.swift

@@ -2034,7 +2034,19 @@ private final class OpenAIJobSearchService {
2034 2034
             contextBlock = recentContext
2035 2035
         }
2036 2036
 
2037
-        let instructions = """
2037
+        let developerInstructions = """
2038
+        You are the job-search backend for a desktop app. Always use web search to find currently posted jobs that match the user's request.
2039
+
2040
+        Your final assistant message must be JSON that strictly matches the configured response schema (one object with a "jobs" array). Do not add markdown, code fences, or conversational prose outside that JSON.
2041
+
2042
+        Each job entry needs a title, a single-sentence description, and a "url" string. Use a real listing or apply URL when available; use an empty string for "url" when none is known.
2043
+
2044
+        Return at most \(jobLimit) distinct listings. If the conversation context already lists jobs, do not repeat the same titles or URLs when the user asks for more—it is fine to return fewer than \(jobLimit) new results.
2045
+
2046
+        Full sentences such as "looking for an AI developer job" are still job queries: always populate "jobs" from web search rather than answering with chatty text alone.
2047
+        """
2048
+
2049
+        let userInput = """
2038 2050
         Continue this same job-search conversation. Use prior context when useful. The line "Latest user query" is the primary task; earlier USER/ASSISTANT lines are context (previous role, location, or results).
2039 2051
 
2040 2052
         Conversation context:
@@ -2042,20 +2054,15 @@ private final class OpenAIJobSearchService {
2042 2054
 
2043 2055
         Latest user query: "\(query)"
2044 2056
 
2045
-        Use web search to find currently posted jobs that satisfy this query. If the user refines pay, seniority, work location, or similar, run a new search that applies those constraints to the same career topic as in the context.
2046
-
2047
-        CRITICAL OUTPUT RULES:
2048
-        - Reply with NOTHING except one JSON object. No markdown, no code fences, no prose before or after, no labels like "Here is the JSON".
2049
-        - The JSON must match exactly this shape (lowercase key "jobs"):
2050
-        {"jobs":[{"title":"...","description":"...","url":"https://..."}]}
2051
-        - If you find no suitable listings, return {"jobs":[]} — still valid JSON only.
2052
-        Return up to \(jobLimit) jobs in the jobs array (fewer only if the web has no additional distinct matches). Do not repeat titles or URLs already implied in the conversation context above.
2053
-        Keep each description to one sentence; prefer real listing URLs.
2057
+        If the user refines pay, seniority, work location, or similar, run a new search that applies those constraints to the same career topic as in the context.
2054 2058
         """
2055
-        let payload = OpenAIResponsesRequest(
2059
+
2060
+        let payload = OpenAIJobSearchAPIRequest.jobSearchPayload(
2056 2061
             model: "gpt-4o-mini",
2057
-            input: instructions,
2058
-            tools: [OpenAIResponsesTool(type: "web_search_preview")]
2062
+            instructions: developerInstructions,
2063
+            input: userInput,
2064
+            tools: [OpenAIResponsesTool(type: "web_search_preview")],
2065
+            jobLimit: jobLimit
2059 2066
         )
2060 2067
 
2061 2068
         do {
@@ -2104,7 +2111,7 @@ private final class OpenAIJobSearchService {
2104 2111
                         userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
2105 2112
                     )
2106 2113
                 }
2107
-                let jobs = try Self.parseJobListings(fromModelText: trimmed)
2114
+                let jobs = try Self.parseJobListings(fromModelText: trimmed).map(Self.normalizedJobListing)
2108 2115
                 completion(.success(JobSearchOutput(jobs: jobs)))
2109 2116
             } catch {
2110 2117
                 completion(.failure(error))
@@ -2189,7 +2196,20 @@ private final class OpenAIJobSearchService {
2189 2196
         }
2190 2197
     }
2191 2198
 
2199
+    private static func normalizedJobListing(_ job: JobListing) -> JobListing {
2200
+        let trimmedURL = job.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
2201
+        if trimmedURL.isEmpty {
2202
+            return JobListing(title: job.title, description: job.description, url: nil)
2203
+        }
2204
+        return JobListing(title: job.title, description: job.description, url: trimmedURL)
2205
+    }
2206
+
2192 2207
     private static func parseJobListings(fromModelText text: String) throws -> [JobListing] {
2208
+        let stripped = text.trimmingCharacters(in: .whitespacesAndNewlines)
2209
+        if stripped.hasPrefix("{"), let directData = stripped.data(using: .utf8),
2210
+           let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: directData) {
2211
+            return payload.jobs
2212
+        }
2193 2213
         let jsonString = extractJobJSONObjectString(from: text) ?? extractJSONObject(from: text)
2194 2214
         let jsonData = Data(jsonString.utf8)
2195 2215
         if let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData) {
@@ -2323,10 +2343,104 @@ private final class OpenAIJobSearchService {
2323 2343
     }
2324 2344
 }
2325 2345
 
2326
-private struct OpenAIResponsesRequest: Codable {
2346
+/// Responses API request with structured JSON output so web-search replies cannot omit the `jobs` schema.
2347
+private struct OpenAIJobSearchAPIRequest: Encodable {
2327 2348
     let model: String
2349
+    let instructions: String
2328 2350
     let input: String
2329 2351
     let tools: [OpenAIResponsesTool]
2352
+    let text: OpenAITextOutputConfig
2353
+
2354
+    static func jobSearchPayload(
2355
+        model: String,
2356
+        instructions: String,
2357
+        input: String,
2358
+        tools: [OpenAIResponsesTool],
2359
+        jobLimit: Int
2360
+    ) -> OpenAIJobSearchAPIRequest {
2361
+        let itemProperties = OpenAIJobSearchJobItemProperties(
2362
+            title: OpenAIJSONSchemaStringField(type: "string", description: "Job title as shown on the listing."),
2363
+            description: OpenAIJSONSchemaStringField(type: "string", description: "One concise sentence summarizing the role."),
2364
+            url: OpenAIJSONSchemaStringField(type: "string", description: "Direct listing or apply URL; use an empty string when unknown.")
2365
+        )
2366
+        let itemSchema = OpenAIJobSearchJobItemSchema(
2367
+            type: "object",
2368
+            properties: itemProperties,
2369
+            required: ["title", "description", "url"],
2370
+            additionalProperties: false
2371
+        )
2372
+        let jobsProperty = OpenAIJobSearchJobsArrayProperty(
2373
+            type: "array",
2374
+            description: "Up to \(jobLimit) jobs from live web search; use an empty array if none are found.",
2375
+            items: itemSchema
2376
+        )
2377
+        let rootProperties = OpenAIJobSearchRootProperties(jobs: jobsProperty)
2378
+        let rootSchema = OpenAIJobSearchRootSchema(
2379
+            type: "object",
2380
+            properties: rootProperties,
2381
+            required: ["jobs"],
2382
+            additionalProperties: false
2383
+        )
2384
+        let format = OpenAIJobSearchResponseJSONSchemaFormat(
2385
+            type: "json_schema",
2386
+            name: "job_search_results",
2387
+            strict: true,
2388
+            schema: rootSchema
2389
+        )
2390
+        return OpenAIJobSearchAPIRequest(
2391
+            model: model,
2392
+            instructions: instructions,
2393
+            input: input,
2394
+            tools: tools,
2395
+            text: OpenAITextOutputConfig(format: format)
2396
+        )
2397
+    }
2398
+}
2399
+
2400
+private struct OpenAITextOutputConfig: Encodable {
2401
+    let format: OpenAIJobSearchResponseJSONSchemaFormat
2402
+}
2403
+
2404
+private struct OpenAIJobSearchResponseJSONSchemaFormat: Encodable {
2405
+    let type: String
2406
+    let name: String
2407
+    let strict: Bool
2408
+    let schema: OpenAIJobSearchRootSchema
2409
+}
2410
+
2411
+private struct OpenAIJobSearchRootSchema: Encodable {
2412
+    let type: String
2413
+    let properties: OpenAIJobSearchRootProperties
2414
+    let required: [String]
2415
+    let additionalProperties: Bool
2416
+}
2417
+
2418
+private struct OpenAIJobSearchRootProperties: Encodable {
2419
+    let jobs: OpenAIJobSearchJobsArrayProperty
2420
+}
2421
+
2422
+private struct OpenAIJobSearchJobsArrayProperty: Encodable {
2423
+    let type: String
2424
+    let description: String
2425
+    let items: OpenAIJobSearchJobItemSchema
2426
+}
2427
+
2428
+private struct OpenAIJobSearchJobItemSchema: Encodable {
2429
+    let type: String
2430
+    let properties: OpenAIJobSearchJobItemProperties
2431
+    let required: [String]
2432
+    let additionalProperties: Bool
2433
+}
2434
+
2435
+private struct OpenAIJobSearchJobItemProperties: Encodable {
2436
+    let title: OpenAIJSONSchemaStringField
2437
+    let description: OpenAIJSONSchemaStringField
2438
+    let url: OpenAIJSONSchemaStringField
2439
+}
2440
+
2441
+private struct OpenAIJSONSchemaStringField: Encodable {
2442
+    let type: String
2443
+    let description: String
2330 2444
 }
2331 2445
 
2332 2446
 private struct OpenAIResponsesTool: Codable {