|
|
@@ -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 {
|