Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to Envoy are documented in this file. Format is based on [Ke
## [Unreleased]

### Added
- Find Jobs feeds the ledger. Every scored result is recorded as a sighting (one per posting per day, so re-running a search inflates nothing), opening a posting records a view, and a new SKIP button on each result records the pass and clears the row. Skipping a flagged posting answers with GHOST DODGED in the status line. Day-by-day sightings are also the listing history the repost-frequency signal needs for its stronger path.
- Scoreboard foundation. Envoy now keeps a local activity ledger (a `JobEvents` table in `envoy.db`): a submitted application is recorded as Applied, and a cancel at the submit gate as Declined, each carrying the ghost risk score, band, and evidence that was on screen when the user decided. Application logs also store the score and band at submit time. Cancelling at the gate gets its own status (`DeclinedByUser`) instead of being lumped in with the safety-check halt, and the Apply view shows it in yellow, not error red, because walking away from a bad posting is a decision, not a failure. This ledger is the data layer for the upcoming stats view, and it begins the cross-session listing history the repost-frequency signal has been waiting on.
- In-app update check. Once per launch, Envoy asks the public GitHub releases API whether a newer version exists and, when one does, shows an UPDATE link in the title bar that opens the release page. Nothing is sent beyond the request itself; set `"CheckForUpdates": false` in `settings.json` to turn it off.

Expand Down
43 changes: 29 additions & 14 deletions src/Envoy.Core/Models/JobEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ public class JobEvent
/// <summary>Links Applied/Declined events back to their submit log.</summary>
public Guid? ApplicationLogId { get; set; }

/// <summary>
/// Builds a ledger event for a posting the user saw or acted on in a
/// results list. The score snapshot is whatever was on screen at the time;
/// null means the posting was never scored.
/// </summary>
public static JobEvent ForPosting(
JobEventType type,
string jobUrl,
string jobTitle,
string company,
string source,
GhostScoreSnapshot? ghostScore) => new()
{
Type = type,
JobUrl = jobUrl,
JobTitle = jobTitle,
Company = company,
Source = source,
PostingKey = Services.PostingKey.For(jobUrl, company, jobTitle),
RiskScore = ghostScore?.RiskScore,
RiskBand = ghostScore?.Band,
Evidence = ghostScore == null || ghostScore.TopEvidence.Length == 0
? null
: string.Join("\n", ghostScore.TopEvidence)
};

/// <summary>
/// Maps a finished submit-flow log to its ledger event: a completed submit
/// becomes <see cref="JobEventType.Applied"/>, a user cancel at the gate
Expand All @@ -53,20 +79,9 @@ public class JobEvent
default: return null;
}

return new JobEvent
{
Type = type,
JobUrl = log.JobUrl,
JobTitle = log.JobTitle,
Company = log.Company,
PostingKey = Services.PostingKey.For(log.JobUrl, log.Company, log.JobTitle),
RiskScore = ghostScore?.RiskScore,
RiskBand = ghostScore?.Band,
Evidence = ghostScore == null || ghostScore.TopEvidence.Length == 0
? null
: string.Join("\n", ghostScore.TopEvidence),
ApplicationLogId = log.Id
};
var jobEvent = ForPosting(type, log.JobUrl, log.JobTitle, log.Company, source: "", ghostScore);
jobEvent.ApplicationLogId = log.Id;
return jobEvent;
}
}

Expand Down
31 changes: 31 additions & 0 deletions src/Envoy.Core/Services/Repositories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ public interface IJobEventRepository
Task AddAsync(JobEvent jobEvent, CancellationToken ct = default);
Task<List<JobEvent>> GetAllAsync(CancellationToken ct = default);
Task<int> CountByTypeAsync(JobEventType type, CancellationToken ct = default);
Task<int> RecordSightingsAsync(IReadOnlyList<JobEvent> sightings, CancellationToken ct = default);
}

public class JobEventRepository : IJobEventRepository
Expand Down Expand Up @@ -271,4 +272,34 @@ public async Task<int> CountByTypeAsync(JobEventType type, CancellationToken ct
.AsNoTracking()
.CountAsync(e => e.Type == type, ct);
}

// Sightings are deduplicated per posting per UTC day: re-running the same
// search minutes later must not inflate the ledger, while a listing seen
// again on a later day lands as a new row — that day-by-day trail is the
// history the repost-frequency signal wants. Non-Sighted events in the
// batch are ignored. Returns the number of rows actually inserted.
public async Task<int> RecordSightingsAsync(IReadOnlyList<JobEvent> sightings, CancellationToken ct = default)
{
if (sightings.Count == 0) return 0;

using var db = _factory.CreateDbContext();
var dayStart = DateTime.UtcNow.Date;
var seenToday = (await db.JobEvents
.AsNoTracking()
.Where(e => e.Type == JobEventType.Sighted && e.OccurredAt >= dayStart)
.Select(e => e.PostingKey)
.ToListAsync(ct)).ToHashSet();

var fresh = new List<JobEvent>();
foreach (var sighting in sightings)
{
if (sighting.Type == JobEventType.Sighted && seenToday.Add(sighting.PostingKey))
fresh.Add(sighting);
}

if (fresh.Count == 0) return 0;
db.JobEvents.AddRange(fresh);
await db.SaveChangesAsync(ct);
return fresh.Count;
}
}
3 changes: 2 additions & 1 deletion src/Envoy.UI/FindJobsView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@
<Border Background="{Binding RiskBrush}" CornerRadius="2" Padding="8,3" HorizontalAlignment="Right">
<TextBlock Text="{Binding RiskText}" Foreground="#0A0E17" FontSize="11" FontWeight="Bold" FontFamily="{StaticResource CyberFontHeading}"/>
</Border>
<Button Content="&#x25C8; VIEW" Tag="{Binding Url}" Click="BtnView_Click" Style="{StaticResource CyberButton}" Padding="14,5" Margin="0,8,0,0"/>
<Button Content="&#x25C8; VIEW" Tag="{Binding}" Click="BtnView_Click" Style="{StaticResource CyberButton}" Padding="14,5" Margin="0,8,0,0" AutomationProperties.Name="Open this posting in the browser"/>
<Button Content="&#x2715; SKIP" Tag="{Binding}" Click="BtnSkip_Click" Style="{StaticResource CyberButton}" Padding="14,5" Margin="0,6,0,0" AutomationProperties.Name="Pass on this posting"/>
</StackPanel>
</Grid>
</Border>
Expand Down
74 changes: 70 additions & 4 deletions src/Envoy.UI/FindJobsView.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Envoy.Core.Configuration;
using Envoy.Core.Models;
using Envoy.Core.Services;
using Envoy.Discovery;
using Envoy.Discovery.Models;
using Envoy.GhostDetection;
Expand All @@ -16,14 +18,16 @@ public partial class FindJobsView : UserControl
private readonly JobDiscoveryService _discovery;
private readonly GhostScorer _scorer;
private readonly EnvoySettings _settings;
private readonly IJobEventRepository _jobEvents;
private List<AtsBoardRef> _boards = new();
private List<DiscoveredJobItem> _lastItems = new();

public FindJobsView(JobDiscoveryService discovery, GhostScorer scorer, EnvoySettings settings)
public FindJobsView(JobDiscoveryService discovery, GhostScorer scorer, EnvoySettings settings, IJobEventRepository jobEvents)
{
_discovery = discovery;
_scorer = scorer;
_settings = settings;
_jobEvents = jobEvents;
InitializeComponent();
Loaded += FindJobsView_Loaded;
}
Expand Down Expand Up @@ -199,6 +203,7 @@ private async Task RenderAsync(DiscoveryResult result)
}
ResultsList.ItemsSource = items;
_lastItems = items;
RecordSightings(items);

if (items.Count == 0)
{
Expand Down Expand Up @@ -241,10 +246,46 @@ private static DiscoveredJobItem ToItem(JobPosting job, GhostScore score)
RiskBrush = brush,
Evidence = evidence,
EvidenceVisibility = string.IsNullOrEmpty(evidence) ? Visibility.Collapsed : Visibility.Visible,
Url = job.Url
Url = job.Url,
Posting = job,
Snapshot = new GhostScoreSnapshot(score.RiskScore, score.Band.ToString(), score.TopEvidence)
};
}

// Ledger bookkeeping is best-effort and off the UI thread; a bookkeeping
// failure must never break the search results on screen.
private void RecordSightings(List<DiscoveredJobItem> items)
{
var sightings = items
.Where(i => i.Posting != null)
.Select(i => JobEvent.ForPosting(
JobEventType.Sighted,
i.Posting!.Url, i.Posting.JobTitle, i.Posting.CompanyName,
i.Posting.Source.ToString(), i.Snapshot))
.ToList();
if (sightings.Count == 0) return;

_ = Task.Run(async () =>
{
try { await _jobEvents.RecordSightingsAsync(sightings); }
catch { /* bookkeeping only */ }
});
}

private void RecordItemEvent(DiscoveredJobItem item, JobEventType type)
{
if (item.Posting == null) return;
var jobEvent = JobEvent.ForPosting(
type, item.Posting.Url, item.Posting.JobTitle, item.Posting.CompanyName,
item.Posting.Source.ToString(), item.Snapshot);

_ = Task.Run(async () =>
{
try { await _jobEvents.AddAsync(jobEvent); }
catch { /* bookkeeping only */ }
});
}

private void CmbSort_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_lastItems.Count == 0 || CmbSort?.SelectedItem == null) return;
Expand All @@ -262,13 +303,33 @@ private void CmbSort_SelectionChanged(object sender, SelectionChangedEventArgs e

private void BtnView_Click(object sender, RoutedEventArgs e)
{
if (sender is Button btn && btn.Tag is string url && !string.IsNullOrWhiteSpace(url))
if (sender is Button btn && btn.Tag is DiscoveredJobItem item && !string.IsNullOrWhiteSpace(item.Url))
{
try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
RecordItemEvent(item, JobEventType.Viewed);
try { Process.Start(new ProcessStartInfo(item.Url) { UseShellExecute = true }); }
catch (Exception ex) { ShowError($"Could not open link: {ex.Message}"); }
}
}

private void BtnSkip_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button btn || btn.Tag is not DiscoveredJobItem item) return;

RecordItemEvent(item, JobEventType.Skipped);

// Drop the row but keep whatever order is currently on screen.
_lastItems.Remove(item);
var current = (ResultsList.ItemsSource as IEnumerable<DiscoveredJobItem>)?.Where(i => i != item).ToList()
?? _lastItems.ToList();
ResultsList.ItemsSource = current;

var flagged = item.Snapshot?.Band is "High" or "Elevated";
StatusText.Text = flagged
? $"✓ GHOST DODGED · {item.RiskText} · {item.Company}"
: $"SKIPPED · {item.Company}";
StatusText.Foreground = flagged ? Green : Gray;
}

private void SetBusy(bool busy, string? message = null)
{
BtnScanBoards.IsEnabled = !busy;
Expand Down Expand Up @@ -298,6 +359,11 @@ public class DiscoveredJobItem
public string Evidence { get; init; } = "";
public Visibility EvidenceVisibility { get; init; } = Visibility.Collapsed;
public string Url { get; init; } = "";

// Raw posting + score snapshot so user actions on this row can be recorded
// in the ledger with the evidence that was on screen.
public JobPosting? Posting { get; init; }
public GhostScoreSnapshot? Snapshot { get; init; }
}

public class BoardListItem
Expand Down
52 changes: 50 additions & 2 deletions tests/Envoy.Core.Tests/JobEventRepositoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,21 @@ public JobEventRepositoryTests()

public void Dispose() => _connection.Dispose();

private static JobEvent Event(JobEventType type, DateTime occurredAt) => new()
private static JobEvent Event(JobEventType type, DateTime occurredAt, string postingKey = "boards.greenhouse.io/acme/jobs/123") => new()
{
Type = type,
OccurredAt = occurredAt,
JobUrl = "https://boards.greenhouse.io/acme/jobs/123",
JobTitle = "Engineer",
Company = "Acme",
PostingKey = "boards.greenhouse.io/acme/jobs/123",
PostingKey = postingKey,
RiskBand = "High",
RiskScore = 80
};

private static JobEvent Sighting(string postingKey, DateTime? occurredAt = null) =>
Event(JobEventType.Sighted, occurredAt ?? DateTime.UtcNow, postingKey);

[Fact]
public async Task AddAndGetAll_RoundTrips_NewestFirst()
{
Expand Down Expand Up @@ -84,4 +87,49 @@ public async Task CountByType_CountsOnlyMatchingEvents()
Assert.Equal(1, await _repo.CountByTypeAsync(JobEventType.Declined));
Assert.Equal(0, await _repo.CountByTypeAsync(JobEventType.Skipped));
}

[Fact]
public async Task RecordSightings_InsertsFresh_SkipsSameDayDuplicates()
{
var first = await _repo.RecordSightingsAsync(new[]
{
Sighting("key-a"),
Sighting("key-b"),
Sighting("key-a") // duplicate inside the same batch
});
Assert.Equal(2, first);

var second = await _repo.RecordSightingsAsync(new[]
{
Sighting("key-a"), // already sighted today
Sighting("key-c")
});
Assert.Equal(1, second);

Assert.Equal(3, await _repo.CountByTypeAsync(JobEventType.Sighted));
}

[Fact]
public async Task RecordSightings_SamePostingOnALaterDay_RecordsAgain()
{
await _repo.AddAsync(Sighting("key-a", DateTime.UtcNow.AddDays(-1)));

var inserted = await _repo.RecordSightingsAsync(new[] { Sighting("key-a") });

Assert.Equal(1, inserted);
Assert.Equal(2, await _repo.CountByTypeAsync(JobEventType.Sighted));
}

[Fact]
public async Task RecordSightings_IgnoresNonSightedEvents()
{
var inserted = await _repo.RecordSightingsAsync(new[]
{
Event(JobEventType.Skipped, DateTime.UtcNow),
Event(JobEventType.Viewed, DateTime.UtcNow)
});

Assert.Equal(0, inserted);
Assert.Empty(await _repo.GetAllAsync());
}
}
16 changes: 16 additions & 0 deletions tests/Envoy.Core.Tests/JobEventTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ public void MachineOutcomes_ProduceNoLedgerEvent(ApplicationStatus status)
Assert.Null(JobEvent.FromApplication(Log(status), Snapshot));
}

[Fact]
public void ForPosting_BuildsIdentityKeySourceAndScoreFields()
{
var jobEvent = JobEvent.ForPosting(
JobEventType.Skipped,
"https://boards.greenhouse.io/acme/jobs/123?utm_source=feed",
"Engineer", "Acme", "Greenhouse", Snapshot);

Assert.Equal(JobEventType.Skipped, jobEvent.Type);
Assert.Equal("boards.greenhouse.io/acme/jobs/123", jobEvent.PostingKey);
Assert.Equal("Greenhouse", jobEvent.Source);
Assert.Equal(72.5, jobEvent.RiskScore);
Assert.Equal("High", jobEvent.RiskBand);
Assert.Null(jobEvent.ApplicationLogId);
}

[Fact]
public void UnscoredPosting_LeavesRiskFieldsNull()
{
Expand Down