Ver código fonte

Harden OpenAI job search response parsing and chat error copy

Parse /v1/responses with JSONSerialization to collect output_text across
message items, handle failed/incomplete statuses, and extract job JSON
from fenced or prose-wrapped payloads. Surface non-network failures with
clearer chat status text.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 semanas atrás
pai
commit
dc15c72e2d
1 arquivos alterados com 168 adições e 84 exclusões
  1. 168 84
      App for Indeed/Views/DashboardView.swift

+ 168 - 84
App for Indeed/Views/DashboardView.swift

@@ -1206,7 +1206,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1206 1206
                     self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
1207 1207
                 case .failure(let error):
1208 1208
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
1209
-                    self.chatStatusLabel.stringValue = "Could not reach API. Try again."
1209
+                    if error is URLError {
1210
+                        self.chatStatusLabel.stringValue = "Could not reach API. Try again."
1211
+                    } else {
1212
+                        self.chatStatusLabel.stringValue = "Search did not finish. Try again."
1213
+                    }
1210 1214
                 }
1211 1215
             }
1212 1216
         }
@@ -1780,117 +1784,197 @@ private final class OpenAIJobSearchService {
1780 1784
                 return
1781 1785
             }
1782 1786
             do {
1783
-                let decoded = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: data)
1784
-                let text = decoded.bestText.trimmingCharacters(in: .whitespacesAndNewlines)
1785
-                guard !text.isEmpty else {
1787
+                let modelText = try Self.extractModelTextFromResponsesBody(data)
1788
+                let trimmed = modelText.trimmingCharacters(in: .whitespacesAndNewlines)
1789
+                guard !trimmed.isEmpty else {
1786 1790
                     throw NSError(
1787 1791
                         domain: "OpenAIJobSearchService",
1788 1792
                         code: 4,
1789 1793
                         userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
1790 1794
                     )
1791 1795
                 }
1792
-                let cleanedText = Self.extractJSONObject(from: text)
1793
-                let jsonData = Data(cleanedText.utf8)
1794
-                let jobsPayload = try JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData)
1795
-                completion(.success(JobSearchOutput(jobs: jobsPayload.jobs)))
1796
+                let jobs = try Self.parseJobListings(fromModelText: trimmed)
1797
+                completion(.success(JobSearchOutput(jobs: jobs)))
1796 1798
             } catch {
1797
-                let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
1798
-                let message = "The API response could not be parsed as job JSON. Raw response: \(rawBody.prefix(600))"
1799
-                completion(.failure(NSError(
1800
-                    domain: "OpenAIJobSearchService",
1801
-                    code: 5,
1802
-                    userInfo: [NSLocalizedDescriptionKey: message]
1803
-                )))
1799
+                completion(.failure(error))
1804 1800
             }
1805 1801
         }.resume()
1806 1802
     }
1807 1803
 
1808
-    private static func extractJSONObject(from text: String) -> String {
1809
-        if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
1810
-            return String(text[range])
1804
+    /// Walks the `/v1/responses` JSON without strict Codable so tool calls, refusals, and future output item types do not break decoding. Collects only `output_text` segments from assistant `message` items (and any other output items that expose a `content` array).
1805
+    private static func extractModelTextFromResponsesBody(_ data: Data) throws -> String {
1806
+        let rootObject: Any
1807
+        do {
1808
+            rootObject = try JSONSerialization.jsonObject(with: data, options: [])
1809
+        } catch {
1810
+            throw NSError(
1811
+                domain: "OpenAIJobSearchService",
1812
+                code: 5,
1813
+                userInfo: [NSLocalizedDescriptionKey: "The job search service returned data that was not valid JSON."]
1814
+            )
1811 1815
         }
1812
-        return text
1816
+        guard let root = rootObject as? [String: Any] else {
1817
+            throw NSError(
1818
+                domain: "OpenAIJobSearchService",
1819
+                code: 5,
1820
+                userInfo: [NSLocalizedDescriptionKey: "Unexpected response shape from the job search service."]
1821
+            )
1822
+        }
1823
+        if let status = root["status"] as? String {
1824
+            if status == "failed" {
1825
+                let message = (root["error"] as? [String: Any])?["message"] as? String ?? "The search request failed."
1826
+                throw NSError(domain: "OpenAIJobSearchService", code: 7, userInfo: [NSLocalizedDescriptionKey: message])
1827
+            }
1828
+            if status == "incomplete",
1829
+               let details = root["incomplete_details"] as? [String: Any],
1830
+               let reason = details["reason"] as? String {
1831
+                throw NSError(
1832
+                    domain: "OpenAIJobSearchService",
1833
+                    code: 8,
1834
+                    userInfo: [NSLocalizedDescriptionKey: "Search stopped early (\(reason)). Try a simpler query or try again."]
1835
+                )
1836
+            }
1837
+        }
1838
+        if let direct = root["output_text"] as? String {
1839
+            let trimmed = direct.trimmingCharacters(in: .whitespacesAndNewlines)
1840
+            if !trimmed.isEmpty { return trimmed }
1841
+        }
1842
+        guard let output = root["output"] as? [Any] else {
1843
+            throw NSError(
1844
+                domain: "OpenAIJobSearchService",
1845
+                code: 9,
1846
+                userInfo: [NSLocalizedDescriptionKey: "The search service returned no assistant text. Try again in a moment."]
1847
+            )
1848
+        }
1849
+        var segments: [String] = []
1850
+        for case let item as [String: Any] in output where (item["type"] as? String) == "message" {
1851
+            collectOutputTextSegments(fromOutputItem: item, into: &segments)
1852
+        }
1853
+        if segments.isEmpty {
1854
+            for case let item as [String: Any] in output {
1855
+                collectOutputTextSegments(fromOutputItem: item, into: &segments)
1856
+            }
1857
+        }
1858
+        let combined = segments.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
1859
+        if !combined.isEmpty {
1860
+            return combined
1861
+        }
1862
+        throw NSError(
1863
+            domain: "OpenAIJobSearchService",
1864
+            code: 9,
1865
+            userInfo: [NSLocalizedDescriptionKey: "The model did not return readable job-search text. Try again."]
1866
+        )
1813 1867
     }
1814
-}
1815
-
1816
-private struct OpenAIResponsesRequest: Codable {
1817
-    let model: String
1818
-    let input: String
1819
-    let tools: [OpenAIResponsesTool]
1820
-}
1821 1868
 
1822
-private struct OpenAIResponsesTool: Codable {
1823
-    let type: String
1824
-}
1825
-
1826
-private struct OpenAIResponsesResponse: Codable {
1827
-    let outputText: String?
1828
-    let output: [OpenAIOutputItem]?
1829
-
1830
-    enum CodingKeys: String, CodingKey {
1831
-        case outputText = "output_text"
1832
-        case output
1869
+    private static func collectOutputTextSegments(fromOutputItem item: [String: Any], into segments: inout [String]) {
1870
+        guard let content = item["content"] as? [Any] else { return }
1871
+        for case let part as [String: Any] in content {
1872
+            guard (part["type"] as? String) == "output_text" else { continue }
1873
+            if let s = part["text"] as? String {
1874
+                segments.append(s)
1875
+            } else if let nested = part["text"] as? [String: Any], let value = nested["value"] as? String {
1876
+                segments.append(value)
1877
+            }
1878
+        }
1833 1879
     }
1834 1880
 
1835
-    var bestText: String {
1836
-        if let outputText, !outputText.isEmpty {
1837
-            return outputText
1881
+    private static func parseJobListings(fromModelText text: String) throws -> [JobListing] {
1882
+        let jsonString = extractJobJSONObjectString(from: text) ?? extractJSONObject(from: text)
1883
+        let jsonData = Data(jsonString.utf8)
1884
+        if let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData) {
1885
+            return payload.jobs
1838 1886
         }
1839
-        let collected = (output ?? [])
1840
-            .flatMap { $0.content ?? [] }
1841
-            .compactMap { chunk in
1842
-                switch chunk {
1843
-                case .outputText(let value):
1844
-                    return value.text
1845
-                case .inputText:
1846
-                    return nil
1887
+        if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) {
1888
+            return listings
1889
+        }
1890
+        throw NSError(
1891
+            domain: "OpenAIJobSearchService",
1892
+            code: 10,
1893
+            userInfo: [NSLocalizedDescriptionKey: "The assistant reply did not include job listings in the expected JSON format. Try your search again."]
1894
+        )
1895
+    }
1896
+
1897
+    private static func stripMarkdownCodeFence(_ text: String) -> String {
1898
+        var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
1899
+        guard s.hasPrefix("```") else { return s }
1900
+        s.removeFirst(3)
1901
+        if s.lowercased().hasPrefix("json") {
1902
+            s.removeFirst(4)
1903
+        }
1904
+        s = s.trimmingCharacters(in: .whitespacesAndNewlines)
1905
+        if let fence = s.range(of: "```", options: .backwards) {
1906
+            s = String(s[..<fence.lowerBound])
1907
+        }
1908
+        return s.trimmingCharacters(in: .whitespacesAndNewlines)
1909
+    }
1910
+
1911
+    private static func balancedJSONObject(from openBrace: String.Index, in s: String) -> String? {
1912
+        var depth = 0
1913
+        var inString = false
1914
+        var escaped = false
1915
+        var i = openBrace
1916
+        while i < s.endIndex {
1917
+            let ch = s[i]
1918
+            if inString {
1919
+                if escaped {
1920
+                    escaped = false
1921
+                } else if ch == "\\" {
1922
+                    escaped = true
1923
+                } else if ch == "\"" {
1924
+                    inString = false
1925
+                }
1926
+            } else {
1927
+                switch ch {
1928
+                case "\"":
1929
+                    inString = true
1930
+                case "{":
1931
+                    depth += 1
1932
+                case "}":
1933
+                    depth -= 1
1934
+                    if depth == 0 {
1935
+                        return String(s[openBrace...i])
1936
+                    }
1937
+                default:
1938
+                    break
1847 1939
                 }
1848 1940
             }
1849
-            .joined(separator: "\n")
1850
-        return collected
1941
+            i = s.index(after: i)
1942
+        }
1943
+        return nil
1851 1944
     }
1852
-}
1853
-
1854
-private struct OpenAIOutputItem: Codable {
1855
-    let content: [OpenAIOutputContent]?
1856
-}
1857
-
1858
-private enum OpenAIOutputContent: Codable {
1859
-    case outputText(OpenAITextChunk)
1860
-    case inputText(OpenAITextChunk)
1861 1945
 
1862
-    enum CodingKeys: String, CodingKey {
1863
-        case type
1864
-        case text
1946
+    /// Prefers the JSON object that contains a `"jobs"` key so prose before/after the payload does not confuse the decoder.
1947
+    private static func extractJobJSONObjectString(from text: String) -> String? {
1948
+        let s = stripMarkdownCodeFence(text)
1949
+        guard let jobsRange = s.range(of: "\"jobs\"") else { return nil }
1950
+        let head = s[..<jobsRange.lowerBound]
1951
+        guard let open = head.lastIndex(of: "{") else { return nil }
1952
+        return balancedJSONObject(from: open, in: s)
1865 1953
     }
1866 1954
 
1867
-    init(from decoder: Decoder) throws {
1868
-        let container = try decoder.container(keyedBy: CodingKeys.self)
1869
-        let type = try container.decode(String.self, forKey: .type)
1870
-        let text = try container.decode(String.self, forKey: .text)
1871
-        let payload = OpenAITextChunk(text: text)
1872
-        if type == "output_text" {
1873
-            self = .outputText(payload)
1874
-        } else {
1875
-            self = .inputText(payload)
1955
+    private static func extractJSONObject(from text: String) -> String {
1956
+        if let extracted = extractJobJSONObjectString(from: text) {
1957
+            return extracted
1876 1958
         }
1877
-    }
1878
-
1879
-    func encode(to encoder: Encoder) throws {
1880
-        var container = encoder.container(keyedBy: CodingKeys.self)
1881
-        switch self {
1882
-        case .outputText(let chunk):
1883
-            try container.encode("output_text", forKey: .type)
1884
-            try container.encode(chunk.text, forKey: .text)
1885
-        case .inputText(let chunk):
1886
-            try container.encode("input_text", forKey: .type)
1887
-            try container.encode(chunk.text, forKey: .text)
1959
+        let stripped = stripMarkdownCodeFence(text)
1960
+        if let first = stripped.firstIndex(of: "{"), let balanced = balancedJSONObject(from: first, in: stripped) {
1961
+            return balanced
1962
+        }
1963
+        if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
1964
+            return String(text[range])
1888 1965
         }
1966
+        return text
1889 1967
     }
1890 1968
 }
1891 1969
 
1892
-private struct OpenAITextChunk: Codable {
1893
-    let text: String
1970
+private struct OpenAIResponsesRequest: Codable {
1971
+    let model: String
1972
+    let input: String
1973
+    let tools: [OpenAIResponsesTool]
1974
+}
1975
+
1976
+private struct OpenAIResponsesTool: Codable {
1977
+    let type: String
1894 1978
 }
1895 1979
 
1896 1980
 private struct JobSearchResultsPayload: Codable {