using Envoy.Core.Services; using Envoy.GhostDetection.Models; using System.Text.Json; namespace Envoy.GhostDetection.Signals; /// /// Deterministic signal: cross-check a job posting against the company's own /// public ATS feed (Greenhouse or Lever). If the role is live on the aggregator /// but absent or closed on the company's board, that's strong evidence the posting /// may be a ghost. /// public class AtsCrossCheckSignal : IGhostSignal { private readonly HttpClient _http; public string Name => "ATS Cross-Check"; public SignalTier Tier => SignalTier.Deterministic; public bool RequiresNetwork => true; public AtsCrossCheckSignal(HttpClient http) { _http = http; } public async Task EvaluateAsync(JobPosting posting, CancellationToken ct = default) { var ats = ExtractAtsInfo(posting); if (ats == null) return null; try { var jobs = await QueryAtsAsync(ats, ct); var match = FindBestMatch(jobs, posting); if (match == null) { // If the ATS was inferred from the company name (not the posting URL), // a no-match is not actionable: the guessed slug might resolve to a // different company's real board. Returning null preserves precision. if (ats.IsInferred) return null; return new SignalResult { SignalName = Name, Score = 0.85, Confidence = 0.75, Evidence = new[] { $"Listed on {posting.Source} but not found on {posting.CompanyName}'s {ats.AtsName} board." }, Tier = Tier }; } return new SignalResult { SignalName = Name, Score = 0.15, Confidence = 0.80, Evidence = new[] { $"Confirmed live on {posting.CompanyName}'s own {ats.AtsName} board." }, Tier = Tier }; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; // caller cancelled: honor it instead of reporting "ATS unreachable" } catch (HttpRequestException) { return null; // ATS unreachable — no opinion } catch (TaskCanceledException) { return null; // timeout — no opinion } catch { return null; // parse error — no opinion } } // ── Extraction ───────────────────────────────────────────────────── private static AtsInfo? ExtractAtsInfo(JobPosting posting) { // Direct Greenhouse URL — known ATS, not inferred if (posting.Url.Contains("greenhouse.io", StringComparison.OrdinalIgnoreCase)) { var token = ExtractGreenhouseBoardToken(posting.Url); if (!string.IsNullOrEmpty(token)) return new AtsInfo("Greenhouse", token, null, false); } // Direct Lever URL — known ATS, not inferred if (posting.Url.Contains("lever.co", StringComparison.OrdinalIgnoreCase)) { var company = ExtractLeverCompany(posting.Url); if (!string.IsNullOrEmpty(company)) return new AtsInfo("Lever", null, company, false); } // Aggregator — inferred from company name; never emit a high-confidence // "not found" on a guess. If the guessed slug resolves to a different // company's board, we'd falsely flag a real job. if (posting.Source is JobSource.Indeed or JobSource.LinkedIn or JobSource.Other) { var companySlug = Slugify(posting.CompanyName); if (!string.IsNullOrEmpty(companySlug)) { return new AtsInfo("Lever", null, companySlug, true); } } return null; } private static string? ExtractGreenhouseBoardToken(string url) { // https://boards.greenhouse.io/{token}/jobs/{id} try { var uri = new Uri(url); var segments = uri.Segments; for (int i = 0; i < segments.Length - 1; i++) { if (segments[i].Trim('/').Equals("jobs", StringComparison.OrdinalIgnoreCase)) return null; var seg = segments[i].Trim('/'); if (!string.IsNullOrEmpty(seg) && seg != "boards") return seg; } } catch { } return null; } private static string? ExtractLeverCompany(string url) { // https://jobs.lever.co/{company}/{id} try { var uri = new Uri(url); var segments = uri.Segments; for (int i = 0; i < segments.Length; i++) { var seg = segments[i].Trim('/'); if (!string.IsNullOrEmpty(seg)) return seg; } } catch { } return null; } private static string Slugify(string companyName) { return companyName.ToLowerInvariant() .Replace(" ", "-") .Replace(".", "") .Replace(",", "") .Replace("&", "and") .Replace("'", "") .Replace("(", "") .Replace(")", ""); } // ── Query ────────────────────────────────────────────────────────── private async Task> QueryAtsAsync(AtsInfo ats, CancellationToken ct) { if (ats.AtsName == "Greenhouse" && !string.IsNullOrEmpty(ats.BoardToken)) { var url = $"https://boards-api.greenhouse.io/v1/boards/{Uri.EscapeDataString(ats.BoardToken)}/jobs"; var json = await _http.GetStringAsync(url, ct); return ParseGreenhouseJobs(json); } if (ats.AtsName == "Lever" && !string.IsNullOrEmpty(ats.CompanySlug)) { var url = $"https://api.lever.co/v0/postings/{Uri.EscapeDataString(ats.CompanySlug)}?mode=json"; var json = await _http.GetStringAsync(url, ct); return ParseLeverJobs(json); } return new List(); } private static List ParseGreenhouseJobs(string json) { var jobs = new List(); try { using var doc = JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("jobs", out var jobsArray)) { foreach (var j in jobsArray.EnumerateArray()) { var title = j.TryGetProperty("title", out var t) ? t.GetString() : ""; var location = ""; if (j.TryGetProperty("location", out var locEl) && locEl.TryGetProperty("name", out var locName)) location = locName.GetString() ?? ""; jobs.Add(new AtsJob(title ?? "", location)); } } } catch { } return jobs; } private static List ParseLeverJobs(string json) { var jobs = new List(); try { using var doc = JsonDocument.Parse(json); foreach (var j in doc.RootElement.EnumerateArray()) { var title = j.TryGetProperty("text", out var t) ? t.GetString() : ""; var location = ""; if (j.TryGetProperty("categories", out var cat) && cat.TryGetProperty("location", out var loc)) location = loc.GetString() ?? ""; jobs.Add(new AtsJob(title ?? "", location)); } } catch { } return jobs; } // ── Matching ─────────────────────────────────────────────────────── private static AtsJob? FindBestMatch(List jobs, JobPosting posting) { AtsJob? best = null; double bestScore = -1; foreach (var job in jobs) { var titleSim = DomScorer.NormalizedSimilarity(posting.JobTitle, job.Title); var locSim = DomScorer.NormalizedSimilarity(posting.Location, job.Location); var score = titleSim * 0.7 + locSim * 0.3; if (score > bestScore) { bestScore = score; best = job; } } return bestScore >= 0.55 ? best : null; } // ── Internal models ────────────────────────────────────────────────── private record AtsInfo(string AtsName, string? BoardToken, string? CompanySlug, bool IsInferred); private record AtsJob(string Title, string Location); }