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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`. |
Expand All @@ -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`). |

Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down
38 changes: 20 additions & 18 deletions api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
namespace ApplyTrack.Api.Tests;

/// <summary>
/// The materials-engine routes over HTTP: the per-tenant structured résumé
/// The materials-engine routes over HTTP: the per-tenant résumé brief
/// (<c>/api/resume</c>), the per-tenant LLM endpoint override (<c>/api/llm-settings</c>,
/// whose API key is write-only and never echoed), and cover-letter generation on
/// <c>POST /api/apps/{name}/draft</c> with a stubbed model. Each test method runs as a
Expand Down Expand Up @@ -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",
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion api/ApplyTrack.Api/ApplyTrack.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ApplyTrack.Api</RootNamespace>
<Version>1.9.0</Version>
<Version>1.10.0</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
9 changes: 4 additions & 5 deletions api/ApplyTrack.Api/Data/Resume.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ public sealed record ResumeExperience(string Company, string Title, string Dates
public sealed record ResumeLink(string Label, string Url);

/// <summary>
/// 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 <c>_BACKGROUND</c>: instead of one person's
/// facts baked into the code, every tenant supplies their own. Serializes to the
/// snake_case <c>/api/resume</c> 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 <see cref="Summary"/>; the older structured fields stay
/// in the API shape for compatibility with existing rows and clients.
/// </summary>
public sealed class Resume
{
Expand Down
4 changes: 2 additions & 2 deletions api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace ApplyTrack.Api.Endpoints;

/// <summary>
/// The materials-engine settings routes: the per-tenant structured résumé
/// The materials-engine settings routes: the per-tenant résumé brief
/// (<c>/api/resume</c>) that feeds the cover-letter prompt, and the per-tenant LLM
/// endpoint override (<c>/api/llm-settings</c>) layered over the instance default.
/// The API key is write-only — it is stored encrypted and never returned, only a
Expand All @@ -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()));

Expand Down
Loading
Loading