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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ killing the process:
| `PUT` | `/api/apps/{name}/raw?expected_version=…` | Replace the full Markdown document. |
| `DELETE` | `/api/apps/{name}` | Delete → `204`. |
| `POST` | `/api/apps/{name}/draft` | Draft a tailored cover letter via the configured LLM; saves it and returns `{ok, material}`. Rate-limited. |
| `GET` | `/api/apps/{name}/cover-letter.pdf` | Download the saved cover letter as a PDF. |
| `POST` | `/api/poll` | Enqueue an on-demand poll → `{count:0}`. Rate-limited; the worker drains it. |
| `POST` | `/api/scrape` | Body `{url}`. Fetch a posting page server-side (SSRF-guarded) and extract `{company, role, location, salary, source, description}` for the editor's Autofill. Rate-limited; 502 when the page can't be read. |

Expand Down Expand Up @@ -343,7 +344,7 @@ résumé you control — provider-agnostic, and built so your data can stay on-p
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).
- **Generate from the application sheet.** Each app gets a **Generate cover letter**
action; the result renders inline with copy / download `.md` / regenerate /
action; the result renders inline with copy / download `.md` or PDF / regenerate /
discard. Letters are stored per application and are excluded from the export
snapshot by design.

Expand Down Expand Up @@ -510,7 +511,7 @@ v1 is intentionally focused. Deferred, with clean seams already in place:
- **Link checking.** `/api/apps/{name}/check-link` returns 501 today; the
SSRF-hardened prober already exists in the poller for when it's enabled.
- **Richer cover-letter output.** The materials engine ships plain-text/Markdown
letters ([Cover letters](#cover-letters)); LaTeX/PDF rendering is the next module.
letters ([Cover letters](#cover-letters)) with direct Markdown and PDF downloads.

## Contributing

Expand Down
8 changes: 8 additions & 0 deletions api/ApplyTrack.Api.Tests/CoverLetterDrafterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ public async Task The_returned_body_is_trimmed()
Assert.Equal(new string('y', 100), body);
}

[Fact]
public async Task A_saved_signature_is_appended_exactly()
{
var body = await new CoverLetterDrafter(new StubLlmClient())
.DraftAsync(new AppFields { Company = "Acme" }, SampleResume(), Cfg, "Sincerely,\nAda Byte");
Assert.EndsWith("Sincerely,\nAda Byte", body);
}

[Theory]
[InlineData("too short")] // under the 40-char floor
[InlineData("")] // empty
Expand Down
28 changes: 28 additions & 0 deletions api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,34 @@ public async Task Cover_letters_default_on_and_the_toggle_round_trips()
Assert.Equal("llama3.1", after.GetProperty("model").GetString());
}

[Fact]
public async Task Cover_letter_signature_round_trips_and_is_appended_to_new_drafts()
{
using var client = await AuthedClientAsync(NewFactory(WithStub(new StubLlmClient())));
await client.PutAsync("/api/resume", Json(NonEmptyResume));
await client.PutAsync("/api/llm-settings", Json("""{"cover_letter_signature":"Sincerely,\nAda Byte"}"""));
var settings = await ReadJson(await client.GetAsync("/api/llm-settings"));
Assert.Equal("Sincerely,\nAda Byte", settings.GetProperty("cover_letter_signature").GetString());

var name = await CreateAppAsync(client, "Acme Corp", "Engineer");
var draft = await ReadJson(await client.PostAsync($"/api/apps/{name}/draft", null));
Assert.EndsWith("Sincerely,\nAda Byte", draft.GetProperty("material").GetString());
}

[Fact]
public async Task Cover_letter_pdf_download_returns_a_pdf()
{
using var client = await AuthedClientAsync(NewFactory(WithStub(new StubLlmClient())));
await client.PutAsync("/api/resume", Json(NonEmptyResume));
var name = await CreateAppAsync(client, "Acme Corp", "Engineer");
await client.PostAsync($"/api/apps/{name}/draft", null);

var pdf = await client.GetAsync($"/api/apps/{name}/cover-letter.pdf");
Assert.Equal(HttpStatusCode.OK, pdf.StatusCode);
Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
Assert.Equal("%PDF-", Encoding.ASCII.GetString((await pdf.Content.ReadAsByteArrayAsync())[..5]));
}

[Fact]
public async Task Draft_is_refused_when_cover_letters_are_disabled()
{
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.10.0</Version>
<Version>1.11.0</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
28 changes: 22 additions & 6 deletions api/ApplyTrack.Api/Data/LlmSettingsRepo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ public LlmSettingsRepo(
_log = log;
}

private sealed record Row(string BaseUrl, string Model, string ApiKeyCiphertext, bool CoverLettersEnabled);
private sealed record Row(
string BaseUrl, string Model, string ApiKeyCiphertext,
bool CoverLettersEnabled, string CoverLetterSignature);

/// <summary>The tenant's override with the API key decrypted, or null when no row exists.</summary>
public async Task<LlmOverride?> GetOverrideAsync()
Expand Down Expand Up @@ -76,6 +78,10 @@ private sealed record Row(string BaseUrl, string Model, string ApiKeyCiphertext,
: (row.BaseUrl, row.Model, row.ApiKeyCiphertext.Length > 0, row.CoverLettersEnabled);
}

/// <summary>The tenant's exact multi-line closing signature, or blank when unset.</summary>
public async Task<string> GetCoverLetterSignatureAsync() =>
(await ReadRowAsync())?.CoverLetterSignature ?? "";

/// <summary>
/// Save the override. <paramref name="changeKey"/> distinguishes "leave the stored
/// key alone" (false) from "set/clear it" (true): a blank <paramref name="newKeyPlaintext"/>
Expand All @@ -85,7 +91,8 @@ private sealed record Row(string BaseUrl, string Model, string ApiKeyCiphertext,
/// </summary>
public async Task UpsertAsync(
string baseUrl, string model, bool changeKey, string? newKeyPlaintext,
bool? coverLettersEnabled = null, IDbTransaction? tx = null)
bool? coverLettersEnabled = null, string? coverLetterSignature = null,
IDbTransaction? tx = null)
{
var ciphertext = "";
if (changeKey && !string.IsNullOrEmpty(newKeyPlaintext))
Expand All @@ -101,16 +108,24 @@ public async Task UpsertAsync(
var keyUpdate = changeKey ? "api_key_ciphertext = EXCLUDED.api_key_ciphertext," : "";
await _conn.ExecuteAsync(
$"""
INSERT INTO llm_settings (tenant_id, base_url, model, api_key_ciphertext, cover_letters_enabled, updated_at)
VALUES (@t, @baseUrl, @model, @ciphertext, coalesce(@enabled, true), now())
INSERT INTO llm_settings (
tenant_id, base_url, model, api_key_ciphertext,
cover_letters_enabled, cover_letter_signature, updated_at)
VALUES (@t, @baseUrl, @model, @ciphertext, coalesce(@enabled, true),
coalesce(@signature, ''), now())
ON CONFLICT (tenant_id) DO UPDATE SET
base_url = EXCLUDED.base_url,
model = EXCLUDED.model,
{keyUpdate}
cover_letters_enabled = coalesce(@enabled, llm_settings.cover_letters_enabled),
cover_letter_signature = coalesce(@signature, llm_settings.cover_letter_signature),
updated_at = now()
""",
new { t = _t, baseUrl = baseUrl.Trim(), model = model.Trim(), ciphertext, enabled = coverLettersEnabled },
new
{
t = _t, baseUrl = baseUrl.Trim(), model = model.Trim(), ciphertext,
enabled = coverLettersEnabled, signature = coverLetterSignature?.Trim(),
},
tx);
}

Expand All @@ -119,7 +134,8 @@ ON CONFLICT (tenant_id) DO UPDATE SET
// Dapper's global MatchNamesWithUnderscores flag.
_conn.QuerySingleOrDefaultAsync<Row?>(
"SELECT base_url AS baseurl, model, api_key_ciphertext AS apikeyciphertext, "
+ "cover_letters_enabled AS coverlettersenabled "
+ "cover_letters_enabled AS coverlettersenabled, "
+ "cover_letter_signature AS coverlettersignature "
+ "FROM llm_settings WHERE tenant_id = @t",
new { t = _t });
}
3 changes: 2 additions & 1 deletion api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ public static void MapAppsEndpoints(this IEndpointRouteBuilder app)

var resume = await resumes.GetAsync();
var cfg = EffectiveLlmConfig.Resolve(instance, await llm.GetOverrideAsync());
var body = await drafter.DraftAsync(rec.Fields, resume, cfg, ct);
var body = await drafter.DraftAsync(
rec.Fields, resume, cfg, await llm.GetCoverLetterSignatureAsync(), ct);
await letters.UpsertAsync(rec.Name, body, cfg.Model);
return Results.Ok(new { ok = true, material = body });
}).RequireRateLimiting("draft");
Expand Down
18 changes: 17 additions & 1 deletion api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app)
base_url = baseUrl,
model,
has_api_key = hasKey,
cover_letter_signature = await repo.GetCoverLetterSignatureAsync(),
// Whether this tenant wants cover letters at all; OFF hides the
// drafting UI and the draft endpoint refuses.
cover_letters_enabled = lettersEnabled,
Expand Down Expand Up @@ -99,14 +100,21 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app)
? en.GetBoolean()
: null;

await repo.UpsertAsync(baseUrl, model, changeKey, newKey, lettersEnabled);
string? signature = payload.ValueKind == JsonValueKind.Object
&& payload.TryGetProperty("cover_letter_signature", out var sig)
&& sig.ValueKind == JsonValueKind.String
? sig.GetString()?.Trim()
: null;

await repo.UpsertAsync(baseUrl, model, changeKey, newKey, lettersEnabled, signature);

var (savedUrl, savedModel, hasKey, savedEnabled) = await repo.GetViewAsync();
return Results.Ok(new
{
base_url = savedUrl,
model = savedModel,
has_api_key = hasKey,
cover_letter_signature = await repo.GetCoverLetterSignatureAsync(),
cover_letters_enabled = savedEnabled,
});
});
Expand All @@ -119,6 +127,14 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app)
? Results.NoContent()
: throw new AppNotFoundException($"no cover letter for '{name}'");
});

app.MapGet("/api/apps/{name}/cover-letter.pdf", async (string name, CoverLetterRepo letters) =>
{
var body = await letters.GetBodyAsync(name)
?? throw new AppNotFoundException($"no cover letter for '{name}'");
var filename = $"{Slug.NameStem(Slug.Normalize(name))}-cover-letter.pdf";
return Results.File(CoverLetterPdfRenderer.Render(body), "application/pdf", filename);
});
}

private static string GetString(JsonElement obj, string key) =>
Expand Down
12 changes: 7 additions & 5 deletions api/ApplyTrack.Api/Materials/CoverLetterDrafter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public sealed class CoverLetterDrafter
};

public async Task<string> DraftAsync(
AppFields app, Resume resume, EffectiveLlmConfig cfg, CancellationToken ct = default)
AppFields app, Resume resume, EffectiveLlmConfig cfg,
string signature = "", CancellationToken ct = default)
{
if (resume.IsEmpty)
throw new AppValidationException("add your résumé in Résumé settings before drafting a cover letter");
Expand All @@ -38,14 +39,14 @@ public async Task<string> DraftAsync(
// Reject empty/implausible output rather than save a broken letter.
if (body.Length is < 40 or > 6000)
throw new LlmUnavailableException("the model returned an unusable draft — try again");
return body;
return signature.Trim() is { Length: > 0 } closing
? $"{body}\n\n{closing}"
: body;
}

private static (string System, string User) BuildPrompt(AppFields app, Resume resume)
{
var lane = LaneLead.TryGetValue(app.Lane, out var lead) ? lead : LaneLead["ai"];
var name = resume.FullName.Length > 0 ? resume.FullName : "the candidate";

var system =
$"""
You are an expert cover-letter writer drafting a tailored, ready-to-send cover
Expand All @@ -65,7 +66,8 @@ and company are about.
Structure the letter as:
- a "Dear Hiring Team," greeting,
- 2-3 short body paragraphs, ~200-280 words total,
- a brief sign-off ending with "{name}".
- end after the final body paragraph; do not write a sign-off or signature.
The application will append the applicant's saved signature exactly.
Do NOT include a date or a postal address.
""";

Expand Down
90 changes: 90 additions & 0 deletions api/ApplyTrack.Api/Materials/CoverLetterPdfRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark

using System.Text.RegularExpressions;
using System.Text;
using System.Globalization;
using UglyToad.PdfPig.Content;
using UglyToad.PdfPig.Fonts.Standard14Fonts;
using UglyToad.PdfPig.Writer;
using UglyToad.PdfPig.Core;

namespace ApplyTrack.Api.Materials;

/// <summary>Renders a generated letter into a small, portable text PDF.</summary>
public static partial class CoverLetterPdfRenderer
{
[GeneratedRegex(@"[*_`#]", RegexOptions.CultureInvariant)]
private static partial Regex MarkdownMarkers();

public static byte[] Render(string body)
{
var builder = new PdfDocumentBuilder();
var font = builder.AddStandard14Font(Standard14Font.Helvetica);
var lines = Wrap(ToPdfText(MarkdownMarkers().Replace(body.Replace("\r\n", "\n"), "")));
const double left = 54;
const double top = 770;
const double lineHeight = 16;
const double bottom = 54;
var page = builder.AddPage(PageSize.A4);
var y = top;

foreach (var line in lines)
{
if (y < bottom)
{
page = builder.AddPage(PageSize.A4);
y = top;
}

if (line.Length > 0)
page.AddText(line, 11, new PdfPoint(left, y), font);
y -= lineHeight;
}

return builder.Build();
}

private static IReadOnlyList<string> Wrap(string text)
{
var result = new List<string>();
foreach (var paragraph in text.Split('\n'))
{
if (paragraph.Length == 0)
{
result.Add("");
continue;
}

var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var line = "";
foreach (var word in words)
{
var candidate = line.Length == 0 ? word : $"{line} {word}";
if (candidate.Length > 88 && line.Length > 0)
{
result.Add(line);
line = word;
}
else
{
line = candidate;
}
}
result.Add(line);
}
return result;
}

// PdfPig's Standard 14 fonts are intentionally ASCII-only. Fold accented
// Latin characters and replace any remaining non-ASCII glyphs safely.
private static string ToPdfText(string text)
{
var normalized = text.Normalize(NormalizationForm.FormD);
var chars = normalized
.Where(c => CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
.Select(c => c <= 127 ? c : '?')
.ToArray();
return new string(chars);
}
}
5 changes: 5 additions & 0 deletions api/ApplyTrack.Api/Migrations/0014_cover_letter_signature.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SPDX-License-Identifier: Apache-2.0
-- Copyright 2026 Aaron K. Clark
-- Tenant-provided closing signature appended to generated cover letters.
ALTER TABLE llm_settings
ADD COLUMN IF NOT EXISTS cover_letter_signature text NOT NULL DEFAULT '';
Loading
Loading