diff --git a/README.md b/README.md index 76c160c..18aad9d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ telemetry, no SaaS. survivors as fresh leads. - **It drafts your cover letters.** Point it at any OpenAI-compatible LLM — a local Ollama/vLLM model (so your résumé never leaves the box, $0 per draft) or a hosted - provider — and generate a letter per application, tailored from your structured + provider — and generate a letter per application, tailored from your uploaded résumé. Keys are yours, encrypted at rest. - **It's genuinely multi-tenant.** Every row is owned by a tenant; every query in both runtimes unconditionally filters `WHERE tenant_id`. One deployment cleanly @@ -260,9 +260,9 @@ killing the process: | Method | Path | Notes | | --- | --- | --- | -| `GET` | `/api/resume` | The tenant's structured résumé — the only facts the drafter may assert. | -| `PUT` | `/api/resume` | Normalize + store the résumé (dedupes skills, drops empty rows/links). | -| `POST` | `/api/resume/upload` | Upload a text-based PDF résumé (`multipart/form-data`, `resume` file, max 5 MB) and store the extracted profile. | +| `GET` | `/api/resume` | The tenant's résumé brief — the only facts the drafter may assert. | +| `PUT` | `/api/resume` | Compatibility endpoint for structured résumé JSON. | +| `POST` | `/api/resume/upload` | Upload a text-based PDF résumé (`multipart/form-data`, `resume` file, max 5 MB) and store the extracted text as the model brief. | | `GET` | `/api/llm-settings` | The tenant's endpoint override + the instance default. The API key is **write-only** — never returned, only a `has_api_key` flag. | | `PUT` | `/api/llm-settings` | Set `base_url` / `model` / `api_key` (omit `api_key` to leave it untouched, blank to clear it) and `cover_letters_enabled` (omit to keep; `false` disables all drafting for the tenant). | | `DELETE` | `/api/apps/{name}/cover-letter` | Discard a generated letter → `204`. | @@ -288,7 +288,7 @@ The schema is migrated by **DbUp** from idempotent `.sql` scripts under | `sessions` | Opaque server-side sessions (instant revocation on logout). | | `seen` | The dedupe ledger — listings already surfaced, so leads don't repeat. | | `poll_requests` | The on-demand "Poll now" queue the worker drains. | -| `resume_profiles` | Per-tenant structured résumé — the facts the cover-letter drafter feeds the LLM. | +| `resume_profiles` | Per-tenant résumé brief — the facts the cover-letter drafter feeds the LLM. | | `llm_settings` | Per-tenant LLM endpoint override; a tenant's own API key is stored **AES-256-GCM-encrypted** at rest. | | `cover_letters` | Generated cover letters, one per application (`FK → applications ON DELETE CASCADE`). | @@ -320,7 +320,7 @@ Each accepts `--database-url` (a libpq URL), falling back to `DATABASE_URL` / th ## Cover letters -OSApplyTrack drafts a tailored cover letter per application from a structured +OSApplyTrack drafts a tailored cover letter per application from an uploaded résumé you control — provider-agnostic, and built so your data can stay on-prem. > **⚠ Any LLM — or none at all.** The backend is hard-required to work with **any @@ -335,9 +335,10 @@ résumé you control — provider-agnostic, and built so your data can stay on-p - **Operator default + per-tenant override.** The instance sets a default endpoint via `Llm__BaseUrl` / `Llm__Model` / `Llm__ApiKey`; each tenant can override any field in the **Settings · AI** tab (override just the model, keep the URL, etc.). -- **Your résumé is the only source of truth.** The **Settings · Résumé** tab captures name, - headline, summary, experience, skills, certifications, and links — the LLM is told - these are the *only* facts it may assert, so it can't invent employers or metrics. +- **Your résumé is the only source of truth.** The **Settings · Résumé** tab uploads + a text-based PDF and stores the extracted résumé text as the model brief. The LLM + is told these are the *only* facts it may assert, so it can't invent employers or + metrics. - **Keys encrypted at rest.** A tenant's own API key is write-only: sealed with AES-256-GCM under `APPLYTRACK_SECRETS_KEY` and never echoed back. Without that master key the per-tenant-key path is disabled (the instance default still works). diff --git a/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs b/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs index 4530257..e7337e9 100644 --- a/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs +++ b/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs @@ -20,7 +20,7 @@ namespace ApplyTrack.Api.Tests; /// -/// The materials-engine routes over HTTP: the per-tenant structured résumé +/// The materials-engine routes over HTTP: the per-tenant résumé brief /// (/api/resume), the per-tenant LLM endpoint override (/api/llm-settings, /// whose API key is write-only and never echoed), and cover-letter generation on /// POST /api/apps/{name}/draft with a stubbed model. Each test method runs as a @@ -155,7 +155,7 @@ public async Task Resume_put_normalizes_and_round_trips() } [Fact] - public async Task Resume_upload_pdf_extracts_and_stores_a_profile() + public async Task Resume_upload_pdf_stores_full_text_for_the_model() { using var form = PdfForm( "Ada Byte", @@ -173,24 +173,26 @@ public async Task Resume_upload_pdf_extracts_and_stores_a_profile() var uploaded = await ReadJson(await _client.PostAsync("/api/resume/upload", form)); - Assert.Equal("Ada Byte", uploaded.GetProperty("full_name").GetString()); - Assert.Equal("Backend Engineer", uploaded.GetProperty("headline").GetString()); - Assert.Equal("Lincoln, NE", uploaded.GetProperty("location").GetString()); - Assert.Contains("Ships reliable services", uploaded.GetProperty("summary").GetString()); - Assert.Contains("Ada Byte", uploaded.GetProperty("summary").GetString()); - Assert.Equal(new[] { "C#", "Postgres", "Distributed systems" }, - uploaded.GetProperty("skills").EnumerateArray().Select(s => s.GetString())); - Assert.Equal("CKA", uploaded.GetProperty("certifications")[0].GetString()); - Assert.Equal("GitHub", uploaded.GetProperty("links")[0].GetProperty("label").GetString()); - Assert.Equal("https://github.com/adabyte", uploaded.GetProperty("links")[0].GetProperty("url").GetString()); - Assert.Equal("Experience from uploaded resume", - uploaded.GetProperty("experience")[0].GetProperty("title").GetString()); - Assert.Contains("Cut p99 latency", - uploaded.GetProperty("experience")[0].GetProperty("highlights").EnumerateArray().Select(h => h.GetString())); + var text = uploaded.GetProperty("summary").GetString()!; + Assert.Equal("", uploaded.GetProperty("full_name").GetString()); + Assert.Equal("", uploaded.GetProperty("headline").GetString()); + Assert.Equal("", uploaded.GetProperty("location").GetString()); + Assert.Empty(uploaded.GetProperty("skills").EnumerateArray()); + Assert.Empty(uploaded.GetProperty("certifications").EnumerateArray()); + Assert.Empty(uploaded.GetProperty("links").EnumerateArray()); + Assert.Empty(uploaded.GetProperty("experience").EnumerateArray()); + Assert.Contains("Ada Byte", text); + Assert.Contains("Backend Engineer", text); + Assert.Contains("Lincoln, NE", text); + Assert.Contains("Skills", text); + Assert.Contains("C#", text); + Assert.Contains("Postgres", text); + Assert.Contains("CKA", text); + Assert.Contains("Cut p99 latency", text); var got = await ReadJson(await _client.GetAsync("/api/resume")); - Assert.Equal("Ada Byte", got.GetProperty("full_name").GetString()); - Assert.Contains("Postgres", got.GetProperty("skills").EnumerateArray().Select(s => s.GetString())); + Assert.Equal(text, got.GetProperty("summary").GetString()); + Assert.Empty(got.GetProperty("experience").EnumerateArray()); } [Fact] diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index 361ebee..9667dc0 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.9.0 + 1.10.0 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/api/ApplyTrack.Api/Data/Resume.cs b/api/ApplyTrack.Api/Data/Resume.cs index ca6c573..f3b567a 100644 --- a/api/ApplyTrack.Api/Data/Resume.cs +++ b/api/ApplyTrack.Api/Data/Resume.cs @@ -13,11 +13,10 @@ public sealed record ResumeExperience(string Company, string Title, string Dates public sealed record ResumeLink(string Label, string Url); /// -/// Per-tenant structured résumé — the factual brief the cover-letter drafter feeds -/// the LLM as "the only facts you may assert about the candidate". The multi-tenant -/// heir to materials.py's hardcoded _BACKGROUND: instead of one person's -/// facts baked into the code, every tenant supplies their own. Serializes to the -/// snake_case /api/resume shape the SPA editor round-trips. +/// Per-tenant résumé facts — the brief the cover-letter drafter feeds the LLM as +/// "the only facts you may assert about the candidate". PDF upload stores the +/// extracted résumé text in ; the older structured fields stay +/// in the API shape for compatibility with existing rows and clients. /// public sealed class Resume { diff --git a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs index 8b2e1f0..42c6e09 100644 --- a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs +++ b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs @@ -10,7 +10,7 @@ namespace ApplyTrack.Api.Endpoints; /// -/// The materials-engine settings routes: the per-tenant structured résumé +/// The materials-engine settings routes: the per-tenant résumé brief /// (/api/resume) that feeds the cover-letter prompt, and the per-tenant LLM /// endpoint override (/api/llm-settings) layered over the instance default. /// The API key is write-only — it is stored encrypted and never returned, only a @@ -22,7 +22,7 @@ public static class MaterialsEndpoints { public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) { - // ---- Structured résumé ------------------------------------------------- + // ---- Résumé brief ------------------------------------------------------ app.MapGet("/api/resume", async (ResumeRepo repo) => Results.Ok(await repo.GetAsync())); diff --git a/api/ApplyTrack.Api/Materials/ResumePdfImporter.cs b/api/ApplyTrack.Api/Materials/ResumePdfImporter.cs index f56f5d1..84e26c3 100644 --- a/api/ApplyTrack.Api/Materials/ResumePdfImporter.cs +++ b/api/ApplyTrack.Api/Materials/ResumePdfImporter.cs @@ -13,32 +13,14 @@ namespace ApplyTrack.Api.Materials; /// /// Best-effort importer for text-based resume PDFs. The PDF remains user-supplied -/// source material: we extract selectable text, infer the obvious structured fields, -/// and keep the full extracted text in Summary so the drafter still has the facts -/// when the PDF layout is too irregular to split cleanly. +/// source material: we extract selectable text and store it as the résumé brief +/// rather than guessing at lossy structured fields. /// public static partial class ResumePdfImporter { public const long MaxPdfBytes = 5L * 1024 * 1024; - private const int MaxSummaryChars = 20_000; - - private static readonly string[] SummaryHeaders = - ["summary", "professional summary", "profile", "career profile", "objective", "about"]; - - private static readonly string[] SkillHeaders = - ["skills", "technical skills", "core skills", "core competencies", "technologies"]; - - private static readonly string[] CertificationHeaders = - ["certifications", "certificates", "licenses", "licensure"]; - - private static readonly string[] ExperienceHeaders = - ["experience", "professional experience", "work experience", "employment", "employment history"]; - - private static readonly HashSet KnownHeaders = new( - SummaryHeaders.Concat(SkillHeaders).Concat(CertificationHeaders).Concat(ExperienceHeaders) - .Concat(["education", "projects", "selected projects", "links", "contact", "awards", "publications"]), - StringComparer.OrdinalIgnoreCase); + private const int MaxResumeTextChars = 100_000; public static Resume FromPdf(byte[] bytes) { @@ -76,22 +58,9 @@ public static Resume FromPdf(byte[] bytes) internal static Resume FromText(string text) { - var lines = Lines(text).ToList(); - var (name, nameIndex) = GuessName(lines); - var headline = GuessHeadline(lines, nameIndex); - var summary = Section(lines, SummaryHeaders); - var experience = Section(lines, ExperienceHeaders); - return new Resume { - FullName = name, - Headline = headline, - Location = GuessLocation(lines), - Summary = BuildSummary(text, summary), - Experience = BuildExperience(experience), - Skills = CleanList(Section(lines, SkillHeaders), splitPhrases: true), - Certifications = CleanList(Section(lines, CertificationHeaders), splitPhrases: false), - Links = ExtractLinks(text), + Summary = BuildResumeText(text), }; } @@ -103,203 +72,20 @@ private static bool LooksLikePdf(byte[] bytes) => && bytes[3] == (byte)'F' && bytes[4] == (byte)'-'; - private static string BuildSummary(string fullText, IReadOnlyList summaryLines) - { - var text = summaryLines.Count > 0 - ? string.Join("\n", summaryLines) + "\n\nExtracted resume text:\n" + fullText - : fullText; - return text.Length <= MaxSummaryChars - ? text - : text[..MaxSummaryChars].TrimEnd() + "\n[truncated]"; - } - - private static List BuildExperience(IReadOnlyList experienceLines) - { - var highlights = CleanList(experienceLines, splitPhrases: false) - .Where(s => s.Length <= 240) - .Take(40) - .ToList(); - return highlights.Count == 0 - ? [] - : [new ResumeExperience("", "Experience from uploaded resume", "", highlights)]; - } - - private static (string Name, int Index) GuessName(IReadOnlyList lines) - { - for (var i = 0; i < Math.Min(lines.Count, 12); i++) - { - var line = CleanLine(lines[i]); - if (line.Length is < 3 or > 80 || LooksLikeContact(line) || IsKnownHeader(line)) - continue; - - var words = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (words.Length is >= 2 and <= 5 && words.All(ContainsLetter)) - return (line, i); - } - return ("", -1); - } - - private static string GuessHeadline(IReadOnlyList lines, int nameIndex) - { - var start = nameIndex >= 0 ? nameIndex + 1 : 0; - for (var i = start; i < Math.Min(lines.Count, start + 5); i++) - { - var line = CleanLine(lines[i]); - if (line.Length is < 3 or > 120 || LooksLikeContact(line) || IsKnownHeader(line)) - continue; - return line; - } - return ""; - } - - private static string GuessLocation(IReadOnlyList lines) - { - foreach (var line in lines.Take(10).Select(CleanLine)) - { - var location = LocationRegex().Match(line); - if (location.Success) - return location.Value; - } - return ""; - } - - private static IReadOnlyList Section(IReadOnlyList lines, IReadOnlyList headers) - { - var found = false; - var values = new List(); - foreach (var raw in lines) - { - var line = CleanLine(raw); - if (line.Length == 0) - continue; - - if (TryHeader(line, headers, out var inline)) - { - found = true; - if (inline.Length > 0) - values.Add(inline); - continue; - } - - if (found && IsKnownHeader(line)) - break; - if (found) - values.Add(line); - } - return values; - } - - private static bool TryHeader(string line, IReadOnlyList headers, out string inline) - { - inline = ""; - var normalized = NormalizeHeader(line); - foreach (var header in headers) - { - if (normalized == header) - return true; - if (normalized.StartsWith(header + ": ", StringComparison.OrdinalIgnoreCase)) - { - inline = line[(line.IndexOf(':') + 1)..].Trim(); - return true; - } - } - return false; - } - - private static bool IsKnownHeader(string line) - { - var normalized = NormalizeHeader(line); - return KnownHeaders.Contains(normalized) - || (line.EndsWith(':') && line.Length <= 40 && KnownHeaders.Contains(normalized.TrimEnd(':'))); - } - - private static string NormalizeHeader(string line) => - SpaceRegex().Replace(CleanBullet(line).Trim().TrimEnd(':').ToLowerInvariant(), " "); - - private static List CleanList(IEnumerable lines, bool splitPhrases) - { - var result = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var line in lines) - { - var cleaned = CleanBullet(line); - var parts = splitPhrases - ? ListSplitRegex().Split(cleaned) - : [cleaned]; - - foreach (var part in parts.Select(CleanLine)) - { - if (part.Length == 0 || part.Length > 120 || IsKnownHeader(part) || LooksLikeContact(part)) - continue; - if (seen.Add(part)) - result.Add(part); - } - } - return result; - } - - private static List ExtractLinks(string text) - { - var links = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (Match match in UrlRegex().Matches(text)) - { - var url = match.Value.TrimEnd('.', ',', ';', ')', ']'); - if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - url = "https://" + url; - if (!seen.Add(url)) - continue; - links.Add(new ResumeLink(LinkLabel(url), url)); - } - return links; - } + private static string NormalizeText(string text) => + string.Join("\n", text.Replace("\r\n", "\n").Replace('\r', '\n') + .Split('\n') + .Select(line => SpaceRegex().Replace(line, " ").TrimEnd())) + .Trim(); - private static string LinkLabel(string url) + private static string BuildResumeText(string text) { - var host = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : url.ToLowerInvariant(); - if (host.Contains("github")) return "GitHub"; - if (host.Contains("linkedin")) return "LinkedIn"; - if (host.Contains("gitlab")) return "GitLab"; - return "Portfolio"; + var normalized = NormalizeText(text); + return normalized.Length <= MaxResumeTextChars + ? normalized + : normalized[..MaxResumeTextChars].TrimEnd() + "\n[truncated]"; } - private static IEnumerable Lines(string text) => - NormalizeText(text) - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(CleanLine) - .Where(s => s.Length > 0); - - private static string NormalizeText(string text) => - SpaceRegex().Replace(text.Replace("\r\n", "\n").Replace('\r', '\n'), " ").Trim(); - - private static string CleanLine(string line) => - SpaceRegex().Replace(CleanBullet(line), " ").Trim(); - - private static string CleanBullet(string line) => - BulletRegex().Replace(line.Trim(), ""); - - private static bool LooksLikeContact(string line) => - line.Contains('@') - || UrlRegex().IsMatch(line) - || PhoneRegex().IsMatch(line); - - private static bool ContainsLetter(string value) => value.Any(char.IsLetter); - [GeneratedRegex(@"[ \t\f\v]+")] private static partial Regex SpaceRegex(); - - [GeneratedRegex(@"^[\-*•·‣▪▫◦]+\s*")] - private static partial Regex BulletRegex(); - - [GeneratedRegex(@"(?:https?://|www\.|linkedin\.com/|github\.com/|gitlab\.com/)[^\s<>""]+", RegexOptions.IgnoreCase)] - private static partial Regex UrlRegex(); - - [GeneratedRegex(@"\+?\d[\d\s().-]{7,}\d")] - private static partial Regex PhoneRegex(); - - [GeneratedRegex(@"[A-Za-z][A-Za-z .'-]+,\s*(?:[A-Z]{2}|[A-Za-z][A-Za-z .'-]+)")] - private static partial Regex LocationRegex(); - - [GeneratedRegex(@"[,;|]| {2,}")] - private static partial Regex ListSplitRegex(); } diff --git a/api/ApplyTrack.Api/wwwroot/app.css b/api/ApplyTrack.Api/wwwroot/app.css index 1753a82..abb12be 100644 --- a/api/ApplyTrack.Api/wwwroot/app.css +++ b/api/ApplyTrack.Api/wwwroot/app.css @@ -306,6 +306,22 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { border-color .resume-upload { display: grid; grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.85fr); gap: 1rem; align-items: end; padding-block: 1rem; border-block: 1px solid var(--border); } .resume-upload h3 { margin: 0 0 0.25rem; font-size: 1.05rem; } .resume-upload-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.5rem; align-items: center; } +.resume-preview-wrap h3 { margin: 0 0 0.5rem; font-size: 1.05rem; } +.resume-preview { + max-height: 32rem; + margin: 0; + padding: 0.8rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--text); + background: var(--surface-subtle); + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: var(--font-mono); + line-height: 1.5; +} +.resume-preview-empty { padding-block: 0.8rem; border-top: 1px solid var(--border); } .preference-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } .source-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.4rem; } .source-row { min-height: 2.75rem; display: flex; align-items: center; gap: 0.6rem; } @@ -314,7 +330,6 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { border-color .board-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem; border: 1px solid var(--border); border-radius: var(--radius); } .board-slug { flex: 1; min-width: 0; overflow-wrap: anywhere; } .board-add { display: grid; grid-template-columns: minmax(8rem, 0.6fr) minmax(10rem, 1fr) auto; gap: 0.5rem; } -.exp-row { margin-top: 0.7rem; padding: 0.8rem; border: 1px solid var(--border); border-radius: var(--radius); } .toast { position: fixed; diff --git a/api/ApplyTrack.Api/wwwroot/app.js b/api/ApplyTrack.Api/wwwroot/app.js index b45eb1f..66564f4 100644 --- a/api/ApplyTrack.Api/wwwroot/app.js +++ b/api/ApplyTrack.Api/wwwroot/app.js @@ -998,87 +998,63 @@ async function loadCriteriaTab(body, gen = settingsGen) { // ---- Résumé panel --------------------------------------------------------- -// The structured résumé feeds the cover-letter prompt as the only facts the model -// may assert. Experience + links are dynamic lists (like ATS boards); because their -// rows hold live inputs, we sync the DOM back into these arrays before any add/remove -// re-render so in-progress edits aren't clobbered. -let resumeExperience = []; -let resumeLinks = []; - -function expRows() { - if (!resumeExperience.length) - return `
No experience added.
`; - return resumeExperience.map((e, i) => ` -
-
- - - -
- -
- -
-
`).join(""); -} - -function syncExperienceFromDom() { - contentEl.querySelectorAll("[data-exp]").forEach((row) => { - const i = Number(row.dataset.exp); - const val = (f) => row.querySelector(`[data-exp-field="${f}"]`).value; - resumeExperience[i] = { - title: val("title").trim(), - company: val("company").trim(), - dates: val("dates").trim(), - highlights: val("highlights").split("\n").map((s) => s.trim()).filter(Boolean), - }; - }); -} +// Uploaded résumé text feeds the cover-letter prompt as the only facts the model +// may assert. Older structured résumé fields still round-trip through the API for +// compatibility, but the settings UI now treats the PDF upload as the source. +function resumePreviewText(r) { + const lines = []; + const fullName = (r.full_name || "").trim(); + const headline = (r.headline || "").trim(); + const location = (r.location || "").trim(); + const summary = (r.summary || "").trim(); + + if (fullName || headline) + lines.push(fullName && headline ? `${fullName} - ${headline}` : (fullName || headline)); + if (location) lines.push(`Location: ${location}`); + if (summary) { + if (lines.length) lines.push(""); + lines.push(summary); + } -function renderExperience() { - const el = $("#resume-experience"); - if (!el) return; - el.innerHTML = expRows(); - el.querySelectorAll("[data-remove-exp]").forEach((btn) => { - btn.onclick = () => { - syncExperienceFromDom(); - resumeExperience.splice(Number(btn.dataset.removeExp), 1); - renderExperience(); - }; - }); -} + const experience = r.experience || []; + if (experience.length) { + lines.push("", "Experience:"); + experience.forEach((e) => { + const head = [e.title, e.company, e.dates].filter(Boolean).join(" · "); + if (head) lines.push(`- ${head}`); + (e.highlights || []).filter(Boolean).forEach((h) => lines.push(` - ${h}`)); + }); + } + if ((r.skills || []).length) lines.push("", `Skills: ${(r.skills || []).join(", ")}`); + if ((r.certifications || []).length) lines.push("", `Certifications: ${(r.certifications || []).join(", ")}`); + if ((r.links || []).length) { + lines.push("", "Links:"); + (r.links || []).forEach((l) => { + if (l.url) lines.push(l.label ? `- ${l.label}: ${l.url}` : `- ${l.url}`); + }); + } -function linkRows() { - if (!resumeLinks.length) - return `
No links added.
`; - return resumeLinks.map((l, i) => ` - `).join(""); + return lines.join("\n").trim(); } -function syncLinksFromDom() { - contentEl.querySelectorAll("[data-link]").forEach((row) => { - const i = Number(row.dataset.link); - const val = (f) => row.querySelector(`[data-link-field="${f}"]`).value; - resumeLinks[i] = { label: val("label").trim(), url: val("url").trim() }; - }); -} +function resumePreviewMarkup(r) { + const text = resumePreviewText(r); + if (!text) { + return ` +
+

Current résumé

+
No résumé PDF has been uploaded yet.
+
`; + } -function renderLinks() { - const el = $("#resume-links"); - if (!el) return; - el.innerHTML = linkRows(); - el.querySelectorAll("[data-remove-link]").forEach((btn) => { - btn.onclick = () => { - syncLinksFromDom(); - resumeLinks.splice(Number(btn.dataset.removeLink), 1); - renderLinks(); - }; - }); + const preview = text.length > 6000 + ? `${text.slice(0, 6000).trimEnd()}\n[preview truncated]` + : text; + return ` +
+

Current résumé text

+
${escapeHtml(preview)}
+
`; } function resumeMarkup(r) { @@ -1087,114 +1063,28 @@ function resumeMarkup(r) {
Résumé

The facts behind your cover letters

- The drafting AI may assert only what's here — no invented employers, titles, or metrics. + The drafting AI may assert only what's in your uploaded résumé.

Upload a PDF résumé

- Text-based PDFs are extracted into this profile. Review the fields after upload. + Text-based PDFs are extracted and stored as the résumé text for cover-letter drafting.

- +
-
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- -
-
Experience
-
- -
- -
-
- - -
-
- - -
-
- -
-
Links
- - -
- -
- - -
+ ${resumePreviewMarkup(r)} `; } -function gatherResume() { - syncExperienceFromDom(); - syncLinksFromDom(); - const splitList = (sel) => $(sel).value.split(/[\n,]/).map((s) => s.trim()).filter(Boolean); - return { - full_name: $("#r-name").value.trim(), - headline: $("#r-headline").value.trim(), - location: $("#r-location").value.trim(), - summary: $("#r-summary").value.trim(), - skills: splitList("#r-skills"), - certifications: splitList("#r-certs"), - experience: resumeExperience.filter( - (e) => e.company || e.title || e.dates || (e.highlights && e.highlights.length)), - links: resumeLinks.filter((l) => l.url), - }; -} - function wireResume() { - contentEl.querySelector('[data-act="cancel"]').onclick = () => - state.current ? openApp(state.current) : renderEmpty(); - contentEl.querySelector('[data-act="add-exp"]').onclick = () => { - syncExperienceFromDom(); - resumeExperience.push({ title: "", company: "", dates: "", highlights: [] }); - renderExperience(); - }; - contentEl.querySelector('[data-act="add-link"]').onclick = () => { - syncLinksFromDom(); - resumeLinks.push({ label: "", url: "" }); - renderLinks(); - }; - contentEl.querySelector('[data-act="save"]').onclick = async () => { - try { - await api("PUT", "/api/resume", gatherResume()); - toast("Résumé saved."); - } catch (e) { - toast(e.message); - } - }; contentEl.querySelector('[data-act="upload-resume"]').onclick = async () => { const input = $("#r-pdf"); const file = input.files?.[0]; @@ -1207,31 +1097,23 @@ function wireResume() { const label = btn.textContent; btn.disabled = true; btn.textContent = "Uploading…"; + let uploaded = null; try { const form = new FormData(); form.append("resume", file); - const r = await api.form("/api/resume/upload", form); - resumeExperience = (r.experience || []).map((e) => ({ - title: e.title || "", company: e.company || "", dates: e.dates || "", - highlights: e.highlights || [], - })); - resumeLinks = (r.links || []).map((l) => ({ label: l.label || "", url: l.url || "" })); - $("#r-name").value = r.full_name || ""; - $("#r-location").value = r.location || ""; - $("#r-headline").value = r.headline || ""; - $("#r-summary").value = r.summary || ""; - $("#r-skills").value = (r.skills || []).join("\n"); - $("#r-certs").value = (r.certifications || []).join("\n"); - renderExperience(); - renderLinks(); - toast("Résumé PDF imported."); - focusView("#r-name"); + uploaded = await api.form("/api/resume/upload", form); + toast("Résumé PDF uploaded."); } catch (e) { toast(e.message); } finally { btn.disabled = false; btn.textContent = label; } + if (uploaded) { + contentEl.innerHTML = resumeMarkup(uploaded); + wireResume(); + focusView("#resume-preview-title"); + } }; } @@ -1239,14 +1121,7 @@ function wireResume() { async function loadResumeTab(body, gen = settingsGen) { const r = await api("GET", "/api/resume"); if (settingsSuperseded(gen)) return; - resumeExperience = (r.experience || []).map((e) => ({ - title: e.title || "", company: e.company || "", dates: e.dates || "", - highlights: e.highlights || [], - })); - resumeLinks = (r.links || []).map((l) => ({ label: l.label || "", url: l.url || "" })); body.innerHTML = resumeMarkup(r); - renderExperience(); - renderLinks(); wireResume(); } diff --git a/pyproject.toml b/pyproject.toml index 70e4721..ef6f41a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.9.0" +version = "1.10.0" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/tests/web/accessibility.spec.js b/tests/web/accessibility.spec.js index 36a263c..d528954 100644 --- a/tests/web/accessibility.spec.js +++ b/tests/web/accessibility.spec.js @@ -110,6 +110,17 @@ test("settings sections expose labeled controls", async ({ page }) => { } }); +test("resume settings use PDF upload instead of manual fields", async ({ page }) => { + await openSettings(page); + await page.getByRole("tab", { name: "Résumé", exact: true }).click(); + await expect(page.getByLabel("Résumé PDF file")).toBeVisible(); + await expect(page.getByRole("button", { name: "Upload PDF" })).toBeVisible(); + await expect(page.getByLabel("Full name")).toHaveCount(0); + await expect(page.getByLabel("Headline")).toHaveCount(0); + await expect(page.getByLabel("Summary")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Save résumé" })).toHaveCount(0); +}); + test("keyboard navigation and validation retain visible focus", async ({ page }) => { await page.keyboard.press("/"); await expect(page.getByLabel("Search applications")).toBeFocused(); diff --git a/uv.lock b/uv.lock index bc1dfd4..cca504a 100644 --- a/uv.lock +++ b/uv.lock @@ -23,7 +23,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.9.0" +version = "1.10.0" source = { editable = "." } dependencies = [ { name = "defusedxml" },