|
|
@@ -1243,16 +1243,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1243
|
1243
|
}
|
|
1244
|
1244
|
|
|
1245
|
1245
|
private func resolvedSearchQuery(for prompt: String) -> String {
|
|
1246
|
|
- guard isContinuationPrompt(prompt) else { return prompt }
|
|
1247
|
|
- for message in chatMessages.reversed() where message.role == "user" {
|
|
1248
|
|
- let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1249
|
|
- if !candidate.isEmpty, !isContinuationPrompt(candidate) {
|
|
1250
|
|
- return candidate
|
|
1251
|
|
- }
|
|
|
1246
|
+ let anchor = anchorUserJobQuery(excludingLatestUserMessage: prompt)
|
|
|
1247
|
+ if isContinuationPrompt(prompt), !isRefinementPrompt(prompt) {
|
|
|
1248
|
+ if let anchor { return anchor }
|
|
|
1249
|
+ return prompt
|
|
|
1250
|
+ }
|
|
|
1251
|
+ if isRefinementPrompt(prompt), let anchor {
|
|
|
1252
|
+ return "\(anchor). User follow-up (apply on top of the same search topic): \(prompt)"
|
|
1252
|
1253
|
}
|
|
1253
|
1254
|
return prompt
|
|
1254
|
1255
|
}
|
|
1255
|
1256
|
|
|
|
1257
|
+ /// First prior user message that looks like an original job query (skips short continuations and refinements so follow-ups keep a stable topic anchor).
|
|
|
1258
|
+ private func anchorUserJobQuery(excludingLatestUserMessage latest: String) -> String? {
|
|
|
1259
|
+ let prior = Array(chatMessages.dropLast())
|
|
|
1260
|
+ for message in prior.reversed() where message.role == "user" {
|
|
|
1261
|
+ let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1262
|
+ guard !candidate.isEmpty, candidate != latest else { continue }
|
|
|
1263
|
+ if isContinuationPrompt(candidate) { continue }
|
|
|
1264
|
+ if isRefinementPrompt(candidate) { continue }
|
|
|
1265
|
+ return candidate
|
|
|
1266
|
+ }
|
|
|
1267
|
+ return nil
|
|
|
1268
|
+ }
|
|
|
1269
|
+
|
|
1256
|
1270
|
private func isContinuationPrompt(_ prompt: String) -> Bool {
|
|
1257
|
1271
|
let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
1258
|
1272
|
let continuationPhrases: Set<String> = [
|
|
|
@@ -1272,6 +1286,35 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1272
|
1286
|
return normalized.contains("more search") || normalized.contains("more job")
|
|
1273
|
1287
|
}
|
|
1274
|
1288
|
|
|
|
1289
|
+ /// Follow-ups that narrow, re-rank, or re-frame results rather than starting a brand-new role search.
|
|
|
1290
|
+ /// 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.
|
|
|
1291
|
+ private func isRefinementPrompt(_ prompt: String) -> Bool {
|
|
|
1292
|
+ let n = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
1293
|
+ if n.isEmpty { return false }
|
|
|
1294
|
+ let strongPhrases = [
|
|
|
1295
|
+ "higher pay", "high pay", "better pay", "more pay", "top pay", "best pay",
|
|
|
1296
|
+ "higher salary", "better salary", "more salary", "pay rate", "hourly rate",
|
|
|
1297
|
+ "paid more", "paying more", "earn more", "better paid", "paying better",
|
|
|
1298
|
+ "work from home", "in office", "in-office", "on-site only", "remote only",
|
|
|
1299
|
+ "hybrid only", "onsite only", "visa sponsorship", "h1b",
|
|
|
1300
|
+ "entry level", "entry-level", "mid level", "mid-level", "full time", "full-time",
|
|
|
1301
|
+ "part time", "part-time",
|
|
|
1302
|
+ "closer to", "nearer", "different city", "different state", "relocate",
|
|
|
1303
|
+ "filter", "only show", "just show", "exclude", "without", "sort by", "rank by",
|
|
|
1304
|
+ "cheaper", "lower pay", "less travel",
|
|
|
1305
|
+ "better benefits", "equity", "bonus", "overtime",
|
|
|
1306
|
+ "get me the jobs", "show me the jobs", "give me the jobs", "narrow", "refine"
|
|
|
1307
|
+ ]
|
|
|
1308
|
+ if strongPhrases.contains(where: { n.contains($0) }) { return true }
|
|
|
1309
|
+ if n.hasPrefix("only ") || n.hasPrefix("just ") { return true }
|
|
|
1310
|
+ guard !lastSearchResults.isEmpty, n.count <= 52 else { return false }
|
|
|
1311
|
+ let softAfterResults = [
|
|
|
1312
|
+ "remote", "hybrid", "onsite", "on-site", "senior", "junior", "staff", "lead",
|
|
|
1313
|
+ "principal", "intern", "contract", "location"
|
|
|
1314
|
+ ]
|
|
|
1315
|
+ return softAfterResults.contains(where: { n.contains($0) })
|
|
|
1316
|
+ }
|
|
|
1317
|
+
|
|
1275
|
1318
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
1276
|
1319
|
applySearchFieldInsertionPoint(obj.object)
|
|
1277
|
1320
|
if (obj.object as? NSTextField) === jobKeywordsField {
|
|
|
@@ -1721,25 +1764,21 @@ private final class OpenAIJobSearchService {
|
|
1721
|
1764
|
}
|
|
1722
|
1765
|
|
|
1723
|
1766
|
let instructions = """
|
|
1724
|
|
- Continue this same job-search conversation. Use prior context when useful, and prioritize the latest user query.
|
|
|
1767
|
+ 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).
|
|
1725
|
1768
|
|
|
1726
|
1769
|
Conversation context:
|
|
1727
|
1770
|
\(contextBlock)
|
|
1728
|
1771
|
|
|
1729
|
1772
|
Latest user query: "\(query)"
|
|
1730
|
1773
|
|
|
1731
|
|
- Search the web for currently available jobs related to the latest query (and relevant context above).
|
|
1732
|
|
- Return ONLY strict JSON that matches this schema:
|
|
1733
|
|
- {
|
|
1734
|
|
- "jobs": [
|
|
1735
|
|
- {
|
|
1736
|
|
- "title": "string",
|
|
1737
|
|
- "description": "string",
|
|
1738
|
|
- "url": "https://..."
|
|
1739
|
|
- }
|
|
1740
|
|
- ]
|
|
1741
|
|
- }
|
|
1742
|
|
- Keep descriptions concise (1 sentence), include real apply/listing URLs when available, and return up to 8 jobs.
|
|
|
1774
|
+ 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.
|
|
|
1775
|
+
|
|
|
1776
|
+ CRITICAL OUTPUT RULES:
|
|
|
1777
|
+ - Reply with NOTHING except one JSON object. No markdown, no code fences, no prose before or after, no labels like "Here is the JSON".
|
|
|
1778
|
+ - The JSON must match exactly this shape (lowercase key "jobs"):
|
|
|
1779
|
+ {"jobs":[{"title":"...","description":"...","url":"https://..."}]}
|
|
|
1780
|
+ - If you find no suitable listings, return {"jobs":[]} — still valid JSON only.
|
|
|
1781
|
+ Keep each description to one sentence; prefer real listing URLs; at most 8 jobs.
|
|
1743
|
1782
|
"""
|
|
1744
|
1783
|
let payload = OpenAIResponsesRequest(
|
|
1745
|
1784
|
model: "gpt-4o-mini",
|
|
|
@@ -1887,6 +1926,14 @@ private final class OpenAIJobSearchService {
|
|
1887
|
1926
|
if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) {
|
|
1888
|
1927
|
return listings
|
|
1889
|
1928
|
}
|
|
|
1929
|
+ if let obj = try? JSONSerialization.jsonObject(with: jsonData, options: []) {
|
|
|
1930
|
+ if let dict = obj as? [String: Any], let jobs = jobListings(fromFlexibleJSONObject: dict) {
|
|
|
1931
|
+ return jobs
|
|
|
1932
|
+ }
|
|
|
1933
|
+ if let arr = obj as? [[String: Any]], let jobs = jobListings(fromFlexibleJobArray: arr) {
|
|
|
1934
|
+ return jobs
|
|
|
1935
|
+ }
|
|
|
1936
|
+ }
|
|
1890
|
1937
|
throw NSError(
|
|
1891
|
1938
|
domain: "OpenAIJobSearchService",
|
|
1892
|
1939
|
code: 10,
|
|
|
@@ -1894,6 +1941,43 @@ private final class OpenAIJobSearchService {
|
|
1894
|
1941
|
)
|
|
1895
|
1942
|
}
|
|
1896
|
1943
|
|
|
|
1944
|
+ private static func jobListings(fromFlexibleJSONObject dict: [String: Any]) -> [JobListing]? {
|
|
|
1945
|
+ for (key, value) in dict {
|
|
|
1946
|
+ guard key.caseInsensitiveCompare("jobs") == .orderedSame, let arr = value as? [[String: Any]] else { continue }
|
|
|
1947
|
+ if let jobs = jobListings(fromFlexibleJobArray: arr) { return jobs }
|
|
|
1948
|
+ }
|
|
|
1949
|
+ for wrapKey in ["data", "result", "results", "payload"] {
|
|
|
1950
|
+ if let inner = dict[wrapKey] as? [String: Any], let nested = jobListings(fromFlexibleJSONObject: inner) {
|
|
|
1951
|
+ return nested
|
|
|
1952
|
+ }
|
|
|
1953
|
+ }
|
|
|
1954
|
+ return nil
|
|
|
1955
|
+ }
|
|
|
1956
|
+
|
|
|
1957
|
+ private static func jobListings(fromFlexibleJobArray jobs: [[String: Any]]) -> [JobListing]? {
|
|
|
1958
|
+ var out: [JobListing] = []
|
|
|
1959
|
+ for item in jobs {
|
|
|
1960
|
+ guard let title = firstString(valuesForKeys: ["title", "job_title", "name", "position"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
1961
|
+ !title.isEmpty,
|
|
|
1962
|
+ let desc = firstString(valuesForKeys: ["description", "snippet", "summary", "desc"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
1963
|
+ !desc.isEmpty else { continue }
|
|
|
1964
|
+ let urlRaw = firstString(valuesForKeys: ["url", "link", "apply_url", "job_url"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1965
|
+ let url: String? = (urlRaw?.isEmpty == true) ? nil : urlRaw
|
|
|
1966
|
+ out.append(JobListing(title: title, description: desc, url: url))
|
|
|
1967
|
+ }
|
|
|
1968
|
+ return out.isEmpty ? nil : out
|
|
|
1969
|
+ }
|
|
|
1970
|
+
|
|
|
1971
|
+ private static func firstString(valuesForKeys keys: [String], in dict: [String: Any]) -> String? {
|
|
|
1972
|
+ for wanted in keys {
|
|
|
1973
|
+ for (dk, dv) in dict {
|
|
|
1974
|
+ guard dk.caseInsensitiveCompare(wanted) == .orderedSame, let s = dv as? String else { continue }
|
|
|
1975
|
+ return s
|
|
|
1976
|
+ }
|
|
|
1977
|
+ }
|
|
|
1978
|
+ return nil
|
|
|
1979
|
+ }
|
|
|
1980
|
+
|
|
1897
|
1981
|
private static func stripMarkdownCodeFence(_ text: String) -> String {
|
|
1898
|
1982
|
var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1899
|
1983
|
guard s.hasPrefix("```") else { return s }
|
|
|
@@ -1946,7 +2030,7 @@ private final class OpenAIJobSearchService {
|
|
1946
|
2030
|
/// Prefers the JSON object that contains a `"jobs"` key so prose before/after the payload does not confuse the decoder.
|
|
1947
|
2031
|
private static func extractJobJSONObjectString(from text: String) -> String? {
|
|
1948
|
2032
|
let s = stripMarkdownCodeFence(text)
|
|
1949
|
|
- guard let jobsRange = s.range(of: "\"jobs\"") else { return nil }
|
|
|
2033
|
+ guard let jobsRange = s.range(of: "\"jobs\"", options: .caseInsensitive) else { return nil }
|
|
1950
|
2034
|
let head = s[..<jobsRange.lowerBound]
|
|
1951
|
2035
|
guard let open = head.lastIndex(of: "{") else { return nil }
|
|
1952
|
2036
|
return balancedJSONObject(from: open, in: s)
|