From 5f4818d0b8ed21de8ed1ccdfe48a6342bdf18991 Mon Sep 17 00:00:00 2001 From: LXBStudioLLC Date: Sat, 18 Jul 2026 15:04:35 -0400 Subject: [PATCH] feat(core): job-event ledger, declined status, ghost score on submit logs The scoreboard needs raw events the app was throwing away. This adds the append-only JobEvents table: a completed submit 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. The same table starts the cross-session listing history the repost-frequency signal needs for its stronger path. Cancelling at the gate now gets its own ApplicationStatus (DeclinedByUser, appended after SafeModeStopped so stored integers keep their meaning) instead of sharing SafeModeStopped with the safety-check auto-halt. Application logs also store the score and band at submit time, and the Apply view passes its scored result through instead of discarding it. PostingKey builds a stable cross-session posting identity from the URL (host, path, canonicalized query, tracking params stripped) with a company|title fallback. Migration AddJobEventsAndGhostRisk: two nullable columns on ApplicationLogs plus the JobEvents table and its indexes. Verified the full chain applies cleanly to a fresh SQLite file. --- AGENTS.md | 1 + CHANGELOG.md | 1 + src/Envoy.Core/Data/EnvoyDbContext.cs | 11 + ...90253_AddJobEventsAndGhostRisk.Designer.cs | 346 ++++++++++++++++++ ...20260718190253_AddJobEventsAndGhostRisk.cs | 74 ++++ .../Migrations/EnvoyDbContextModelSnapshot.cs | 59 +++ src/Envoy.Core/Models/ApplicationLog.cs | 11 +- src/Envoy.Core/Models/GhostScoreSnapshot.cs | 9 + src/Envoy.Core/Models/JobEvent.cs | 93 +++++ .../Services/ApplicationOrchestrator.cs | 41 ++- src/Envoy.Core/Services/PostingKey.cs | 63 ++++ src/Envoy.Core/Services/Repositories.cs | 43 +++ .../Services/ServiceRegistration.cs | 1 + src/Envoy.UI/ApplyView.xaml.cs | 21 +- .../JobEventRepositoryTests.cs | 87 +++++ tests/Envoy.Core.Tests/JobEventTests.cs | 93 +++++ tests/Envoy.Core.Tests/PostingKeyTests.cs | 48 +++ 17 files changed, 992 insertions(+), 10 deletions(-) create mode 100644 src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.Designer.cs create mode 100644 src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.cs create mode 100644 src/Envoy.Core/Models/GhostScoreSnapshot.cs create mode 100644 src/Envoy.Core/Models/JobEvent.cs create mode 100644 src/Envoy.Core/Services/PostingKey.cs create mode 100644 tests/Envoy.Core.Tests/JobEventRepositoryTests.cs create mode 100644 tests/Envoy.Core.Tests/JobEventTests.cs create mode 100644 tests/Envoy.Core.Tests/PostingKeyTests.cs diff --git a/AGENTS.md b/AGENTS.md index e87442f..4f988e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ src/ OllamaService.cs — local LLM inference wrapper Models/ ApplicationLog.cs + JobEvent.cs — append-only activity ledger (scoreboard stats + cross-session listing history) MasterProfile.cs TailoredProfile.cs Envoy.GhostDetection/ NEW — ghost-job detection framework diff --git a/CHANGELOG.md b/CHANGELOG.md index 69fb5c8..03d1426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Envoy are documented in this file. Format is based on [Ke ## [Unreleased] ### Added +- 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. ## [1.0.3] - 2026-07-14 diff --git a/src/Envoy.Core/Data/EnvoyDbContext.cs b/src/Envoy.Core/Data/EnvoyDbContext.cs index 3b34b5d..586c69d 100644 --- a/src/Envoy.Core/Data/EnvoyDbContext.cs +++ b/src/Envoy.Core/Data/EnvoyDbContext.cs @@ -9,6 +9,7 @@ public class EnvoyDbContext : DbContext public DbSet MasterProfiles { get; set; } = null!; public DbSet TailoredProfiles { get; set; } = null!; public DbSet ApplicationLogs { get; set; } = null!; + public DbSet JobEvents { get; set; } = null!; public string DbPath { get; } @@ -123,6 +124,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id); entity.Property(e => e.JobUrl).IsRequired(); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.PostingKey).IsRequired(); + // The ledger is read two ways: "this posting's history" (dodge + // receipts, repost detection) and "recent activity" (scoreboard). + entity.HasIndex(e => e.PostingKey); + entity.HasIndex(e => e.OccurredAt); + }); } // Helper used by ValueConverter expressions on JSON columns. If a row diff --git a/src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.Designer.cs b/src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.Designer.cs new file mode 100644 index 0000000..28073ee --- /dev/null +++ b/src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.Designer.cs @@ -0,0 +1,346 @@ +// +using System; +using Envoy.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Envoy.Core.Data.Migrations +{ + [DbContext(typeof(EnvoyDbContext))] + [Migration("20260718190253_AddJobEventsAndGhostRisk")] + partial class AddJobEventsAndGhostRisk + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("Envoy.Core.Models.ApplicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AfterScreenshot") + .HasColumnType("BLOB"); + + b.Property("BeforeScreenshot") + .HasColumnType("BLOB"); + + b.Property("Company") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("GhostRiskBand") + .HasColumnType("TEXT"); + + b.Property("GhostRiskScore") + .HasColumnType("REAL"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Mode") + .HasColumnType("INTEGER"); + + b.Property("SiteTemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TailoredProfileId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApplicationLogs"); + }); + + modelBuilder.Entity("Envoy.Core.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationLogId") + .HasColumnType("TEXT"); + + b.Property("Company") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Evidence") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OccurredAt") + .HasColumnType("TEXT"); + + b.Property("PostingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskBand") + .HasColumnType("TEXT"); + + b.Property("RiskScore") + .HasColumnType("REAL"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("PostingKey"); + + b.ToTable("JobEvents"); + }); + + modelBuilder.Entity("Envoy.Core.Models.MasterProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Anomalies") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LinkedIn") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParseConfidence") + .HasColumnType("REAL"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Website") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MasterProfiles"); + }); + + modelBuilder.Entity("Envoy.Core.Models.TailoredProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChangesMade") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Company") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("JobDescriptionText") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MasterProfileId") + .HasColumnType("TEXT"); + + b.Property("MatchScore") + .HasColumnType("REAL"); + + b.Property("SafetyResult") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TailoredData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TailoredProfiles"); + }); + + modelBuilder.Entity("Envoy.Core.Models.MasterProfile", b => + { + b.OwnsMany("Envoy.Core.Models.EducationEntry", "Education", b1 => + { + b1.Property("MasterProfileId") + .HasColumnType("TEXT"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b1.Property("Degree") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("GraduationDate") + .HasColumnType("TEXT"); + + b1.Property("Institution") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Location") + .HasColumnType("TEXT"); + + b1.HasKey("MasterProfileId", "Id"); + + b1.ToTable("EducationEntry"); + + b1.WithOwner() + .HasForeignKey("MasterProfileId"); + }); + + b.OwnsMany("Envoy.Core.Models.ExperienceEntry", "Experience", b1 => + { + b1.Property("MasterProfileId") + .HasColumnType("TEXT"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b1.Property("Bullets") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Company") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("EndDate") + .HasColumnType("TEXT"); + + b1.Property("IsCurrent") + .HasColumnType("INTEGER"); + + b1.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Location") + .HasColumnType("TEXT"); + + b1.Property("StartDate") + .HasColumnType("TEXT"); + + b1.HasKey("MasterProfileId", "Id"); + + b1.ToTable("ExperienceEntry"); + + b1.WithOwner() + .HasForeignKey("MasterProfileId"); + }); + + b.OwnsMany("Envoy.Core.Models.ProjectEntry", "Projects", b1 => + { + b1.Property("MasterProfileId") + .HasColumnType("TEXT"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b1.Property("Description") + .HasColumnType("TEXT"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Technologies") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("MasterProfileId", "Id"); + + b1.ToTable("ProjectEntry"); + + b1.WithOwner() + .HasForeignKey("MasterProfileId"); + }); + + b.Navigation("Education"); + + b.Navigation("Experience"); + + b.Navigation("Projects"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.cs b/src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.cs new file mode 100644 index 0000000..a573cda --- /dev/null +++ b/src/Envoy.Core/Data/Migrations/20260718190253_AddJobEventsAndGhostRisk.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Envoy.Core.Data.Migrations +{ + /// + public partial class AddJobEventsAndGhostRisk : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GhostRiskBand", + table: "ApplicationLogs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "GhostRiskScore", + table: "ApplicationLogs", + type: "REAL", + nullable: true); + + migrationBuilder.CreateTable( + name: "JobEvents", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + OccurredAt = table.Column(type: "TEXT", nullable: false), + JobUrl = table.Column(type: "TEXT", nullable: false), + JobTitle = table.Column(type: "TEXT", nullable: false), + Company = table.Column(type: "TEXT", nullable: false), + PostingKey = table.Column(type: "TEXT", nullable: false), + Source = table.Column(type: "TEXT", nullable: false), + RiskScore = table.Column(type: "REAL", nullable: true), + RiskBand = table.Column(type: "TEXT", nullable: true), + Evidence = table.Column(type: "TEXT", nullable: true), + ApplicationLogId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_JobEvents", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_JobEvents_OccurredAt", + table: "JobEvents", + column: "OccurredAt"); + + migrationBuilder.CreateIndex( + name: "IX_JobEvents_PostingKey", + table: "JobEvents", + column: "PostingKey"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobEvents"); + + migrationBuilder.DropColumn( + name: "GhostRiskBand", + table: "ApplicationLogs"); + + migrationBuilder.DropColumn( + name: "GhostRiskScore", + table: "ApplicationLogs"); + } + } +} diff --git a/src/Envoy.Core/Data/Migrations/EnvoyDbContextModelSnapshot.cs b/src/Envoy.Core/Data/Migrations/EnvoyDbContextModelSnapshot.cs index b439cb8..9037940 100644 --- a/src/Envoy.Core/Data/Migrations/EnvoyDbContextModelSnapshot.cs +++ b/src/Envoy.Core/Data/Migrations/EnvoyDbContextModelSnapshot.cs @@ -39,6 +39,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ErrorMessage") .HasColumnType("TEXT"); + b.Property("GhostRiskBand") + .HasColumnType("TEXT"); + + b.Property("GhostRiskScore") + .HasColumnType("REAL"); + b.Property("JobTitle") .IsRequired() .HasColumnType("TEXT"); @@ -68,6 +74,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ApplicationLogs"); }); + modelBuilder.Entity("Envoy.Core.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationLogId") + .HasColumnType("TEXT"); + + b.Property("Company") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Evidence") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OccurredAt") + .HasColumnType("TEXT"); + + b.Property("PostingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RiskBand") + .HasColumnType("TEXT"); + + b.Property("RiskScore") + .HasColumnType("REAL"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("PostingKey"); + + b.ToTable("JobEvents"); + }); + modelBuilder.Entity("Envoy.Core.Models.MasterProfile", b => { b.Property("Id") diff --git a/src/Envoy.Core/Models/ApplicationLog.cs b/src/Envoy.Core/Models/ApplicationLog.cs index 403159b..2120fcc 100644 --- a/src/Envoy.Core/Models/ApplicationLog.cs +++ b/src/Envoy.Core/Models/ApplicationLog.cs @@ -15,8 +15,14 @@ public class ApplicationLog public DateTime StartedAt { get; set; } = DateTime.UtcNow; public DateTime? CompletedAt { get; set; } public ExecutionMode Mode { get; set; } + + // Ghost-risk snapshot at the moment of submit; null when the posting was + // never scored. Band is text so stored rows survive enum renumbering. + public double? GhostRiskScore { get; set; } + public string? GhostRiskBand { get; set; } } +// Persisted as integers in envoy.db — append new members at the end, never reorder. public enum ApplicationStatus { Pending, @@ -25,7 +31,10 @@ public enum ApplicationStatus Failed, RequiresCaptcha, Blocked, - SafeModeStopped + SafeModeStopped, + + /// The user reviewed the filled application at the submit gate and chose not to send it. + DeclinedByUser } public enum ExecutionMode diff --git a/src/Envoy.Core/Models/GhostScoreSnapshot.cs b/src/Envoy.Core/Models/GhostScoreSnapshot.cs new file mode 100644 index 0000000..a4dedb5 --- /dev/null +++ b/src/Envoy.Core/Models/GhostScoreSnapshot.cs @@ -0,0 +1,9 @@ +namespace Envoy.Core.Models; + +/// +/// A ghost-detection result at the moment the user acted on a posting, carried +/// into Envoy.Core without a project reference to Envoy.GhostDetection. The +/// band travels as text ("Neutral" / "Elevated" / "High") because it outlives +/// the process in the database, where an enum renumbering must not re-label it. +/// +public record GhostScoreSnapshot(double RiskScore, string Band, string[] TopEvidence); diff --git a/src/Envoy.Core/Models/JobEvent.cs b/src/Envoy.Core/Models/JobEvent.cs new file mode 100644 index 0000000..6a3e221 --- /dev/null +++ b/src/Envoy.Core/Models/JobEvent.cs @@ -0,0 +1,93 @@ +namespace Envoy.Core.Models; + +/// +/// One row in the append-only activity ledger behind the scoreboard. Every +/// decision the user makes about a posting (and, later, every scored sighting) +/// is recorded with the ghost-risk evidence that was on screen at the time, so +/// stats like "ghosts dodged" are backed by receipts instead of invented +/// numbers. The same table is the cross-session listing history the +/// repost-frequency signal needs for its stronger detection path. +/// +public class JobEvent +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public JobEventType Type { get; set; } + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; + + // Identity snapshot of the posting. Postings are transient upstream (the + // discovery cache is in-memory only), so each event carries its own copy. + public string JobUrl { get; set; } = string.Empty; + public string JobTitle { get; set; } = string.Empty; + public string Company { get; set; } = string.Empty; + + /// Stable cross-session identity from . + public string PostingKey { get; set; } = string.Empty; + + /// Feed the posting came from (e.g. "Greenhouse"). Empty when unknown. + public string Source { get; set; } = string.Empty; + + // Ghost-risk snapshot at the moment of the event; null when the posting was + // never scored. The band is text ("Neutral"/"Elevated"/"High") so stored + // rows can't be re-labeled by a later renumbering of the source enum. + public double? RiskScore { get; set; } + public string? RiskBand { get; set; } + public string? Evidence { get; set; } + + /// Links Applied/Declined events back to their submit log. + public Guid? ApplicationLogId { get; set; } + + /// + /// Maps a finished submit-flow log to its ledger event: a completed submit + /// becomes , a user cancel at the gate + /// becomes . Every other terminal status + /// (failure, CAPTCHA, safety halt) is a machine outcome, not a user + /// decision, and produces no event. + /// + public static JobEvent? FromApplication(ApplicationLog log, GhostScoreSnapshot? ghostScore) + { + JobEventType type; + switch (log.Status) + { + case ApplicationStatus.Completed: type = JobEventType.Applied; break; + case ApplicationStatus.DeclinedByUser: type = JobEventType.Declined; break; + 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 + }; + } +} + +/// +/// What happened. Values are persisted as integers in envoy.db — append new +/// members at the end, never reorder. +/// +public enum JobEventType +{ + /// Posting was scored and shown to the user in a results list. + Sighted, + + /// User opened the posting to look at it. + Viewed, + + /// User explicitly passed on the posting from a results list. + Skipped, + + /// User reviewed the filled application and cancelled at the submit gate. + Declined, + + /// Application was submitted. + Applied +} diff --git a/src/Envoy.Core/Services/ApplicationOrchestrator.cs b/src/Envoy.Core/Services/ApplicationOrchestrator.cs index c4bcce9..474db2b 100644 --- a/src/Envoy.Core/Services/ApplicationOrchestrator.cs +++ b/src/Envoy.Core/Services/ApplicationOrchestrator.cs @@ -14,6 +14,7 @@ public class ApplicationOrchestrator private readonly IProfileRepository _profileRepo; private readonly ITailoredProfileRepository _tailoredRepo; private readonly IApplicationLogRepository _logRepo; + private readonly IJobEventRepository _eventRepo; private readonly IBrowserLauncher _browserLauncher; private readonly EnvoySettings _settings; private readonly ILogger _log; @@ -27,6 +28,7 @@ public ApplicationOrchestrator( IProfileRepository profileRepo, ITailoredProfileRepository tailoredRepo, IApplicationLogRepository logRepo, + IJobEventRepository eventRepo, IBrowserLauncher browserLauncher, EnvoySettings settings, ILogger log) @@ -39,6 +41,7 @@ public ApplicationOrchestrator( _profileRepo = profileRepo; _tailoredRepo = tailoredRepo; _logRepo = logRepo; + _eventRepo = eventRepo; _browserLauncher = browserLauncher; _settings = settings; _log = log; @@ -129,6 +132,7 @@ public async Task SubmitApplicationAsync( Guid tailoredProfileId, ExecutionMode mode, Func> onConfirmationRequired, + GhostScoreSnapshot? ghostScore = null, CancellationToken ct = default) { var tailored = await _tailoredRepo.GetByIdAsync(tailoredProfileId, ct) @@ -141,7 +145,9 @@ public async Task SubmitApplicationAsync( JobTitle = tailored.JobTitle, Company = tailored.Company, Mode = mode, - Status = ApplicationStatus.InProgress + Status = ApplicationStatus.InProgress, + GhostRiskScore = ghostScore?.RiskScore, + GhostRiskBand = ghostScore?.Band }; await _logRepo.AddAsync(log, ct); @@ -220,11 +226,13 @@ await _templates.ExecuteTemplateAsync(template, _browser, tailored, async msg => await _logRepo.UpdateAsync(log, ct); var approved = await onConfirmationRequired(msg); - if (approved) - { - log.Status = ApplicationStatus.InProgress; - await _logRepo.UpdateAsync(log, ct); - } + // A "no" at the gate is a deliberate decision by a human who just + // read the evidence — record it as its own status, distinct from + // the safety-check auto-halt that shares this callback shape. + log.Status = approved + ? ApplicationStatus.InProgress + : ApplicationStatus.DeclinedByUser; + await _logRepo.UpdateAsync(log, ct); return approved; }, ct); @@ -232,7 +240,8 @@ await _templates.ExecuteTemplateAsync(template, _browser, tailored, async msg => if (_settings.CaptureScreenshots) log.AfterScreenshot = await _browser.CaptureScreenshotAsync(ct); - if (log.Status != ApplicationStatus.SafeModeStopped) + if (log.Status != ApplicationStatus.SafeModeStopped + && log.Status != ApplicationStatus.DeclinedByUser) { log.Status = ApplicationStatus.Completed; } @@ -249,6 +258,24 @@ await _templates.ExecuteTemplateAsync(template, _browser, tailored, async msg => await _browser.CloseAsync(CancellationToken.None); } + await RecordLedgerEventAsync(log, ghostScore); + return log; } + + // The scoreboard ledger is bookkeeping — failing to record it must never + // change the outcome of a submit flow that already ran. + private async Task RecordLedgerEventAsync(ApplicationLog log, GhostScoreSnapshot? ghostScore) + { + try + { + var jobEvent = JobEvent.FromApplication(log, ghostScore); + if (jobEvent != null) + await _eventRepo.AddAsync(jobEvent, CancellationToken.None); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Failed to record scoreboard event for application {LogId}", log.Id); + } + } } diff --git a/src/Envoy.Core/Services/PostingKey.cs b/src/Envoy.Core/Services/PostingKey.cs new file mode 100644 index 0000000..7e25ebe --- /dev/null +++ b/src/Envoy.Core/Services/PostingKey.cs @@ -0,0 +1,63 @@ +namespace Envoy.Core.Services; + +/// +/// Builds the stable identity key that lets Envoy recognize the same posting +/// across sessions (the scoreboard ledger today, repost-frequency history +/// later). Prefers the URL with host, path, and query canonicalized and +/// tracking noise stripped; falls back to company + title when there is no +/// usable URL. +/// +public static class PostingKey +{ + // Query parameters that vary per click without changing which job the URL + // points at. Meaningful ids (e.g. Greenhouse's gh_jid) are kept. + private static readonly HashSet TrackingParams = new(StringComparer.OrdinalIgnoreCase) + { + "ref", "referrer", "source", "src", "gclid", "fbclid", "mc_cid", "mc_eid" + }; + + public static string For(string? jobUrl, string? company, string? jobTitle) + { + if (!string.IsNullOrWhiteSpace(jobUrl) + && Uri.TryCreate(jobUrl.Trim(), UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + var host = uri.Host.ToLowerInvariant(); + if (host.StartsWith("www.", StringComparison.Ordinal)) + host = host[4..]; + var path = uri.AbsolutePath.TrimEnd('/').ToLowerInvariant(); + var query = CanonicalQuery(uri.Query.ToLowerInvariant()); + return host + path + query; + } + + // No usable URL: normalized company|title pair. Distinct unknown + // postings can collide here; acceptable for stats, and better than + // dropping the event. + return Normalize(company) + "|" + Normalize(jobTitle); + } + + // Drops tracking parameters and sorts the survivors so the same posting + // reached via reordered or campaign-decorated links produces one key. + private static string CanonicalQuery(string query) + { + if (string.IsNullOrEmpty(query) || query == "?") return ""; + + var kept = query.TrimStart('?') + .Split('&', StringSplitOptions.RemoveEmptyEntries) + .Where(pair => + { + var name = pair.Split('=', 2)[0]; + return name.Length > 0 + && !TrackingParams.Contains(name) + && !name.StartsWith("utm_", StringComparison.Ordinal); + }) + .OrderBy(pair => pair, StringComparer.Ordinal) + .ToArray(); + + return kept.Length == 0 ? "" : "?" + string.Join("&", kept); + } + + private static string Normalize(string? value) => + string.Join(' ', (value ?? "").ToLowerInvariant() + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); +} diff --git a/src/Envoy.Core/Services/Repositories.cs b/src/Envoy.Core/Services/Repositories.cs index f2aecbb..900c5b7 100644 --- a/src/Envoy.Core/Services/Repositories.cs +++ b/src/Envoy.Core/Services/Repositories.cs @@ -229,3 +229,46 @@ public async Task UpdateAsync(ApplicationLog log, CancellationToken ct = default .FirstOrDefaultAsync(l => l.TailoredProfileId == tailoredProfileId, ct); } } + +public interface IJobEventRepository +{ + Task AddAsync(JobEvent jobEvent, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task CountByTypeAsync(JobEventType type, CancellationToken ct = default); +} + +public class JobEventRepository : IJobEventRepository +{ + private readonly IDbContextFactory _factory; + private readonly ILogger _log; + + public JobEventRepository(IDbContextFactory factory, ILogger log) + { + _factory = factory; + _log = log; + } + + public async Task AddAsync(JobEvent jobEvent, CancellationToken ct = default) + { + using var db = _factory.CreateDbContext(); + db.JobEvents.Add(jobEvent); + await db.SaveChangesAsync(ct); + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + using var db = _factory.CreateDbContext(); + return await db.JobEvents + .AsNoTracking() + .OrderByDescending(e => e.OccurredAt) + .ToListAsync(ct); + } + + public async Task CountByTypeAsync(JobEventType type, CancellationToken ct = default) + { + using var db = _factory.CreateDbContext(); + return await db.JobEvents + .AsNoTracking() + .CountAsync(e => e.Type == type, ct); + } +} diff --git a/src/Envoy.Core/Services/ServiceRegistration.cs b/src/Envoy.Core/Services/ServiceRegistration.cs index 6a62809..233c84e 100644 --- a/src/Envoy.Core/Services/ServiceRegistration.cs +++ b/src/Envoy.Core/Services/ServiceRegistration.cs @@ -20,6 +20,7 @@ public static IServiceCollection AddEnvoyCore(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Envoy.UI/ApplyView.xaml.cs b/src/Envoy.UI/ApplyView.xaml.cs index 389bf28..55d2e04 100644 --- a/src/Envoy.UI/ApplyView.xaml.cs +++ b/src/Envoy.UI/ApplyView.xaml.cs @@ -17,6 +17,7 @@ public partial class ApplyView : UserControl private Guid _profileId; private TailoredProfile? _tailored; private string _jobDescription = ""; + private GhostScore? _ghostScore; private TaskCompletionSource? _confirmTcs; public ApplyView(ApplicationOrchestrator orchestrator, GhostScorer ghostScorer, EnvoySettings settings) @@ -43,6 +44,7 @@ public void SetProfileId(Guid profileId) _profileId = profileId; _tailored = null; _jobDescription = ""; + _ghostScore = null; // Clear inputs left over from the previous profile so two job apps // for two different people don't accidentally cross-pollinate. TxtJobUrl.Text = ""; @@ -118,6 +120,9 @@ private async Task ScoreGhostRiskAsync(string jobUrl) }; var score = await _ghostScorer.ScoreAsync(posting); + // Held so the score in front of the user travels with the submit — + // the log and the ledger record what the decision was made against. + _ghostScore = score; var (badge, label) = score.Band switch { @@ -135,6 +140,7 @@ private async Task ScoreGhostRiskAsync(string jobUrl) } catch { + _ghostScore = null; GhostRiskPanel.Visibility = Visibility.Collapsed; } } @@ -174,18 +180,29 @@ private async void BtnExecute_Click(object sender, RoutedEventArgs e) StatusText.Text = "ESTABLISHING CONNECTION..."; StatusText.Foreground = Cyan; + var snapshot = _ghostScore == null + ? null + : new GhostScoreSnapshot(_ghostScore.RiskScore, _ghostScore.Band.ToString(), _ghostScore.TopEvidence); + var log = await _orchestrator.SubmitApplicationAsync( - _tailored.Id, mode, RequestSubmitConfirmationAsync); + _tailored.Id, mode, RequestSubmitConfirmationAsync, snapshot); StatusText.Text = log.Status switch { ApplicationStatus.Completed => "✓ MISSION ACCOMPLISHED", + ApplicationStatus.DeclinedByUser => "⏸ DECLINED — NOTHING WAS SENT", ApplicationStatus.SafeModeStopped => "⏸ SUBMISSION HELD — NOT SUBMITTED", ApplicationStatus.RequiresCaptcha => "🧩 CAPTCHA DETECTED — HUMAN INPUT NEEDED", ApplicationStatus.Failed => $"✕ FAILED: {log.ErrorMessage}", _ => $"STATUS: {log.Status}" }; - StatusText.Foreground = log.Status == ApplicationStatus.Completed ? Green : Red; + // Declining is a decision, not a failure — don't paint it error-red. + StatusText.Foreground = log.Status switch + { + ApplicationStatus.Completed => Green, + ApplicationStatus.DeclinedByUser => Yellow, + _ => Red + }; } catch (Exception ex) { diff --git a/tests/Envoy.Core.Tests/JobEventRepositoryTests.cs b/tests/Envoy.Core.Tests/JobEventRepositoryTests.cs new file mode 100644 index 0000000..aea9feb --- /dev/null +++ b/tests/Envoy.Core.Tests/JobEventRepositoryTests.cs @@ -0,0 +1,87 @@ +using Envoy.Core.Data; +using Envoy.Core.Models; +using Envoy.Core.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Envoy.Core.Tests; + +public class JobEventRepositoryTests : IDisposable +{ + // Shared open connection keeps the in-memory database alive across the + // short-lived contexts the repository creates per operation. + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(SqliteConnection connection) + { + _options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + using var db = new EnvoyDbContext(_options); + db.Database.EnsureCreated(); + } + + public EnvoyDbContext CreateDbContext() => new(_options); + } + + private readonly SqliteConnection _connection; + private readonly JobEventRepository _repo; + + public JobEventRepositoryTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + _repo = new JobEventRepository( + new TestDbContextFactory(_connection), + Mock.Of>()); + } + + public void Dispose() => _connection.Dispose(); + + private static JobEvent Event(JobEventType type, DateTime occurredAt) => new() + { + Type = type, + OccurredAt = occurredAt, + JobUrl = "https://boards.greenhouse.io/acme/jobs/123", + JobTitle = "Engineer", + Company = "Acme", + PostingKey = "boards.greenhouse.io/acme/jobs/123", + RiskBand = "High", + RiskScore = 80 + }; + + [Fact] + public async Task AddAndGetAll_RoundTrips_NewestFirst() + { + var older = Event(JobEventType.Applied, new DateTime(2026, 7, 1, 9, 0, 0, DateTimeKind.Utc)); + var newer = Event(JobEventType.Declined, new DateTime(2026, 7, 2, 9, 0, 0, DateTimeKind.Utc)); + await _repo.AddAsync(older); + await _repo.AddAsync(newer); + + var all = await _repo.GetAllAsync(); + + Assert.Equal(2, all.Count); + Assert.Equal(newer.Id, all[0].Id); + Assert.Equal(older.Id, all[1].Id); + Assert.Equal("High", all[0].RiskBand); + Assert.Equal(80, all[0].RiskScore); + } + + [Fact] + public async Task CountByType_CountsOnlyMatchingEvents() + { + var now = DateTime.UtcNow; + await _repo.AddAsync(Event(JobEventType.Applied, now)); + await _repo.AddAsync(Event(JobEventType.Applied, now)); + await _repo.AddAsync(Event(JobEventType.Declined, now)); + + Assert.Equal(2, await _repo.CountByTypeAsync(JobEventType.Applied)); + Assert.Equal(1, await _repo.CountByTypeAsync(JobEventType.Declined)); + Assert.Equal(0, await _repo.CountByTypeAsync(JobEventType.Skipped)); + } +} diff --git a/tests/Envoy.Core.Tests/JobEventTests.cs b/tests/Envoy.Core.Tests/JobEventTests.cs new file mode 100644 index 0000000..f5b746a --- /dev/null +++ b/tests/Envoy.Core.Tests/JobEventTests.cs @@ -0,0 +1,93 @@ +using Envoy.Core.Models; +using Xunit; + +namespace Envoy.Core.Tests; + +public class JobEventTests +{ + private static ApplicationLog Log(ApplicationStatus status) => new() + { + Status = status, + JobUrl = "https://boards.greenhouse.io/acme/jobs/123", + JobTitle = "Engineer", + Company = "Acme" + }; + + private static readonly GhostScoreSnapshot Snapshot = + new(72.5, "High", new[] { "Closed on the company ATS", "Live for 94 days" }); + + [Fact] + public void CompletedSubmit_BecomesAppliedEvent_WithScoreReceipts() + { + var log = Log(ApplicationStatus.Completed); + + var jobEvent = JobEvent.FromApplication(log, Snapshot); + + Assert.NotNull(jobEvent); + Assert.Equal(JobEventType.Applied, jobEvent!.Type); + Assert.Equal(log.JobUrl, jobEvent.JobUrl); + Assert.Equal("Acme", jobEvent.Company); + Assert.Equal("boards.greenhouse.io/acme/jobs/123", jobEvent.PostingKey); + Assert.Equal(72.5, jobEvent.RiskScore); + Assert.Equal("High", jobEvent.RiskBand); + Assert.Equal("Closed on the company ATS\nLive for 94 days", jobEvent.Evidence); + Assert.Equal(log.Id, jobEvent.ApplicationLogId); + } + + [Fact] + public void DeclinedAtGate_BecomesDeclinedEvent() + { + var jobEvent = JobEvent.FromApplication(Log(ApplicationStatus.DeclinedByUser), Snapshot); + + Assert.NotNull(jobEvent); + Assert.Equal(JobEventType.Declined, jobEvent!.Type); + } + + [Theory] + [InlineData(ApplicationStatus.Pending)] + [InlineData(ApplicationStatus.InProgress)] + [InlineData(ApplicationStatus.Failed)] + [InlineData(ApplicationStatus.RequiresCaptcha)] + [InlineData(ApplicationStatus.Blocked)] + [InlineData(ApplicationStatus.SafeModeStopped)] + public void MachineOutcomes_ProduceNoLedgerEvent(ApplicationStatus status) + { + Assert.Null(JobEvent.FromApplication(Log(status), Snapshot)); + } + + [Fact] + public void UnscoredPosting_LeavesRiskFieldsNull() + { + var jobEvent = JobEvent.FromApplication(Log(ApplicationStatus.Completed), ghostScore: null); + + Assert.NotNull(jobEvent); + Assert.Null(jobEvent!.RiskScore); + Assert.Null(jobEvent.RiskBand); + Assert.Null(jobEvent.Evidence); + } + + // Both enums are persisted as integers in envoy.db. Pin the values so a + // reorder can't silently re-label rows that are already on disk. + [Fact] + public void ApplicationStatus_StoredValues_AreStable() + { + Assert.Equal(0, (int)ApplicationStatus.Pending); + Assert.Equal(1, (int)ApplicationStatus.InProgress); + Assert.Equal(2, (int)ApplicationStatus.Completed); + Assert.Equal(3, (int)ApplicationStatus.Failed); + Assert.Equal(4, (int)ApplicationStatus.RequiresCaptcha); + Assert.Equal(5, (int)ApplicationStatus.Blocked); + Assert.Equal(6, (int)ApplicationStatus.SafeModeStopped); + Assert.Equal(7, (int)ApplicationStatus.DeclinedByUser); + } + + [Fact] + public void JobEventType_StoredValues_AreStable() + { + Assert.Equal(0, (int)JobEventType.Sighted); + Assert.Equal(1, (int)JobEventType.Viewed); + Assert.Equal(2, (int)JobEventType.Skipped); + Assert.Equal(3, (int)JobEventType.Declined); + Assert.Equal(4, (int)JobEventType.Applied); + } +} diff --git a/tests/Envoy.Core.Tests/PostingKeyTests.cs b/tests/Envoy.Core.Tests/PostingKeyTests.cs new file mode 100644 index 0000000..ed8525d --- /dev/null +++ b/tests/Envoy.Core.Tests/PostingKeyTests.cs @@ -0,0 +1,48 @@ +using Envoy.Core.Services; +using Xunit; + +namespace Envoy.Core.Tests; + +public class PostingKeyTests +{ + [Fact] + public void SamePosting_ReachedViaTrackingNoise_ProducesOneKey() + { + var clean = PostingKey.For("https://boards.greenhouse.io/acme/jobs/123", "Acme", "Engineer"); + var noisy = PostingKey.For( + "https://www.Boards.Greenhouse.io/Acme/jobs/123/?utm_source=linkedin&ref=share&gclid=abc", + "Acme", "Engineer"); + + Assert.Equal(clean, noisy); + Assert.Equal("boards.greenhouse.io/acme/jobs/123", clean); + } + + [Fact] + public void QueryParameterOrder_DoesNotChangeTheKey() + { + var a = PostingKey.For("https://jobs.example.com/apply?a=1&b=2", null, null); + var b = PostingKey.For("https://jobs.example.com/apply?b=2&a=1", null, null); + + Assert.Equal(a, b); + } + + [Fact] + public void MeaningfulQueryParams_KeepDistinctJobsDistinct() + { + // Company career pages often carry the job id in the query (e.g. gh_jid). + var first = PostingKey.For("https://acme.com/careers?gh_jid=111", "Acme", "Engineer"); + var second = PostingKey.For("https://acme.com/careers?gh_jid=222", "Acme", "Engineer"); + + Assert.NotEqual(first, second); + } + + [Fact] + public void NoUsableUrl_FallsBackToNormalizedCompanyAndTitle() + { + var fromEmpty = PostingKey.For("", " Acme Corp ", "Senior Engineer"); + var fromMalformed = PostingKey.For("not a url", "acme corp", "senior engineer"); + + Assert.Equal("acme corp|senior engineer", fromEmpty); + Assert.Equal(fromEmpty, fromMalformed); + } +}