Procházet zdrojové kódy

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 před 2 měsíci
rodič
revize
dc15c72e2d
1 změnil soubory, kde provedl 168 přidání a 84 odebrání
  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 {
                     self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
                 case .failure(let error):
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
-                    self.chatStatusLabel.stringValue = "Could not reach API. Try again."
+                    if error is URLError {
+                        self.chatStatusLabel.stringValue = "Could not reach API. Try again."
+                    } else {
+                        self.chatStatusLabel.stringValue = "Search did not finish. Try again."
+                    }
                 }
             }
         }
@@ -1780,117 +1784,197 @@ private final class OpenAIJobSearchService {
                 return
             }
             do {
-                let decoded = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: data)
-                let text = decoded.bestText.trimmingCharacters(in: .whitespacesAndNewlines)
-                guard !text.isEmpty else {
+                let modelText = try Self.extractModelTextFromResponsesBody(data)
+                let trimmed = modelText.trimmingCharacters(in: .whitespacesAndNewlines)
+                guard !trimmed.isEmpty else {
                     throw NSError(
                         domain: "OpenAIJobSearchService",
                         code: 4,
                         userInfo: [NSLocalizedDescriptionKey: "The API returned an empty text payload."]
                     )
                 }
-                let cleanedText = Self.extractJSONObject(from: text)
-                let jsonData = Data(cleanedText.utf8)
-                let jobsPayload = try JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData)
-                completion(.success(JobSearchOutput(jobs: jobsPayload.jobs)))
+                let jobs = try Self.parseJobListings(fromModelText: trimmed)
+                completion(.success(JobSearchOutput(jobs: jobs)))
             } catch {
-                let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
-                let message = "The API response could not be parsed as job JSON. Raw response: \(rawBody.prefix(600))"
-                completion(.failure(NSError(
-                    domain: "OpenAIJobSearchService",
-                    code: 5,
-                    userInfo: [NSLocalizedDescriptionKey: message]
-                )))
+                completion(.failure(error))
             }
         }.resume()
     }
 
-    private static func extractJSONObject(from text: String) -> String {
-        if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
-            return String(text[range])
+    /// 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).
+    private static func extractModelTextFromResponsesBody(_ data: Data) throws -> String {
+        let rootObject: Any
+        do {
+            rootObject = try JSONSerialization.jsonObject(with: data, options: [])
+        } catch {
+            throw NSError(
+                domain: "OpenAIJobSearchService",
+                code: 5,
+                userInfo: [NSLocalizedDescriptionKey: "The job search service returned data that was not valid JSON."]
+            )
         }
-        return text
+        guard let root = rootObject as? [String: Any] else {
+            throw NSError(
+                domain: "OpenAIJobSearchService",
+                code: 5,
+                userInfo: [NSLocalizedDescriptionKey: "Unexpected response shape from the job search service."]
+            )
+        }
+        if let status = root["status"] as? String {
+            if status == "failed" {
+                let message = (root["error"] as? [String: Any])?["message"] as? String ?? "The search request failed."
+                throw NSError(domain: "OpenAIJobSearchService", code: 7, userInfo: [NSLocalizedDescriptionKey: message])
+            }
+            if status == "incomplete",
+               let details = root["incomplete_details"] as? [String: Any],
+               let reason = details["reason"] as? String {
+                throw NSError(
+                    domain: "OpenAIJobSearchService",
+                    code: 8,
+                    userInfo: [NSLocalizedDescriptionKey: "Search stopped early (\(reason)). Try a simpler query or try again."]
+                )
+            }
+        }
+        if let direct = root["output_text"] as? String {
+            let trimmed = direct.trimmingCharacters(in: .whitespacesAndNewlines)
+            if !trimmed.isEmpty { return trimmed }
+        }
+        guard let output = root["output"] as? [Any] else {
+            throw NSError(
+                domain: "OpenAIJobSearchService",
+                code: 9,
+                userInfo: [NSLocalizedDescriptionKey: "The search service returned no assistant text. Try again in a moment."]
+            )
+        }
+        var segments: [String] = []
+        for case let item as [String: Any] in output where (item["type"] as? String) == "message" {
+            collectOutputTextSegments(fromOutputItem: item, into: &segments)
+        }
+        if segments.isEmpty {
+            for case let item as [String: Any] in output {
+                collectOutputTextSegments(fromOutputItem: item, into: &segments)
+            }
+        }
+        let combined = segments.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+        if !combined.isEmpty {
+            return combined
+        }
+        throw NSError(
+            domain: "OpenAIJobSearchService",
+            code: 9,
+            userInfo: [NSLocalizedDescriptionKey: "The model did not return readable job-search text. Try again."]
+        )
     }
-}
-
-private struct OpenAIResponsesRequest: Codable {
-    let model: String
-    let input: String
-    let tools: [OpenAIResponsesTool]
-}
 
-private struct OpenAIResponsesTool: Codable {
-    let type: String
-}
-
-private struct OpenAIResponsesResponse: Codable {
-    let outputText: String?
-    let output: [OpenAIOutputItem]?
-
-    enum CodingKeys: String, CodingKey {
-        case outputText = "output_text"
-        case output
+    private static func collectOutputTextSegments(fromOutputItem item: [String: Any], into segments: inout [String]) {
+        guard let content = item["content"] as? [Any] else { return }
+        for case let part as [String: Any] in content {
+            guard (part["type"] as? String) == "output_text" else { continue }
+            if let s = part["text"] as? String {
+                segments.append(s)
+            } else if let nested = part["text"] as? [String: Any], let value = nested["value"] as? String {
+                segments.append(value)
+            }
+        }
     }
 
-    var bestText: String {
-        if let outputText, !outputText.isEmpty {
-            return outputText
+    private static func parseJobListings(fromModelText text: String) throws -> [JobListing] {
+        let jsonString = extractJobJSONObjectString(from: text) ?? extractJSONObject(from: text)
+        let jsonData = Data(jsonString.utf8)
+        if let payload = try? JSONDecoder().decode(JobSearchResultsPayload.self, from: jsonData) {
+            return payload.jobs
         }
-        let collected = (output ?? [])
-            .flatMap { $0.content ?? [] }
-            .compactMap { chunk in
-                switch chunk {
-                case .outputText(let value):
-                    return value.text
-                case .inputText:
-                    return nil
+        if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) {
+            return listings
+        }
+        throw NSError(
+            domain: "OpenAIJobSearchService",
+            code: 10,
+            userInfo: [NSLocalizedDescriptionKey: "The assistant reply did not include job listings in the expected JSON format. Try your search again."]
+        )
+    }
+
+    private static func stripMarkdownCodeFence(_ text: String) -> String {
+        var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard s.hasPrefix("```") else { return s }
+        s.removeFirst(3)
+        if s.lowercased().hasPrefix("json") {
+            s.removeFirst(4)
+        }
+        s = s.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let fence = s.range(of: "```", options: .backwards) {
+            s = String(s[..<fence.lowerBound])
+        }
+        return s.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    private static func balancedJSONObject(from openBrace: String.Index, in s: String) -> String? {
+        var depth = 0
+        var inString = false
+        var escaped = false
+        var i = openBrace
+        while i < s.endIndex {
+            let ch = s[i]
+            if inString {
+                if escaped {
+                    escaped = false
+                } else if ch == "\\" {
+                    escaped = true
+                } else if ch == "\"" {
+                    inString = false
+                }
+            } else {
+                switch ch {
+                case "\"":
+                    inString = true
+                case "{":
+                    depth += 1
+                case "}":
+                    depth -= 1
+                    if depth == 0 {
+                        return String(s[openBrace...i])
+                    }
+                default:
+                    break
                 }
             }
-            .joined(separator: "\n")
-        return collected
+            i = s.index(after: i)
+        }
+        return nil
     }
-}
-
-private struct OpenAIOutputItem: Codable {
-    let content: [OpenAIOutputContent]?
-}
-
-private enum OpenAIOutputContent: Codable {
-    case outputText(OpenAITextChunk)
-    case inputText(OpenAITextChunk)
 
-    enum CodingKeys: String, CodingKey {
-        case type
-        case text
+    /// 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 }
+        let head = s[..<jobsRange.lowerBound]
+        guard let open = head.lastIndex(of: "{") else { return nil }
+        return balancedJSONObject(from: open, in: s)
     }
 
-    init(from decoder: Decoder) throws {
-        let container = try decoder.container(keyedBy: CodingKeys.self)
-        let type = try container.decode(String.self, forKey: .type)
-        let text = try container.decode(String.self, forKey: .text)
-        let payload = OpenAITextChunk(text: text)
-        if type == "output_text" {
-            self = .outputText(payload)
-        } else {
-            self = .inputText(payload)
+    private static func extractJSONObject(from text: String) -> String {
+        if let extracted = extractJobJSONObjectString(from: text) {
+            return extracted
         }
-    }
-
-    func encode(to encoder: Encoder) throws {
-        var container = encoder.container(keyedBy: CodingKeys.self)
-        switch self {
-        case .outputText(let chunk):
-            try container.encode("output_text", forKey: .type)
-            try container.encode(chunk.text, forKey: .text)
-        case .inputText(let chunk):
-            try container.encode("input_text", forKey: .type)
-            try container.encode(chunk.text, forKey: .text)
+        let stripped = stripMarkdownCodeFence(text)
+        if let first = stripped.firstIndex(of: "{"), let balanced = balancedJSONObject(from: first, in: stripped) {
+            return balanced
+        }
+        if let range = text.range(of: "\\{[\\s\\S]*\\}", options: .regularExpression) {
+            return String(text[range])
         }
+        return text
     }
 }
 
-private struct OpenAITextChunk: Codable {
-    let text: String
+private struct OpenAIResponsesRequest: Codable {
+    let model: String
+    let input: String
+    let tools: [OpenAIResponsesTool]
+}
+
+private struct OpenAIResponsesTool: Codable {
+    let type: String
 }
 
 private struct JobSearchResultsPayload: Codable {