Selaa lähdekoodia

Improve job-search follow-ups and JSON parsing

Detect refinement prompts (pay, location mode, short follow-ups after
results) and merge them with the anchor user query for the API.
Tighten Responses instructions so the model returns raw JSON only.
Add lenient decoding for jobs arrays (key casing, wrappers, field
aliases) when strict Codable fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 viikkoa sitten
vanhempi
commit
370373d91b
1 muutettua tiedostoa jossa 104 lisäystä ja 20 poistoa
  1. 104 20
      App for Indeed/Views/DashboardView.swift

+ 104 - 20
App for Indeed/Views/DashboardView.swift

@@ -1243,16 +1243,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1243 1243
     }
1244 1244
 
1245 1245
     private func resolvedSearchQuery(for prompt: String) -> String {
1246
-        guard isContinuationPrompt(prompt) else { return prompt }
1247
-        for message in chatMessages.reversed() where message.role == "user" {
1248
-            let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
1249
-            if !candidate.isEmpty, !isContinuationPrompt(candidate) {
1250
-                return candidate
1251
-            }
1246
+        let anchor = anchorUserJobQuery(excludingLatestUserMessage: prompt)
1247
+        if isContinuationPrompt(prompt), !isRefinementPrompt(prompt) {
1248
+            if let anchor { return anchor }
1249
+            return prompt
1250
+        }
1251
+        if isRefinementPrompt(prompt), let anchor {
1252
+            return "\(anchor). User follow-up (apply on top of the same search topic): \(prompt)"
1252 1253
         }
1253 1254
         return prompt
1254 1255
     }
1255 1256
 
1257
+    /// First prior user message that looks like an original job query (skips short continuations and refinements so follow-ups keep a stable topic anchor).
1258
+    private func anchorUserJobQuery(excludingLatestUserMessage latest: String) -> String? {
1259
+        let prior = Array(chatMessages.dropLast())
1260
+        for message in prior.reversed() where message.role == "user" {
1261
+            let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
1262
+            guard !candidate.isEmpty, candidate != latest else { continue }
1263
+            if isContinuationPrompt(candidate) { continue }
1264
+            if isRefinementPrompt(candidate) { continue }
1265
+            return candidate
1266
+        }
1267
+        return nil
1268
+    }
1269
+
1256 1270
     private func isContinuationPrompt(_ prompt: String) -> Bool {
1257 1271
         let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1258 1272
         let continuationPhrases: Set<String> = [
@@ -1272,6 +1286,35 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1272 1286
         return normalized.contains("more search") || normalized.contains("more job")
1273 1287
     }
1274 1288
 
1289
+    /// Follow-ups that narrow, re-rank, or re-frame results rather than starting a brand-new role search.
1290
+    /// Strong phrases always count. Single-word cues (e.g. "remote") only count after we already showed results, so first searches like "Senior iOS remote" stay anchored as primary queries.
1291
+    private func isRefinementPrompt(_ prompt: String) -> Bool {
1292
+        let n = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1293
+        if n.isEmpty { return false }
1294
+        let strongPhrases = [
1295
+            "higher pay", "high pay", "better pay", "more pay", "top pay", "best pay",
1296
+            "higher salary", "better salary", "more salary", "pay rate", "hourly rate",
1297
+            "paid more", "paying more", "earn more", "better paid", "paying better",
1298
+            "work from home", "in office", "in-office", "on-site only", "remote only",
1299
+            "hybrid only", "onsite only", "visa sponsorship", "h1b",
1300
+            "entry level", "entry-level", "mid level", "mid-level", "full time", "full-time",
1301
+            "part time", "part-time",
1302
+            "closer to", "nearer", "different city", "different state", "relocate",
1303
+            "filter", "only show", "just show", "exclude", "without", "sort by", "rank by",
1304
+            "cheaper", "lower pay", "less travel",
1305
+            "better benefits", "equity", "bonus", "overtime",
1306
+            "get me the jobs", "show me the jobs", "give me the jobs", "narrow", "refine"
1307
+        ]
1308
+        if strongPhrases.contains(where: { n.contains($0) }) { return true }
1309
+        if n.hasPrefix("only ") || n.hasPrefix("just ") { return true }
1310
+        guard !lastSearchResults.isEmpty, n.count <= 52 else { return false }
1311
+        let softAfterResults = [
1312
+            "remote", "hybrid", "onsite", "on-site", "senior", "junior", "staff", "lead",
1313
+            "principal", "intern", "contract", "location"
1314
+        ]
1315
+        return softAfterResults.contains(where: { n.contains($0) })
1316
+    }
1317
+
1275 1318
     func controlTextDidBeginEditing(_ obj: Notification) {
1276 1319
         applySearchFieldInsertionPoint(obj.object)
1277 1320
         if (obj.object as? NSTextField) === jobKeywordsField {
@@ -1721,25 +1764,21 @@ private final class OpenAIJobSearchService {
1721 1764
         }
1722 1765
 
1723 1766
         let instructions = """
1724
-        Continue this same job-search conversation. Use prior context when useful, and prioritize the latest user query.
1767
+        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).
1725 1768
 
1726 1769
         Conversation context:
1727 1770
         \(contextBlock)
1728 1771
 
1729 1772
         Latest user query: "\(query)"
1730 1773
 
1731
-        Search the web for currently available jobs related to the latest query (and relevant context above).
1732
-        Return ONLY strict JSON that matches this schema:
1733
-        {
1734
-          "jobs": [
1735
-            {
1736
-              "title": "string",
1737
-              "description": "string",
1738
-              "url": "https://..."
1739
-            }
1740
-          ]
1741
-        }
1742
-        Keep descriptions concise (1 sentence), include real apply/listing URLs when available, and return up to 8 jobs.
1774
+        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.
1775
+
1776
+        CRITICAL OUTPUT RULES:
1777
+        - Reply with NOTHING except one JSON object. No markdown, no code fences, no prose before or after, no labels like "Here is the JSON".
1778
+        - The JSON must match exactly this shape (lowercase key "jobs"):
1779
+        {"jobs":[{"title":"...","description":"...","url":"https://..."}]}
1780
+        - If you find no suitable listings, return {"jobs":[]} — still valid JSON only.
1781
+        Keep each description to one sentence; prefer real listing URLs; at most 8 jobs.
1743 1782
         """
1744 1783
         let payload = OpenAIResponsesRequest(
1745 1784
             model: "gpt-4o-mini",
@@ -1887,6 +1926,14 @@ private final class OpenAIJobSearchService {
1887 1926
         if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) {
1888 1927
             return listings
1889 1928
         }
1929
+        if let obj = try? JSONSerialization.jsonObject(with: jsonData, options: []) {
1930
+            if let dict = obj as? [String: Any], let jobs = jobListings(fromFlexibleJSONObject: dict) {
1931
+                return jobs
1932
+            }
1933
+            if let arr = obj as? [[String: Any]], let jobs = jobListings(fromFlexibleJobArray: arr) {
1934
+                return jobs
1935
+            }
1936
+        }
1890 1937
         throw NSError(
1891 1938
             domain: "OpenAIJobSearchService",
1892 1939
             code: 10,
@@ -1894,6 +1941,43 @@ private final class OpenAIJobSearchService {
1894 1941
         )
1895 1942
     }
1896 1943
 
1944
+    private static func jobListings(fromFlexibleJSONObject dict: [String: Any]) -> [JobListing]? {
1945
+        for (key, value) in dict {
1946
+            guard key.caseInsensitiveCompare("jobs") == .orderedSame, let arr = value as? [[String: Any]] else { continue }
1947
+            if let jobs = jobListings(fromFlexibleJobArray: arr) { return jobs }
1948
+        }
1949
+        for wrapKey in ["data", "result", "results", "payload"] {
1950
+            if let inner = dict[wrapKey] as? [String: Any], let nested = jobListings(fromFlexibleJSONObject: inner) {
1951
+                return nested
1952
+            }
1953
+        }
1954
+        return nil
1955
+    }
1956
+
1957
+    private static func jobListings(fromFlexibleJobArray jobs: [[String: Any]]) -> [JobListing]? {
1958
+        var out: [JobListing] = []
1959
+        for item in jobs {
1960
+            guard let title = firstString(valuesForKeys: ["title", "job_title", "name", "position"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
1961
+                  !title.isEmpty,
1962
+                  let desc = firstString(valuesForKeys: ["description", "snippet", "summary", "desc"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
1963
+                  !desc.isEmpty else { continue }
1964
+            let urlRaw = firstString(valuesForKeys: ["url", "link", "apply_url", "job_url"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines)
1965
+            let url: String? = (urlRaw?.isEmpty == true) ? nil : urlRaw
1966
+            out.append(JobListing(title: title, description: desc, url: url))
1967
+        }
1968
+        return out.isEmpty ? nil : out
1969
+    }
1970
+
1971
+    private static func firstString(valuesForKeys keys: [String], in dict: [String: Any]) -> String? {
1972
+        for wanted in keys {
1973
+            for (dk, dv) in dict {
1974
+                guard dk.caseInsensitiveCompare(wanted) == .orderedSame, let s = dv as? String else { continue }
1975
+                return s
1976
+            }
1977
+        }
1978
+        return nil
1979
+    }
1980
+
1897 1981
     private static func stripMarkdownCodeFence(_ text: String) -> String {
1898 1982
         var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
1899 1983
         guard s.hasPrefix("```") else { return s }
@@ -1946,7 +2030,7 @@ private final class OpenAIJobSearchService {
1946 2030
     /// Prefers the JSON object that contains a `"jobs"` key so prose before/after the payload does not confuse the decoder.
1947 2031
     private static func extractJobJSONObjectString(from text: String) -> String? {
1948 2032
         let s = stripMarkdownCodeFence(text)
1949
-        guard let jobsRange = s.range(of: "\"jobs\"") else { return nil }
2033
+        guard let jobsRange = s.range(of: "\"jobs\"", options: .caseInsensitive) else { return nil }
1950 2034
         let head = s[..<jobsRange.lowerBound]
1951 2035
         guard let open = head.lastIndex(of: "{") else { return nil }
1952 2036
         return balancedJSONObject(from: open, in: s)