Skip to content
Open
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
28 changes: 24 additions & 4 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 <resume> [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/<Name>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/<Name>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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -76,7 +77,7 @@ ats-scanner scan <resume> [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 |

Expand Down
2 changes: 1 addition & 1 deletion src/AtsScanner.Cli/Commands/ScanSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
3 changes: 2 additions & 1 deletion src/AtsScanner.Core/Models/AtsPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ public enum AtsPlatform
SmartRecruiters,
Avature,
Personio,
Prospective
Prospective,
Refline
}
4 changes: 3 additions & 1 deletion src/AtsScanner.Core/Profiles/ProfileRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public static class ProfileRegistry
new SmartRecruitersProfile(),
new AvatureProfile(),
new PersonioProfile(),
new ProspectiveProfile()
new ProspectiveProfile(),
new ReflineProfile()
];

public static IReadOnlyList<IAtsPlatformProfile> GetAll() => All;
Expand 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;
Expand Down
111 changes: 111 additions & 0 deletions src/AtsScanner.Core/Profiles/ReflineProfile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using AtsScanner.Core.Models;

namespace AtsScanner.Core.Profiles;

/// <summary>
/// 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.
/// </summary>
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<ScanIssue>();

// ── 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));
}
}
5 changes: 4 additions & 1 deletion tests/AtsScanner.Tests/Analysis/ProfileRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand All @@ -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);
Expand Down
Loading