diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4f189e1..a8b82c2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## Project Overview -**ATS Scanner with Privacy** is a local, privacy-first resume scanner that simulates how applicant tracking systems (ATS) parse and score resumes. Users run this tool entirely on their own machine — no resume data is ever sent to external services. It supports analysis against the parsing behaviors of Workday, Greenhouse, Taleo, Lever, SuccessFactors, and Haufe-umantis. +**ATS Scanner with Privacy** is a local, privacy-first resume scanner that simulates how applicant tracking systems (ATS) parse and score resumes. Users run this tool entirely on their own machine — no resume data is ever sent to external services. It supports analysis against the parsing behaviors of Workday, Greenhouse, Taleo, Lever, SuccessFactors, Haufe-umantis, digitalent.ch, SmartRecruiters, Avature, Personio, prospective.ch, and refline.io. ## Tech Stack @@ -15,8 +15,11 @@ # Build dotnet build -# Run -dotnet run --project src/AtsScanner +# Run the CLI +dotnet run --project src/AtsScanner.Cli -- scan resume.pdf + +# Run the desktop GUI (Avalonia UI) +dotnet run --project src/AtsScanner.Gui # Run tests dotnet test @@ -26,20 +29,37 @@ dotnet test --filter "FullyQualifiedName~ClassName.MethodName" # Run tests for a specific project dotnet test tests/AtsScanner.Tests + +# Publish a self-contained single-file CLI binary (see DISTRIBUTION.md for other RIDs) +dotnet publish src/AtsScanner.Cli -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o ./dist/win-x64 ``` ## High-Level Architecture +The repository has three projects under `src/`: + +- **`AtsScanner.Core`** — the engine. Contains `Parsing` (PDF/DOCX → `ParsedResume`), `Profiles` (one class per ATS platform), `Models` (`ParsedResume`, `ScanResult`, `AtsPlatform`, etc.), and `Analysis` (runs a resume through one or more profiles). +- **`AtsScanner.Cli`** — Spectre.Console-based command line app (`ats-scanner scan [options]`). +- **`AtsScanner.Gui`** — Avalonia UI desktop app, built on the same `AtsScanner.Core` engine. + The scanner is structured around three core concerns: 1. **Document Parsing** — Extracts raw text, structure (headings, bullet points, tables), and metadata from resume files (PDF, DOCX). Lives in a dedicated parsing layer that returns a normalized `ParsedResume` model. -2. **ATS Platform Profiles** — Each supported platform (Workday, Greenhouse, Taleo, Lever, SuccessFactors) has its own scoring/analysis profile that encodes known quirks: field detection heuristics, keyword parsing rules, section recognition patterns, and formatting penalties. Profiles implement a shared `IAtsPlatformProfile` interface. +2. **ATS Platform Profiles** — Each supported platform has its own scoring/analysis profile that encodes known quirks: field detection heuristics, keyword parsing rules, section recognition patterns, and formatting penalties. Profiles implement a shared `IAtsPlatformProfile` interface and extend `BaseAtsPlatformProfile` for common helpers (`HasFormat`, `HasSection`, `HasContactInfo`, `HasValidDates`, `CalculateScore`, etc.). 3. **Analysis & Reporting** — The scanner runs a `ParsedResume` through one or more platform profiles and produces a structured `ScanResult` with per-field scores, warnings, and improvement suggestions. **Privacy boundary:** All processing is in-process. No HTTP calls to external services. No telemetry. No file uploads. +## Adding a New ATS Platform Profile + +1. Add the platform to the `AtsPlatform` enum (`src/AtsScanner.Core/Models/AtsPlatform.cs`). +2. Create `src/AtsScanner.Core/Profiles/Profile.cs` extending `BaseAtsPlatformProfile`, documenting the platform's real-world parsing quirks in an XML doc comment and encoding them as `ScanIssue`s in `Analyze`. +3. Register the new profile instance in `ProfileRegistry.All`, and add lowercase name/domain aliases to `ProfileRegistry.TryParse`. +4. Update the CLI `--platform` option description (`src/AtsScanner.Cli/Commands/ScanSettings.cs`) and the platform table / options table in `README.md`. +5. Add `tests/AtsScanner.Tests/Profiles/ProfileTests.cs` (mirror an existing profile's tests) and extend `ProfileRegistryTests` (`TryParse`, `GetAll` count, `Get_EachPlatform_ReturnsCorrectProfile`). + ## Key Conventions - **Privacy by design:** Never add any network calls, telemetry, analytics, or external API dependencies. All dependencies must be local/offline-capable NuGet packages. diff --git a/README.md b/README.md index bc5e3fd..f7bbf42 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Your resume never leaves your machine. | **Avature** | Configurable enterprise ATS/CRM; keyword-scoring against job requisitions, no image OCR | | **Personio** | DACH-region HR/ATS for SMEs; bilingual (German/English) header recognition, no image OCR | | **prospective.ch** | Swiss e-recruiting platform used by public administrations & SMEs; German/French/Italian headers, strict on layout and dates | +| **refline.io** | Swiss ATS for SMEs, large companies & public sector; German/French/Italian headers, strict on layout and dates | --- @@ -76,7 +77,7 @@ ats-scanner scan [options] | Option | Values | Default | Description | |---|---|---|---| -| `-p`, `--platform` | `all`, `workday`, `greenhouse`, `taleo`, `lever`, `successfactors`, `umantis`, `digitalent`, `smartrecruiters`, `avature`, `personio`, `prospective` | `all` | ATS platform to check against | +| `-p`, `--platform` | `all`, `workday`, `greenhouse`, `taleo`, `lever`, `successfactors`, `umantis`, `digitalent`, `smartrecruiters`, `avature`, `personio`, `prospective`, `refline` | `all` | ATS platform to check against | | `-o`, `--output` | `text`, `json` | `text` | Output format | | `-v`, `--verbose` | — | off | Show expanded results with document analysis, positive checklist, issues grouped by category, and top priority actions | diff --git a/src/AtsScanner.Cli/Commands/ScanSettings.cs b/src/AtsScanner.Cli/Commands/ScanSettings.cs index 830b2f0..f5e8b0b 100644 --- a/src/AtsScanner.Cli/Commands/ScanSettings.cs +++ b/src/AtsScanner.Cli/Commands/ScanSettings.cs @@ -13,7 +13,7 @@ public sealed class ScanSettings : CommandSettings public string ResumePath { get; init; } = ""; [CommandOption("-p|--platform")] - [Description("ATS platform to scan against: all, workday, greenhouse, taleo, lever, successfactors, umantis, digitalent, smartrecruiters, avature, personio, prospective")] + [Description("ATS platform to scan against: all, workday, greenhouse, taleo, lever, successfactors, umantis, digitalent, smartrecruiters, avature, personio, prospective, refline")] [DefaultValue("all")] public string Platform { get; init; } = "all"; diff --git a/src/AtsScanner.Core/Models/AtsPlatform.cs b/src/AtsScanner.Core/Models/AtsPlatform.cs index c12441d..3e074da 100644 --- a/src/AtsScanner.Core/Models/AtsPlatform.cs +++ b/src/AtsScanner.Core/Models/AtsPlatform.cs @@ -12,5 +12,6 @@ public enum AtsPlatform SmartRecruiters, Avature, Personio, - Prospective + Prospective, + Refline } diff --git a/src/AtsScanner.Core/Profiles/ProfileRegistry.cs b/src/AtsScanner.Core/Profiles/ProfileRegistry.cs index 140da83..8b51dce 100644 --- a/src/AtsScanner.Core/Profiles/ProfileRegistry.cs +++ b/src/AtsScanner.Core/Profiles/ProfileRegistry.cs @@ -17,7 +17,8 @@ public static class ProfileRegistry new SmartRecruitersProfile(), new AvatureProfile(), new PersonioProfile(), - new ProspectiveProfile() + new ProspectiveProfile(), + new ReflineProfile() ]; public static IReadOnlyList GetAll() => All; @@ -40,6 +41,7 @@ public static bool TryParse(string name, out AtsPlatform platform) "avature" or "avature.net" => AtsPlatform.Avature, "personio" or "personio.com" or "personio.de" => AtsPlatform.Personio, "prospective" or "prospective.ch" => AtsPlatform.Prospective, + "refline" or "refline.io" => AtsPlatform.Refline, _ => (AtsPlatform)(-1) }; return (int)platform >= 0; diff --git a/src/AtsScanner.Core/Profiles/ReflineProfile.cs b/src/AtsScanner.Core/Profiles/ReflineProfile.cs new file mode 100644 index 0000000..29666fb --- /dev/null +++ b/src/AtsScanner.Core/Profiles/ReflineProfile.cs @@ -0,0 +1,111 @@ +using AtsScanner.Core.Models; + +namespace AtsScanner.Core.Profiles; + +/// +/// Refline AG (refline.io) is a Swiss applicant tracking system provider, founded in +/// 2002 and headquartered in Switzerland, used by SMEs, large companies, NGOs, and +/// public-sector organisations for applicant management and recruiting. +/// +/// Key characteristics: +/// - Candidates apply through a structured, configurable web application form. The +/// form captures name, email, and phone as discrete fields, but the attached CV +/// document is still parsed to populate the candidate dossier used by recruiters +/// and hiring managers for screening and comparison. +/// - Strongly DACH-region focused: German is the primary language, with French and +/// Italian also common for Swiss postings across cantons and multilingual +/// organisations. Section header recognition favours German labels +/// ("Berufserfahrung", "Ausbildung", "Kenntnisse") alongside English equivalents. +/// - As a configurable, modular ATS aimed at simplified pre-screening, the parser +/// favours simple, linear, single-column documents; multi-column layouts and +/// tables are a common source of misassigned fields in the generated dossier. +/// - Contact information (email, phone) should appear as plain text in the document +/// body in addition to the web form, since recruiters frequently reference the +/// attached document directly rather than the structured form fields. +/// - Employment history dates are used to build the candidate's professional +/// timeline in the dossier; missing or inconsistently formatted dates degrade the +/// automatically generated summary shown to recruiters and line managers. +/// - Image-based/scanned PDFs are not reliably parseable — only text-based PDF and +/// DOCX are reliably supported. +/// +public sealed class ReflineProfile : BaseAtsPlatformProfile +{ + public override AtsPlatform Platform => AtsPlatform.Refline; + public override string DisplayName => "refline.io"; + + private static readonly SectionType[] RequiredSections = + [SectionType.Experience, SectionType.Education, SectionType.Skills]; + + public override ScanResult Analyze(ParsedResume resume) + { + var issues = new List(); + + // ── Formatting ──────────────────────────────────────────────────────── + + if (HasFormat(resume, ResumeFormatFlags.HasMultipleColumns)) + issues.Add(new ScanIssue( + IssueSeverity.Critical, "Formatting", + "Multi-column layout detected.", + "Refline parses documents top-to-bottom in a single pass when building the candidate dossier. Side-by-side columns will be merged, scrambling your experience and skills sections. Use a single-column layout.")); + + if (HasFormat(resume, ResumeFormatFlags.HasTables)) + issues.Add(new ScanIssue( + IssueSeverity.Warning, "Formatting", + "Tables detected.", + "Table cells are read in unpredictable order by Refline's parser. Replace skills or education tables with plain bullet-point lists.")); + + if (HasFormat(resume, ResumeFormatFlags.HasImages)) + issues.Add(new ScanIssue( + IssueSeverity.Warning, "Formatting", + "Images or graphics detected.", + "Text embedded in images (including profile photos with overlaid text) is not extracted. Remove decorative graphics and keep all content as selectable text.")); + + if (HasFormat(resume, ResumeFormatFlags.HasHeaders) || HasFormat(resume, ResumeFormatFlags.HasFooters)) + issues.Add(new ScanIssue( + IssueSeverity.Warning, "Formatting", + "Document headers or footers detected.", + "Refline may not extract contact information placed in document headers or footers. Move your name, email, and phone number into the main document body.")); + + // ── Contact information ─────────────────────────────────────────────── + + if (!HasContactInfo(resume)) + issues.Add(new ScanIssue( + IssueSeverity.Warning, "Content", + "Email address or phone number not detected.", + "Although Refline's application form collects contact details separately, recruiters often reference your attached document directly. Include plain-text email and phone at the top of the document body.")); + + // ── Structure ───────────────────────────────────────────────────────── + + foreach (var missing in GetMissingSections(resume, RequiredSections)) + issues.Add(new ScanIssue( + IssueSeverity.Warning, "Structure", + $"{missing} section not detected.", + $"Use a clearly labelled section header such as 'Work Experience' / 'Berufserfahrung' (Experience), 'Education' / 'Ausbildung', or 'Skills' / 'Kenntnisse'. Refline recognises both English and German headers.")); + + if (!HasSection(resume, SectionType.Summary) && !HasSection(resume, SectionType.Objective)) + issues.Add(new ScanIssue( + IssueSeverity.Info, "Structure", + "No summary or objective section detected.", + "A short professional summary ('Profil' or 'Zusammenfassung') helps recruiters and line managers quickly assess fit when reviewing the candidate dossier in Refline.")); + + // ── Dates ───────────────────────────────────────────────────────────── + + if (!HasValidDates(resume)) + issues.Add(new ScanIssue( + IssueSeverity.Warning, "Content", + "No clearly formatted dates detected.", + "Refline builds a chronological employment timeline from date ranges in the candidate dossier. Use 'MM/YYYY – MM/YYYY' or 'Month YYYY – Month YYYY' consistently. Swiss German formats such as '01.2020 – 03.2023' are also recognised.")); + + // ── PDF-specific ────────────────────────────────────────────────────── + + if (resume.FileFormat == "pdf") + issues.Add(new ScanIssue( + IssueSeverity.Info, "Format", + "PDF format detected.", + "Refline accepts PDF, but a text-based PDF created from a word processor parses more reliably than one exported from a design tool. Ensure the text is selectable (not a scanned image).")); + + return new ScanResult( + Platform, CalculateScore(issues), issues, + GetDetectedSections(resume), GetMissingSections(resume, RequiredSections)); + } +} diff --git a/tests/AtsScanner.Tests/Analysis/ProfileRegistryTests.cs b/tests/AtsScanner.Tests/Analysis/ProfileRegistryTests.cs index 516bd84..5ab9a06 100644 --- a/tests/AtsScanner.Tests/Analysis/ProfileRegistryTests.cs +++ b/tests/AtsScanner.Tests/Analysis/ProfileRegistryTests.cs @@ -25,6 +25,8 @@ public class ProfileRegistryTests [InlineData("personio.de", AtsPlatform.Personio)] [InlineData("prospective", AtsPlatform.Prospective)] [InlineData("prospective.ch", AtsPlatform.Prospective)] + [InlineData("refline", AtsPlatform.Refline)] + [InlineData("refline.io", AtsPlatform.Refline)] public void TryParse_KnownName_Succeeds(string name, AtsPlatform expected) { var result = ProfileRegistry.TryParse(name, out var platform); @@ -44,7 +46,7 @@ public void TryParse_UnknownName_ReturnsFalse() public void GetAll_ReturnsAllProfiles() { var profiles = ProfileRegistry.GetAll(); - profiles.Should().HaveCount(11); + profiles.Should().HaveCount(12); } [Theory] @@ -59,6 +61,7 @@ public void GetAll_ReturnsAllProfiles() [InlineData(AtsPlatform.Avature)] [InlineData(AtsPlatform.Personio)] [InlineData(AtsPlatform.Prospective)] + [InlineData(AtsPlatform.Refline)] public void Get_EachPlatform_ReturnsCorrectProfile(AtsPlatform platform) { var profile = ProfileRegistry.Get(platform); diff --git a/tests/AtsScanner.Tests/Profiles/ReflineProfileTests.cs b/tests/AtsScanner.Tests/Profiles/ReflineProfileTests.cs new file mode 100644 index 0000000..5c69e87 --- /dev/null +++ b/tests/AtsScanner.Tests/Profiles/ReflineProfileTests.cs @@ -0,0 +1,183 @@ +using AtsScanner.Core.Models; +using AtsScanner.Core.Profiles; +using FluentAssertions; + +namespace AtsScanner.Tests.Profiles; + +public class ReflineProfileTests +{ + private readonly ReflineProfile _profile = new(); + + [Fact] + public void Analyze_CleanResume_ReturnsHighScore() + { + var resume = BuildResume(ResumeFormatFlags.None, hasContact: true, includeCoreSections: true, fileFormat: "docx"); + + var result = _profile.Analyze(resume); + + result.Score.Should().BeGreaterThanOrEqualTo(80); + result.Issues.Should().NotContain(i => i.Severity == IssueSeverity.Critical); + } + + [Fact] + public void Analyze_MultiColumnLayout_RaisesCriticalIssue() + { + var resume = BuildResume(ResumeFormatFlags.HasMultipleColumns, hasContact: true, includeCoreSections: true); + + var result = _profile.Analyze(resume); + + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Critical && + i.Category == "Formatting"); + result.Score.Should().BeLessThan(85); + } + + [Fact] + public void Analyze_TablesDetected_RaisesWarning() + { + var resume = BuildResume(ResumeFormatFlags.HasTables, hasContact: true, includeCoreSections: true); + + var result = _profile.Analyze(resume); + + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Warning && + i.Category == "Formatting" && + i.Message.Contains("Table")); + } + + [Fact] + public void Analyze_ImagesDetected_RaisesFormattingWarning() + { + var resume = BuildResume(ResumeFormatFlags.HasImages, hasContact: true, includeCoreSections: true); + + var result = _profile.Analyze(resume); + + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Warning && + i.Category == "Formatting" && + i.Message.Contains("Images")); + } + + [Fact] + public void Analyze_ContentInHeaderOrFooter_RaisesFormattingWarning() + { + var resume = BuildResume(ResumeFormatFlags.HasFooters, hasContact: true, includeCoreSections: true); + + var result = _profile.Analyze(resume); + + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Warning && + i.Category == "Formatting" && + i.Message.Contains("headers or footers")); + } + + [Fact] + public void Analyze_MissingContactInfo_RaisesWarning() + { + var resume = BuildResume(ResumeFormatFlags.None, hasContact: false, includeCoreSections: true); + + var result = _profile.Analyze(resume); + + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Warning && + i.Category == "Content" && + i.Message.Contains("Email")); + } + + [Fact] + public void Analyze_MissingExperienceSection_RaisesStructureWarning() + { + var resume = BuildResume(ResumeFormatFlags.None, hasContact: true, includeCoreSections: false); + + var result = _profile.Analyze(resume); + + result.MissingSections.Should().Contain(SectionType.Experience); + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Warning && + i.Category == "Structure"); + } + + [Fact] + public void Analyze_NoDates_RaisesDateWarning() + { + var resume = BuildResume(ResumeFormatFlags.None, hasContact: true, includeCoreSections: true, includeDates: false); + + var result = _profile.Analyze(resume); + + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Warning && + i.Category == "Content" && + i.Message.Contains("date")); + } + + [Fact] + public void Analyze_PdfFormat_RaisesInfoIssue() + { + var resume = BuildResume(ResumeFormatFlags.None, hasContact: true, includeCoreSections: true, fileFormat: "pdf"); + + var result = _profile.Analyze(resume); + + result.Issues.Should().Contain(i => + i.Severity == IssueSeverity.Info && + i.Category == "Format"); + } + + [Fact] + public void Analyze_ScoreAlwaysInValidRange() + { + var worstCase = BuildResume( + ResumeFormatFlags.HasMultipleColumns | ResumeFormatFlags.HasTables | + ResumeFormatFlags.HasImages | ResumeFormatFlags.HasHeaders | ResumeFormatFlags.HasFooters, + hasContact: false, includeCoreSections: false, includeDates: false); + + var result = _profile.Analyze(worstCase); + + result.Score.Should().BeInRange(0, 100); + } + + [Fact] + public void Analyze_PlatformIsRefline() + { + var resume = BuildResume(ResumeFormatFlags.None, hasContact: true, includeCoreSections: true); + + var result = _profile.Analyze(resume); + + result.Platform.Should().Be(AtsPlatform.Refline); + } + + private static ParsedResume BuildResume( + ResumeFormatFlags flags, + bool hasContact, + bool includeCoreSections, + bool includeDates = true, + string fileFormat = "pdf") + { + var contact = hasContact + ? new ContactInfo("Lukas Meier", "lukas.meier@example.com", "+41 44 123 45 67", null, "linkedin.com/in/lukasmeier", null, null) + : new ContactInfo(null, null, null, null, null, null, null); + + var sections = includeCoreSections + ? new List + { + new("Berufserfahrung", SectionType.Experience, "Software Engineer bei Beispiel AG", []), + new("Ausbildung", SectionType.Education, "B.Sc. Informatik, ETH Zürich", []), + new("Kenntnisse", SectionType.Skills, "C#, .NET, Azure, SQL", ["C#", ".NET", "Azure"]) + } + : new List(); + + var rawText = string.Join("\n", + [ + hasContact ? "Lukas Meier\nlukas.meier@example.com\n+41 44 123 45 67" : "", + includeCoreSections ? "Berufserfahrung\nAusbildung\nKenntnisse" : "", + includeDates ? "01/2020 – 03/2024" : "" + ]); + + return new ParsedResume( + FileName: $"lebenslauf.{fileFormat}", + FileFormat: fileFormat, + RawText: rawText, + Contact: contact, + Sections: sections, + Format: flags); + } +}