|
|
@@ -1243,16 +1243,30 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
|
|
|
private func resolvedSearchQuery(for prompt: String) -> String {
|
|
|
- guard isContinuationPrompt(prompt) else { return prompt }
|
|
|
- for message in chatMessages.reversed() where message.role == "user" {
|
|
|
- let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
- if !candidate.isEmpty, !isContinuationPrompt(candidate) {
|
|
|
- return candidate
|
|
|
- }
|
|
|
+ let anchor = anchorUserJobQuery(excludingLatestUserMessage: prompt)
|
|
|
+ if isContinuationPrompt(prompt), !isRefinementPrompt(prompt) {
|
|
|
+ if let anchor { return anchor }
|
|
|
+ return prompt
|
|
|
+ }
|
|
|
+ if isRefinementPrompt(prompt), let anchor {
|
|
|
+ return "\(anchor). User follow-up (apply on top of the same search topic): \(prompt)"
|
|
|
}
|
|
|
return prompt
|
|
|
}
|
|
|
|
|
|
+ /// First prior user message that looks like an original job query (skips short continuations and refinements so follow-ups keep a stable topic anchor).
|
|
|
+ private func anchorUserJobQuery(excludingLatestUserMessage latest: String) -> String? {
|
|
|
+ let prior = Array(chatMessages.dropLast())
|
|
|
+ for message in prior.reversed() where message.role == "user" {
|
|
|
+ let candidate = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ guard !candidate.isEmpty, candidate != latest else { continue }
|
|
|
+ if isContinuationPrompt(candidate) { continue }
|
|
|
+ if isRefinementPrompt(candidate) { continue }
|
|
|
+ return candidate
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
private func isContinuationPrompt(_ prompt: String) -> Bool {
|
|
|
let normalized = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
let continuationPhrases: Set<String> = [
|
|
|
@@ -1272,6 +1286,35 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
return normalized.contains("more search") || normalized.contains("more job")
|
|
|
}
|
|
|
|
|
|
+ /// Follow-ups that narrow, re-rank, or re-frame results rather than starting a brand-new role search.
|
|
|
+ /// 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.
|
|
|
+ private func isRefinementPrompt(_ prompt: String) -> Bool {
|
|
|
+ let n = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
+ if n.isEmpty { return false }
|
|
|
+ let strongPhrases = [
|
|
|
+ "higher pay", "high pay", "better pay", "more pay", "top pay", "best pay",
|
|
|
+ "higher salary", "better salary", "more salary", "pay rate", "hourly rate",
|
|
|
+ "paid more", "paying more", "earn more", "better paid", "paying better",
|
|
|
+ "work from home", "in office", "in-office", "on-site only", "remote only",
|
|
|
+ "hybrid only", "onsite only", "visa sponsorship", "h1b",
|
|
|
+ "entry level", "entry-level", "mid level", "mid-level", "full time", "full-time",
|
|
|
+ "part time", "part-time",
|
|
|
+ "closer to", "nearer", "different city", "different state", "relocate",
|
|
|
+ "filter", "only show", "just show", "exclude", "without", "sort by", "rank by",
|
|
|
+ "cheaper", "lower pay", "less travel",
|
|
|
+ "better benefits", "equity", "bonus", "overtime",
|
|
|
+ "get me the jobs", "show me the jobs", "give me the jobs", "narrow", "refine"
|
|
|
+ ]
|
|
|
+ if strongPhrases.contains(where: { n.contains($0) }) { return true }
|
|
|
+ if n.hasPrefix("only ") || n.hasPrefix("just ") { return true }
|
|
|
+ guard !lastSearchResults.isEmpty, n.count <= 52 else { return false }
|
|
|
+ let softAfterResults = [
|
|
|
+ "remote", "hybrid", "onsite", "on-site", "senior", "junior", "staff", "lead",
|
|
|
+ "principal", "intern", "contract", "location"
|
|
|
+ ]
|
|
|
+ return softAfterResults.contains(where: { n.contains($0) })
|
|
|
+ }
|
|
|
+
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
|
applySearchFieldInsertionPoint(obj.object)
|
|
|
if (obj.object as? NSTextField) === jobKeywordsField {
|
|
|
@@ -1721,25 +1764,21 @@ private final class OpenAIJobSearchService {
|
|
|
}
|
|
|
|
|
|
let instructions = """
|
|
|
- Continue this same job-search conversation. Use prior context when useful, and prioritize the latest user query.
|
|
|
+ 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).
|
|
|
|
|
|
Conversation context:
|
|
|
\(contextBlock)
|
|
|
|
|
|
Latest user query: "\(query)"
|
|
|
|
|
|
- Search the web for currently available jobs related to the latest query (and relevant context above).
|
|
|
- Return ONLY strict JSON that matches this schema:
|
|
|
- {
|
|
|
- "jobs": [
|
|
|
- {
|
|
|
- "title": "string",
|
|
|
- "description": "string",
|
|
|
- "url": "https://..."
|
|
|
- }
|
|
|
- ]
|
|
|
- }
|
|
|
- Keep descriptions concise (1 sentence), include real apply/listing URLs when available, and return up to 8 jobs.
|
|
|
+ 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.
|
|
|
+
|
|
|
+ CRITICAL OUTPUT RULES:
|
|
|
+ - Reply with NOTHING except one JSON object. No markdown, no code fences, no prose before or after, no labels like "Here is the JSON".
|
|
|
+ - The JSON must match exactly this shape (lowercase key "jobs"):
|
|
|
+ {"jobs":[{"title":"...","description":"...","url":"https://..."}]}
|
|
|
+ - If you find no suitable listings, return {"jobs":[]} — still valid JSON only.
|
|
|
+ Keep each description to one sentence; prefer real listing URLs; at most 8 jobs.
|
|
|
"""
|
|
|
let payload = OpenAIResponsesRequest(
|
|
|
model: "gpt-4o-mini",
|
|
|
@@ -1887,6 +1926,14 @@ private final class OpenAIJobSearchService {
|
|
|
if let listings = try? JSONDecoder().decode([JobListing].self, from: jsonData) {
|
|
|
return listings
|
|
|
}
|
|
|
+ if let obj = try? JSONSerialization.jsonObject(with: jsonData, options: []) {
|
|
|
+ if let dict = obj as? [String: Any], let jobs = jobListings(fromFlexibleJSONObject: dict) {
|
|
|
+ return jobs
|
|
|
+ }
|
|
|
+ if let arr = obj as? [[String: Any]], let jobs = jobListings(fromFlexibleJobArray: arr) {
|
|
|
+ return jobs
|
|
|
+ }
|
|
|
+ }
|
|
|
throw NSError(
|
|
|
domain: "OpenAIJobSearchService",
|
|
|
code: 10,
|
|
|
@@ -1894,6 +1941,43 @@ private final class OpenAIJobSearchService {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ private static func jobListings(fromFlexibleJSONObject dict: [String: Any]) -> [JobListing]? {
|
|
|
+ for (key, value) in dict {
|
|
|
+ guard key.caseInsensitiveCompare("jobs") == .orderedSame, let arr = value as? [[String: Any]] else { continue }
|
|
|
+ if let jobs = jobListings(fromFlexibleJobArray: arr) { return jobs }
|
|
|
+ }
|
|
|
+ for wrapKey in ["data", "result", "results", "payload"] {
|
|
|
+ if let inner = dict[wrapKey] as? [String: Any], let nested = jobListings(fromFlexibleJSONObject: inner) {
|
|
|
+ return nested
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func jobListings(fromFlexibleJobArray jobs: [[String: Any]]) -> [JobListing]? {
|
|
|
+ var out: [JobListing] = []
|
|
|
+ for item in jobs {
|
|
|
+ guard let title = firstString(valuesForKeys: ["title", "job_title", "name", "position"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
+ !title.isEmpty,
|
|
|
+ let desc = firstString(valuesForKeys: ["description", "snippet", "summary", "desc"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
+ !desc.isEmpty else { continue }
|
|
|
+ let urlRaw = firstString(valuesForKeys: ["url", "link", "apply_url", "job_url"], in: item)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let url: String? = (urlRaw?.isEmpty == true) ? nil : urlRaw
|
|
|
+ out.append(JobListing(title: title, description: desc, url: url))
|
|
|
+ }
|
|
|
+ return out.isEmpty ? nil : out
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func firstString(valuesForKeys keys: [String], in dict: [String: Any]) -> String? {
|
|
|
+ for wanted in keys {
|
|
|
+ for (dk, dv) in dict {
|
|
|
+ guard dk.caseInsensitiveCompare(wanted) == .orderedSame, let s = dv as? String else { continue }
|
|
|
+ return s
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
private static func stripMarkdownCodeFence(_ text: String) -> String {
|
|
|
var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
guard s.hasPrefix("```") else { return s }
|
|
|
@@ -1946,7 +2030,7 @@ private final class OpenAIJobSearchService {
|
|
|
/// 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 }
|
|
|
+ guard let jobsRange = s.range(of: "\"jobs\"", options: .caseInsensitive) else { return nil }
|
|
|
let head = s[..<jobsRange.lowerBound]
|
|
|
guard let open = head.lastIndex(of: "{") else { return nil }
|
|
|
return balancedJSONObject(from: open, in: s)
|