소스 검색

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 2 달 전
부모
커밋
370373d91b
1개의 변경된 파일104개의 추가작업 그리고 20개의 파일을 삭제
  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 {
     }
 
     private func resolvedSearchQuery(for prompt: String) -> String {
-        guard isContinuationPrompt(prompt) else { return prompt }
-        for message in chatMessages.reversed() where message.role == "user" {
-            let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
-            if !candidate.isEmpty, !isContinuationPrompt(candidate) {
-                return candidate
-            }
+        let anchor = anchorUserJobQuery(excludingLatestUserMessage: prompt)
+        if isContinuationPrompt(prompt), !isRefinementPrompt(prompt) {
+            if let anchor { return anchor }
+            return prompt
+        }
+        if isRefinementPrompt(prompt), let anchor {
+            return "\(anchor). User follow-up (apply on top of the same search topic): \(prompt)"
         }
         return prompt
     }
 
+    /// First prior user message that looks like an original job query (skips short continuations and refinements so follow-ups keep a stable topic anchor).
+    private func anchorUserJobQuery(excludingLatestUserMessage latest: String) -> String? {
+        let prior = Array(chatMessages.dropLast())
+        for message in prior.reversed() where message.role == "user" {
+            let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !candidate.isEmpty, candidate != latest else { continue }
+            if isContinuationPrompt(candidate) { continue }
+            if isRefinementPrompt(candidate) { continue }
+            return candidate
+        }
+        return nil
+    }
+
     private func isContinuationPrompt(_ prompt: String) -> Bool {
         let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         let continuationPhrases: Set<String> = [
@@ -1272,6 +1286,35 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         return normalized.contains("more search") || normalized.contains("more job")
     }
 
+    /// Follow-ups that narrow, re-rank, or re-frame results rather than starting a brand-new role search.
+    /// 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.
+    private func isRefinementPrompt(_ prompt: String) -> Bool {
+        let n = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        if n.isEmpty { return false }
+        let strongPhrases = [
+            "higher pay", "high pay", "better pay", "more pay", "top pay", "best pay",
+            "higher salary", "better salary", "more salary", "pay rate", "hourly rate",
+            "paid more", "paying more", "earn more", "better paid", "paying better",
+            "work from home", "in office", "in-office", "on-site only", "remote only",
+            "hybrid only", "onsite only", "visa sponsorship", "h1b",
+            "entry level", "entry-level", "mid level", "mid-level", "full time", "full-time",
+            "part time", "part-time",
+            "closer to", "nearer", "different city", "different state", "relocate",
+            "filter", "only show", "just show", "exclude", "without", "sort by", "rank by",
+            "cheaper", "lower pay", "less travel",
+            "better benefits", "equity", "bonus", "overtime",
+            "get me the jobs", "show me the jobs", "give me the jobs", "narrow", "refine"
+        ]
+        if strongPhrases.contains(where: { n.contains($0) }) { return true }
+        if n.hasPrefix("only ") || n.hasPrefix("just ") { return true }
+        guard !lastSearchResults.isEmpty, n.count <= 52 else { return false }
+        let softAfterResults = [
+            "remote", "hybrid", "onsite", "on-site", "senior", "junior", "staff", "lead",
+            "principal", "intern", "contract", "location"
+        ]
+        return softAfterResults.contains(where: { n.contains($0) })
+    }
+
     func controlTextDidBeginEditing(_ obj: Notification) {
         applySearchFieldInsertionPoint(obj.object)
         if (obj.object as? NSTextField) === jobKeywordsField {
@@ -1721,25 +1764,21 @@ private final class OpenAIJobSearchService {
         }
 
         let instructions = """
-        Continue this same job-search conversation. Use prior context when useful, and prioritize the latest user query.
+        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).
 
         Conversation context:
         \(contextBlock)
 
         Latest user query: "\(query)"
 
-        Search the web for currently available jobs related to the latest query (and relevant context above).
-        Return ONLY strict JSON that matches this schema:
-        {
-          "jobs": [
-            {
-              "title": "string",
-              "description": "string",
-              "url": "https://..."
-            }
-          ]
-        }
-        Keep descriptions concise (1 sentence), include real apply/listing URLs when available, and return up to 8 jobs.
+        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.
+
+        CRITICAL OUTPUT RULES:
+        - Reply with NOTHING except one JSON object. No markdown, no code fences, no prose before or after, no labels like "Here is the JSON".
+        - The JSON must match exactly this shape (lowercase key "jobs"):
+        {"jobs":[{"title":"...","description":"...","url":"https://..."}]}
+        - If you find no suitable listings, return {"jobs":[]} — still valid JSON only.
+        Keep each description to one sentence; prefer real listing URLs; at most 8 jobs.
         """
         let payload = OpenAIResponsesRequest(
             model: "gpt-4o-mini",
@@ -1887,6 +1926,14 @@ private final class OpenAIJobSearchService {
         if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) {
             return listings
         }
+        if let obj = try? JSONSerialization.jsonObject(with: jsonData, options: []) {
+            if let dict = obj as? [String: Any], let jobs = jobListings(fromFlexibleJSONObject: dict) {
+                return jobs
+            }
+            if let arr = obj as? [[String: Any]], let jobs = jobListings(fromFlexibleJobArray: arr) {
+                return jobs
+            }
+        }
         throw NSError(
             domain: "OpenAIJobSearchService",
             code: 10,
@@ -1894,6 +1941,43 @@ private final class OpenAIJobSearchService {
         )
     }
 
+    private static func jobListings(fromFlexibleJSONObject dict: [String: Any]) -> [JobListing]? {
+        for (key, value) in dict {
+            guard key.caseInsensitiveCompare("jobs") == .orderedSame, let arr = value as? [[String: Any]] else { continue }
+            if let jobs = jobListings(fromFlexibleJobArray: arr) { return jobs }
+        }
+        for wrapKey in ["data", "result", "results", "payload"] {
+            if let inner = dict[wrapKey] as? [String: Any], let nested = jobListings(fromFlexibleJSONObject: inner) {
+                return nested
+            }
+        }
+        return nil
+    }
+
+    private static func jobListings(fromFlexibleJobArray jobs: [[String: Any]]) -> [JobListing]? {
+        var out: [JobListing] = []
+        for item in jobs {
+            guard let title = firstString(valuesForKeys: ["title", "job_title", "name", "position"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
+                  !title.isEmpty,
+                  let desc = firstString(valuesForKeys: ["description", "snippet", "summary", "desc"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
+                  !desc.isEmpty else { continue }
+            let urlRaw = firstString(valuesForKeys: ["url", "link", "apply_url", "job_url"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines)
+            let url: String? = (urlRaw?.isEmpty == true) ? nil : urlRaw
+            out.append(JobListing(title: title, description: desc, url: url))
+        }
+        return out.isEmpty ? nil : out
+    }
+
+    private static func firstString(valuesForKeys keys: [String], in dict: [String: Any]) -> String? {
+        for wanted in keys {
+            for (dk, dv) in dict {
+                guard dk.caseInsensitiveCompare(wanted) == .orderedSame, let s = dv as? String else { continue }
+                return s
+            }
+        }
+        return nil
+    }
+
     private static func stripMarkdownCodeFence(_ text: String) -> String {
         var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
         guard s.hasPrefix("```") else { return s }
@@ -1946,7 +2030,7 @@ private final class OpenAIJobSearchService {
     /// Prefers the JSON object that contains a `"jobs"` key so prose before/after the payload does not confuse the decoder.
     private static func extractJobJSONObjectString(from text: String) -> String? {
         let s = stripMarkdownCodeFence(text)
-        guard let jobsRange = s.range(of: "\"jobs\"") else { return nil }
+        guard let jobsRange = s.range(of: "\"jobs\"", options: .caseInsensitive) else { return nil }
         let head = s[..<jobsRange.lowerBound]
         guard let open = head.lastIndex(of: "{") else { return nil }
         return balancedJSONObject(from: open, in: s)