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