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