Skip to content

Tailored document generation (technical)

WorkWingman replicates, inside the app and with the user's own Claude CLI, the process the user otherwise does by hand: take their standard resume + a job's LinkedIn description and produce a tailored resume, a cover letter, and (post-interview) a thank-you note — grounded strictly in the user's real data. The tailored resume is rendered to a real .docx and flows into the apply flow's "upload documents" section.

Plain-language version: ../plain/document-generation.md

The tailoring pattern it replicates

The Claude-backed drafter is prompted to produce, in this order:

  1. A role-matched headline.
  2. A rewritten professional summary aimed at the job description.
  3. A "WHY THIS ROLE" section mapping each JD requirement / nice-to-have to the user's matching real evidence — a requirement with no real match is omitted, never fabricated.
  4. Experience bullets re-angled toward the JD.

Honesty is the whole point

The drafter may only reshape, reorder, and re-emphasize what IntakeProfile / StoryProfile already contain. The prompt forbids inventing or altering employers, titles, dates, degrees, schools, certifications, or skills, and requires preserving every hedge the profile carries (e.g. "proposed, not yet adopted", "did not sit for the exam").

Because a language model can still slip, there is a deterministic backstop: DraftHonestyGuard. It scans the concatenated draft for the four highest-risk fabrications and returns a finding for any that are not attested anywhere in the user's real data:

Checked How it's judged a fabrication
Employers / schools A capitalized multi-word phrase followed by an org/school suffix (Inc, LLC, Corporation, University, …) that the profile never lists.
Degrees A known degree term (Master, PhD, MBA, B.S., …), as a whole word, absent from the profile's education/degree aliases.
Dates Any 1900–2099 year that isn't a graduation year, a work start/end year, or a certification year in the profile.

If the guard returns any finding, ClaudeDrafter rejects the whole draft and falls back to the deterministic template — so a fabrication can never ship. Covered by DraftHonestyGuardTests and ClaudeDrafterTests.FallsBackToTemplate_WhenTheDraftTripsTheHonestyGuard.

How the Claude CLI is invoked and gated

ClaudeDrafter mirrors the existing ClaudeResumeExtractor seam exactly — the user's own logged-in CLI, no API key of ours, nothing written to the CLI's state:

  • Gated on IClaudeCliConnection.GetStatusAsync() reporting CliInstalled && LoggedIn. It never provisions or logs in on the user's behalf.
  • Invoked through IProcessRunner: on Windows cmd.exe /c claude -p, elsewhere claude -p (print mode). The whole prompt (fixed instruction + grounding block) rides on stdin, so there is no argument quoting to get wrong across shells.
  • Hard-timed-out at 45s (SystemProcessRunner kills the whole process tree).
  • Best-effort / never-throws: a missing binary, non-zero exit, timeout, unparseable output, or a guard-tripping draft all fall back to the deterministic TemplateDrafter. Only a genuine cancellation propagates.

The CLI returns minified JSON ({"resumeMarkdown","resumeChanges","coverLetterMarkdown", "coverLetterChanges"}), parsed tolerantly (prose/fences stripped) by the same first-valid-object scan ClaudeResumeExtractor uses. Only the two bodies and their change summaries are taken from Claude; the surrounding envelope (file names, CopyPasteFields) stays the deterministic one.

From draft to an uploaded .docx

  1. ClaudeDrafter renders both bodies to real Word files via DocxWriter (DocumentFormat.OpenXml) and points each TailoredDocument.LocalPath at its file.
  2. Files land under a contained directory — {app-data}/generated-documents/{jobId}/{name}.docx — with every path component slugged to a safe leaf, so no caller string reaches the filesystem verbatim (no traversal surface). A failed render returns an empty path (attach then falls back to the base resume) — it can never break generation.
  3. DocumentService persists the GeneratedDocuments; the user reviews and approves in the UI (DocumentsController generate / regenerate / approve).
  4. On an apply run, PlaywrightApplyRunDriver loads the intake, then — only if this job has an approved tailored resume on disk — overrides intake.SourceResumeFile in memory for this run only so ApplyEngine.AttachResumeAsync uploads the tailored file. The override is never persisted, so one job's tailored resume can't leak into another job's application; unapproved / unrendered jobs attach the user's base resume exactly as before.

Thank-you notes

ClaudeThankYouGenerator extends the IThankYouDraftGenerator seam with the same CLI gating/best-effort contract, drafting the email + LinkedIn notes in the user's voice from only what they logged for that interview (retro, question log, common ground, interviewer name). It keeps the deterministic ThankYouDraftGenerator as its fallback. DRAFT ONLY — same boundary as before: no mail/messaging client, no send path, Approved only records that a human read it. See interview-retro.md.

Tests

DocxWriterTests (valid openable .docx, contained path, overwrite-in-place), DraftHonestyGuardTests (passes grounded, flags fabricated employer/school/degree/year), ClaudeDrafterTests (uses grounded Claude output, renders .docx, falls back on not-installed/not-logged-in/timeout/non-zero/unparseable/guard-trip, feeds prompt on stdin), and ClaudeThankYouGeneratorTests (uses Claude text, falls back on every failure, feeds facts on stdin). All drive a fake IProcessRunner + CLI probe — no real subprocess.