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:
- A role-matched headline.
- A rewritten professional summary aimed at the job description.
- 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.
- 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()reportingCliInstalled && LoggedIn. It never provisions or logs in on the user's behalf. - Invoked through
IProcessRunner: on Windowscmd.exe /c claude -p, elsewhereclaude -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 (
SystemProcessRunnerkills 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¶
ClaudeDrafterrenders both bodies to real Word files viaDocxWriter(DocumentFormat.OpenXml) and points eachTailoredDocument.LocalPathat its file.- 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. DocumentServicepersists theGeneratedDocuments; the user reviews and approves in the UI (DocumentsControllergenerate / regenerate / approve).- On an apply run,
PlaywrightApplyRunDriverloads the intake, then — only if this job has an approved tailored resume on disk — overridesintake.SourceResumeFilein memory for this run only soApplyEngine.AttachResumeAsyncuploads 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.