From 92f807c33b274a229d0f541d32c763783a13ec98 Mon Sep 17 00:00:00 2001 From: ogarciarevett Date: Thu, 28 May 2026 21:32:13 +0200 Subject: [PATCH 1/2] feat: import candidate brief from LinkedIn profile (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional LinkedIn-import path alongside CV upload for generating config/candidate-brief.md, using LinkedIn's self-serve "Save to PDF" export (no scraping/auth, ToS-safe) through the existing parse → LLM pipeline with a LinkedIn-tuned prompt. - src/lib/brief-prompt.ts: single shared buildBriefPrompt(text, source, maxChars) with a 'cv' | 'linkedin' source. Consolidates the prompt that was duplicated in setup-brief.ts and ui/plugins/brief.ts; the output contract (3 paragraphs) is identical across sources so quality matches. - ui/plugins/brief.ts + _shared.ts: /api/cv accepts an optional, allowlist- validated `source`; defaults to 'cv'. - ui/src/lib/api/index.ts: cv.upload input gains optional `source`. - ui/src/Onboarding.tsx: optional "Import from LinkedIn" affordance in the CV-upload step (collapsed; reveals Save-to-PDF steps + PDF picker). - ui/src/Profile.tsx: matching "From LinkedIn" toggle on the Profile tab. - src/setup-brief.ts: `--linkedin ` flag + filename auto-detect. - Tests (brief-prompt.test.ts, Onboarding.test.tsx) + README docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 17 +++++--- src/lib/brief-prompt.ts | 53 +++++++++++++++++++++++++ src/setup-brief.ts | 58 +++++++++++++++++----------- tests/brief-prompt.test.ts | 41 ++++++++++++++++++++ ui/plugins/_shared.ts | 3 ++ ui/plugins/brief.ts | 33 ++++++++-------- ui/src/Onboarding.module.css | 49 +++++++++++++++++++++++ ui/src/Onboarding.test.tsx | 30 +++++++++++++++ ui/src/Onboarding.tsx | 75 ++++++++++++++++++++++++++++++++++-- ui/src/Profile.tsx | 58 +++++++++++++++++++++++++--- ui/src/lib/api/index.ts | 17 ++++++-- 11 files changed, 376 insertions(+), 58 deletions(-) create mode 100644 src/lib/brief-prompt.ts create mode 100644 tests/brief-prompt.test.ts diff --git a/README.md b/README.md index 21e8d95..201515c 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ PUPILA shells out to whichever AI CLI you already have authenticated locally — Drag a `.pdf` / `.docx` / `.md` / `.txt` CV onto the drop zone (or click **Choose file**). The CV is parsed locally and sent to your LLM CLI to generate a short candidate brief — who you are, what stack you work in, what kind of role you want, and what to avoid. The original CV stays on disk at `config/cv.` (gitignored) so **AI Apply** can re-attach it later. +**No recent CV?** Click **Import from LinkedIn instead** (optional) and upload a LinkedIn profile PDF — on your LinkedIn profile, go to **More → Save to PDF**, then drop the downloaded file here. It runs through the same parse → LLM pipeline with a LinkedIn-tuned prompt, so the resulting brief is comparable to a CV-sourced one. (No login or scraping — the "Save to PDF" export is fully self-serve.) + ### 3. Confirm the generated brief

@@ -84,7 +86,7 @@ Contributing rules and project invariants live in [`CONTRIBUTING.md`](./CONTRIBU > **Looking for today's matches?** → [`JOBS.md`](./JOBS.md) (auto-generated by the local aggregator). > Raw data lives in [`data/jobs.json`](./data/jobs.json). > Local RSS feed at [`data/feed.xml`](./data/feed.xml) (point your reader at the file:// path). -> Prefer a UI? → `pnpm run ui` opens a local-only Vite dashboard at `http://127.0.0.1:5173`. Four tabs: **Jobs** (filter, search, sortable columns, click-to-expand rows over `data/jobs.json`), **Jinder** (Tinder-style swipe deck — right-swipe to queue an AI Apply, left-swipe to skip), **Profile** (drop a PDF/DOCX/MD CV to set up or refresh your candidate brief), and **Settings** (scheduler lifecycle, scoring profile regenerate, disk usage, apply queue). See [AI Apply](#ai-apply-per-job-optional) and [AI per-job review](#ai-per-job-review) below. +> Prefer a UI? → `pnpm run ui` opens a local-only Vite dashboard at `http://127.0.0.1:5173`. Four tabs: **Jobs** (filter, search, sortable columns, click-to-expand rows over `data/jobs.json`), **Jinder** (Tinder-style swipe deck — right-swipe to queue an AI Apply, left-swipe to skip), **Profile** (drop a PDF/DOCX/MD CV — or import a LinkedIn profile PDF — to set up or refresh your candidate brief), and **Settings** (scheduler lifecycle, scoring profile regenerate, disk usage, apply queue). See [AI Apply](#ai-apply-per-job-optional) and [AI per-job review](#ai-per-job-review) below. --- @@ -113,16 +115,19 @@ The friendliest path is the **first-run onboarding wizard**: run `pnpm run ui` o For the CLI-only path, run setup-brief directly: ```bash -pnpm run setup-brief --file ~/Documents/cv.pdf # PDF -pnpm run setup-brief --file ~/Documents/cv.docx # Word document -pnpm run setup-brief --file ~/Documents/cv.md # markdown -cat resume.txt | pnpm run setup-brief # stdin +pnpm run setup-brief --file ~/Documents/cv.pdf # PDF +pnpm run setup-brief --file ~/Documents/cv.docx # Word document +pnpm run setup-brief --file ~/Documents/cv.md # markdown +pnpm run setup-brief --linkedin ~/Downloads/profile.pdf # LinkedIn "Save to PDF" export +cat resume.txt | pnpm run setup-brief # stdin ``` +`--linkedin` runs the same pipeline as `--file` but tells the LLM the input is a LinkedIn profile export (so it ignores LinkedIn's boilerplate). Don't have a recent CV? On your LinkedIn profile, go to **More → Save to PDF** and pass the downloaded file. (A `--file` whose name contains "linkedin" is auto-detected as a LinkedIn source.) + Or open the UI and use the Profile tab: ```bash -pnpm run ui # http://127.0.0.1:5173 → Profile tab → drop your CV +pnpm run ui # http://127.0.0.1:5173 → Profile tab → drop your CV (or "From LinkedIn") ``` The auto-detected provider order is `claude` → `codex` → `gemini` → `opencode` (whichever is on `PATH` first). Override with `PUPILA_LLM=codex pnpm run setup-brief ...`. No API keys; uses your existing CLI subscription. diff --git a/src/lib/brief-prompt.ts b/src/lib/brief-prompt.ts new file mode 100644 index 0000000..fe5398a --- /dev/null +++ b/src/lib/brief-prompt.ts @@ -0,0 +1,53 @@ +// Single source of truth for the candidate-brief summarization prompt. +// +// Both entry points that turn a CV-like document into config/candidate-brief.md +// share this builder so they can't drift: +// - the `pnpm run setup-brief` CLI (src/setup-brief.ts) +// - the UI's POST /api/cv middleware (ui/plugins/brief.ts) +// +// The output contract is identical regardless of source — three short +// paragraphs of plain markdown — so a LinkedIn-sourced brief is comparable in +// quality to a CV-sourced one. Only the *framing* changes: a LinkedIn "Save to +// PDF" export has a predictable structure (and predictable boilerplate) that +// the LLM does better with when we name it explicitly. + +export type BriefSource = 'cv' | 'linkedin'; + +// Shared three-paragraph contract + closing instructions. Kept verbatim across +// sources so the resulting brief has the same shape no matter where the raw +// text came from. +const OUTPUT_CONTRACT = `Output ONLY three short paragraphs as plain markdown text. No preamble, no markdown fences, no headings, no commentary. + +PARAGRAPH 1 — Who they are: role, years of experience, primary location, primary stack/skills. Be concrete (frameworks, languages, tools they ship with regularly). +PARAGRAPH 2 — What they're looking for: target seniority (senior / lead / staff / principal IC), domains/sectors of interest (web3, AI, fintech, etc.), location preference (remote-worldwide / remote-EMEA / hybrid in / open to relocation). +PARAGRAPH 3 — What to avoid: roles that look like a fit on paper but aren't. Examples: wrong specialty (backend if frontend, etc.), wrong level (junior, intern, exec), on-site only, US-only positions, support/solutions/devrel/GTM titles. + +Aim for 6-10 lines total. Drop anything that doesn't help a job-matching tool decide. Don't editorialize.`; + +const CV_INTRO = `You are summarizing the following CV into a short candidate brief that will be sent to an LLM each time the candidate's job-matching tool evaluates a posting. The brief decides whether the LLM agrees with the rule-based fit score.`; + +const LINKEDIN_INTRO = `You are summarizing the candidate's LinkedIn profile into a short candidate brief that will be sent to an LLM each time the candidate's job-matching tool evaluates a posting. The brief decides whether the LLM agrees with the rule-based fit score.`; + +// LinkedIn "Save to PDF" exports interleave profile content with structural +// boilerplate (Contact section, "Page N of M" footers, endorsement/skill +// counts, "Top Skills", repeated headers). Naming the source lets the LLM +// ignore that noise and read the Experience/Education sections as a résumé. +const LINKEDIN_PREAMBLE = `The text below was extracted from a LinkedIn profile exported via "Save to PDF". Treat it as the candidate's résumé. Ignore LinkedIn boilerplate — contact details, "Page N of M" footers, skill-endorsement counts, "Top Skills" / "Contact" section labels, and repeated page headers. Infer the candidate's current role, seniority, and stack from the Experience and Education sections.`; + +/** + * Build the summarization prompt for a CV or LinkedIn profile export. + * + * @param text parsed plain text of the document (CV or LinkedIn PDF) + * @param source 'cv' (default) or 'linkedin' — only changes the framing + * @param maxChars hard cap on how much of `text` we forward to the LLM + */ +export function buildBriefPrompt(text: string, source: BriefSource, maxChars: number): string { + const label = source === 'linkedin' ? 'LINKEDIN PROFILE' : 'CV'; + const intro = source === 'linkedin' ? `${LINKEDIN_INTRO}\n\n${LINKEDIN_PREAMBLE}` : CV_INTRO; + return `${intro} + +${OUTPUT_CONTRACT} + +${label}: +${text.slice(0, maxChars)}`; +} diff --git a/src/setup-brief.ts b/src/setup-brief.ts index 70637b9..f6f3cfe 100644 --- a/src/setup-brief.ts +++ b/src/setup-brief.ts @@ -5,17 +5,23 @@ // keeping it sharp directly improves the per-job verdicts. // // CLI: -// pnpm run setup-brief --file path/to/cv.pdf # parse PDF (via pdfjs-dist) -// pnpm run setup-brief --file path/to/cv.docx # parse DOCX (via mammoth) -// pnpm run setup-brief --file path/to/cv.md # plain markdown -// pnpm run setup-brief --file path/to/cv.txt # plain text -// cat cv.txt | pnpm run setup-brief # stdin +// pnpm run setup-brief --file path/to/cv.pdf # parse PDF (via pdfjs-dist) +// pnpm run setup-brief --file path/to/cv.docx # parse DOCX (via mammoth) +// pnpm run setup-brief --file path/to/cv.md # plain markdown +// pnpm run setup-brief --file path/to/cv.txt # plain text +// pnpm run setup-brief --linkedin path/to/profile.pdf # LinkedIn "Save to PDF" export +// cat cv.txt | pnpm run setup-brief # stdin +// +// `--linkedin` is the same pipeline as `--file` but tells the LLM the input is +// a LinkedIn profile export so it ignores LinkedIn's boilerplate. A `--file` +// whose name contains "linkedin" is auto-treated as a LinkedIn source. // // Provider: auto-detects claude / codex / gemini / opencode on PATH (in that // order). Override with PUPILA_LLM=. import { existsSync } from 'node:fs'; import { copyFile } from 'node:fs/promises'; +import { type BriefSource, buildBriefPrompt } from './lib/brief-prompt.js'; import { writeBriefBody } from './lib/brief-template.js'; import { detectFormat, parseCvFile } from './lib/cv-parser.js'; import { detectLlmCli, runLlm } from './lib/llm.js'; @@ -27,11 +33,15 @@ const CV_DEST_BASENAME = 'config/cv'; interface CliArgs { file: string | null; + source: BriefSource; help: boolean; } function parseArgs(argv: string[]): CliArgs { let file: string | null = null; + // Only flips to 'linkedin' when --linkedin is passed, or when a --file path + // looks like a LinkedIn export (see inference below). + let source: BriefSource = 'cv'; let help = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -44,11 +54,26 @@ function parseArgs(argv: string[]): CliArgs { file = next; i++; } + } else if (arg.startsWith('--linkedin=')) { + file = arg.slice(11); + source = 'linkedin'; + } else if (arg === '--linkedin' && i + 1 < argv.length) { + const next = argv[i + 1]; + if (next) { + file = next; + source = 'linkedin'; + i++; + } } else if (arg === '--help' || arg === '-h') { help = true; } } - return { file, help }; + // Auto-detect a LinkedIn export passed via --file by its filename, so + // `--file ~/Downloads/LinkedIn_Profile.pdf` still gets the tuned prompt. + if (source === 'cv' && file && /linkedin/i.test(file)) { + source = 'linkedin'; + } + return { file, source, help }; } async function readStdin(): Promise { @@ -59,21 +84,6 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString('utf-8'); } -function buildPrompt(cvText: string): string { - return `You are summarizing the following CV into a short candidate brief that will be sent to an LLM each time the candidate's job-matching tool evaluates a posting. The brief decides whether the LLM agrees with the rule-based fit score. - -Output ONLY three short paragraphs as plain markdown text. No preamble, no markdown fences, no headings, no commentary. - -PARAGRAPH 1 — Who they are: role, years of experience, primary location, primary stack/skills. Be concrete (frameworks, languages, tools they ship with regularly). -PARAGRAPH 2 — What they're looking for: target seniority (senior / lead / staff / principal IC), domains/sectors of interest (web3, AI, fintech, etc.), location preference (remote-worldwide / remote-EMEA / hybrid in / open to relocation). -PARAGRAPH 3 — What to avoid: roles that look like a fit on paper but aren't. Examples: wrong specialty (backend if frontend, etc.), wrong level (junior, intern, exec), on-site only, US-only positions, support/solutions/devrel/GTM titles. - -Aim for 6-10 lines total. Drop anything that doesn't help a job-matching tool decide. Don't editorialize. - -CV: -${cvText.slice(0, MAX_CV_CHARS)}`; -} - function stripMarkdownFences(text: string): string { let cleaned = text.trim(); if (cleaned.startsWith('```')) { @@ -89,6 +99,7 @@ async function main(): Promise { console.log(' pnpm run setup-brief --file path/to/cv.pdf'); console.log(' pnpm run setup-brief --file path/to/cv.docx'); console.log(' pnpm run setup-brief --file path/to/cv.md'); + console.log(' pnpm run setup-brief --linkedin path/to/profile.pdf # LinkedIn "Save to PDF"'); console.log(' cat cv.txt | pnpm run setup-brief'); console.log(''); console.log('Provider: auto-detects claude/codex/gemini/opencode on PATH.'); @@ -103,7 +114,8 @@ async function main(): Promise { process.exit(1); } const format = detectFormat(args.file); - console.log(`Reading ${args.file} (${format})...`); + const sourceLabel = args.source === 'linkedin' ? 'LinkedIn export' : 'CV'; + console.log(`Reading ${args.file} (${format}, ${sourceLabel})...`); try { cvText = await parseCvFile(args.file); } catch (err) { @@ -149,7 +161,7 @@ async function main(): Promise { `Parsed ${cvText.length} chars. Running ${invocation.provider} (${invocation.cmd})...`, ); - const prompt = buildPrompt(cvText); + const prompt = buildBriefPrompt(cvText, args.source, MAX_CV_CHARS); let raw: string; try { raw = await runLlm(prompt); diff --git a/tests/brief-prompt.test.ts b/tests/brief-prompt.test.ts new file mode 100644 index 0000000..b3d0c62 --- /dev/null +++ b/tests/brief-prompt.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { buildBriefPrompt } from '../src/lib/brief-prompt.js'; + +const SAMPLE = 'Jane Doe — Senior Frontend Engineer. React, TypeScript, 8 years.'; + +describe('buildBriefPrompt', () => { + it('keeps the three-paragraph output contract for both sources', () => { + for (const source of ['cv', 'linkedin'] as const) { + const prompt = buildBriefPrompt(SAMPLE, source, 12_000); + expect(prompt).toContain('PARAGRAPH 1 — Who they are'); + expect(prompt).toContain('PARAGRAPH 2 — What they'); + expect(prompt).toContain('PARAGRAPH 3 — What to avoid'); + expect(prompt).toContain('No preamble, no markdown fences'); + // The raw document text is always appended. + expect(prompt).toContain(SAMPLE); + } + }); + + it('labels the input as CV for the cv source', () => { + const prompt = buildBriefPrompt(SAMPLE, 'cv', 12_000); + expect(prompt).toContain('summarizing the following CV'); + expect(prompt).toContain('\nCV:\n'); + // No LinkedIn-specific framing leaks into the CV prompt. + expect(prompt).not.toMatch(/LinkedIn/i); + }); + + it('adds LinkedIn-tuned framing for the linkedin source', () => { + const prompt = buildBriefPrompt(SAMPLE, 'linkedin', 12_000); + expect(prompt).toContain("summarizing the candidate's LinkedIn profile"); + expect(prompt).toContain('Save to PDF'); + expect(prompt).toContain('Ignore LinkedIn boilerplate'); + expect(prompt).toContain('\nLINKEDIN PROFILE:\n'); + }); + + it('truncates the document text to maxChars', () => { + const long = 'x'.repeat(50); + const prompt = buildBriefPrompt(long, 'cv', 10); + expect(prompt).toContain('xxxxxxxxxx'); // exactly 10 + expect(prompt).not.toContain('x'.repeat(11)); + }); +}); diff --git a/ui/plugins/_shared.ts b/ui/plugins/_shared.ts index 8b0e71d..0136957 100644 --- a/ui/plugins/_shared.ts +++ b/ui/plugins/_shared.ts @@ -20,6 +20,9 @@ export const CV_MAX_CHARS = Number(process.env.PUPILA_CV_MAX_CHARS ?? '12000'); // `src/types.ts` so the MCP server and the UI agree on the same values. export const VALID_STATUSES: ReadonlySet = new Set(APPLICATION_STATUSES); export const VALID_CV_FORMATS = new Set(['pdf', 'docx', 'md', 'txt']); +// Brief input source — 'cv' (a résumé/CV) or 'linkedin' (a LinkedIn +// "Save to PDF" export). Only changes the LLM prompt framing. +export const VALID_CV_SOURCES = new Set(['cv', 'linkedin']); export const VALID_PROVIDER_OR_AUTO = new Set([...SUPPORTED_PROVIDERS, 'auto']); const CV_EXTENSIONS: readonly CvFormat[] = ['pdf', 'docx', 'md', 'txt']; diff --git a/ui/plugins/brief.ts b/ui/plugins/brief.ts index 739e9b9..81c5bae 100644 --- a/ui/plugins/brief.ts +++ b/ui/plugins/brief.ts @@ -1,12 +1,13 @@ import { writeFile } from 'node:fs/promises'; import type { Plugin } from 'vite'; +import { type BriefSource, buildBriefPrompt } from '../../src/lib/brief-prompt.js'; import { readBriefBody, writeBriefBody } from '../../src/lib/brief-template.js'; import { type CvFormat, parseCvBuffer } from '../../src/lib/cv-parser.js'; import { runLlm } from '../../src/lib/llm.js'; import { stripFences } from '../../src/lib/profile-generator.js'; import { streamableResponse } from '../../src/lib/streamable-response.js'; import { CV_BASENAME } from './_paths.ts'; -import { CV_MAX_CHARS, readBody, VALID_CV_FORMATS } from './_shared.ts'; +import { CV_MAX_CHARS, readBody, VALID_CV_FORMATS, VALID_CV_SOURCES } from './_shared.ts'; interface BriefGetResponse { body: string | null; @@ -19,21 +20,9 @@ interface BriefPostBody { interface CvPostBody { format?: unknown; data?: unknown; -} - -function buildCvSummaryPrompt(cvText: string): string { - return `You are summarizing the following CV into a short candidate brief that will be sent to an LLM each time the candidate's job-matching tool evaluates a posting. The brief decides whether the LLM agrees with the rule-based fit score. - -Output ONLY three short paragraphs as plain markdown text. No preamble, no markdown fences, no headings, no commentary. - -PARAGRAPH 1 — Who they are: role, years of experience, primary location, primary stack/skills. Be concrete (frameworks, languages, tools they ship with regularly). -PARAGRAPH 2 — What they're looking for: target seniority (senior / lead / staff / principal IC), domains/sectors of interest, location preference (remote-worldwide / remote-EMEA / hybrid in / open to relocation). -PARAGRAPH 3 — What to avoid: roles that look like a fit on paper but aren't. Examples: wrong specialty, wrong level, on-site only, US-only positions, support/solutions/devrel/GTM titles. - -Aim for 6-10 lines total. Drop anything that doesn't help a job-matching tool decide. Don't editorialize. - -CV: -${cvText.slice(0, CV_MAX_CHARS)}`; + // 'cv' (default) or 'linkedin'. LinkedIn = a profile exported via + // "Save to PDF"; only changes the LLM prompt framing (see brief-prompt.ts). + source?: unknown; } export function briefApiPlugin(): Plugin { @@ -84,17 +73,26 @@ export function briefApiPlugin(): Plugin { // normal JSON 4xx regardless of Accept — streaming hasn't started // yet so we can still set a status code. let format: CvFormat; + let source: BriefSource; let buf: Buffer; try { const body = (await readBody(req)) as CvPostBody; const rawFormat = typeof body.format === 'string' ? (body.format as CvFormat) : null; const data = typeof body.data === 'string' ? body.data : ''; + // Default to 'cv' when omitted so existing callers keep working. + const rawSource = typeof body.source === 'string' ? body.source : 'cv'; if (!rawFormat || !VALID_CV_FORMATS.has(rawFormat)) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'invalid format (pdf/docx/md/txt)' })); return; } + if (!VALID_CV_SOURCES.has(rawSource)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'invalid source (cv/linkedin)' })); + return; + } if (!data) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); @@ -102,6 +100,7 @@ export function briefApiPlugin(): Plugin { return; } format = rawFormat; + source = rawSource as BriefSource; // Binary formats arrive base64-encoded; text formats arrive as // utf-8 strings sent via JSON. Either way, normalize to a Buffer. buf = @@ -134,7 +133,7 @@ export function briefApiPlugin(): Plugin { } responder.send({ type: 'stage', stage: 'calling-llm' }); const raw = await runLlm( - buildCvSummaryPrompt(cvText), + buildBriefPrompt(cvText, source, CV_MAX_CHARS), undefined, responder.isStreaming ? (chunk) => responder.send({ type: 'chunk', data: chunk }) diff --git a/ui/src/Onboarding.module.css b/ui/src/Onboarding.module.css index 84765df..d1b1c90 100644 --- a/ui/src/Onboarding.module.css +++ b/ui/src/Onboarding.module.css @@ -132,6 +132,55 @@ line-height: 1.45; } +/* Optional "Import from LinkedIn" affordance under the CV drop zone. + Collapsed by default — a quiet alternative, not a competing primary CTA. */ +.linkedin { + margin-top: 0.5rem; +} + +.linkedinToggle { + background: none; + border: none; + padding: 0; + color: var(--primary); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.linkedinToggle:hover:not(:disabled) { + text-decoration: underline; +} + +.linkedinToggle:disabled { + cursor: default; + opacity: 0.6; +} + +.linkedinBody { + margin-top: 0.6rem; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-raised); + font-size: 0.85rem; + line-height: 1.5; +} + +.linkedinSteps { + margin: 0.4rem 0 0.75rem; + padding-left: 1.2rem; +} + +.linkedinSteps li { + margin: 0.2rem 0; +} + +.linkedinActions { + display: flex; + gap: 0.5rem; +} + .warn { background: var(--badge-interview-bg); color: var(--badge-interview-fg); diff --git a/ui/src/Onboarding.test.tsx b/ui/src/Onboarding.test.tsx index f93beff..bab939b 100644 --- a/ui/src/Onboarding.test.tsx +++ b/ui/src/Onboarding.test.tsx @@ -87,3 +87,33 @@ describe('Onboarding — provider step', () => { expect(screen.getByRole('radio', { name: /claude code/i })).toBeEnabled(); }); }); + +describe('Onboarding — CV upload step', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Drive the wizard from the provider step (claude installed) onto step 2. + async function gotoCvStep() { + mockDetect(() => ({ ...NONE_INSTALLED, claude: true })); + render(); + await waitFor(() => expect(screen.getByText('Auto-detect')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /next: upload cv/i })); + await waitFor(() => expect(screen.getByText(/Upload your CV/i)).toBeInTheDocument()); + } + + it('offers an optional LinkedIn import alongside the CV drop', async () => { + await gotoCvStep(); + // The affordance is present but collapsed — instructions hidden until opened. + expect(screen.getByRole('button', { name: /import from linkedin/i })).toBeInTheDocument(); + expect(screen.queryByText(/Save to PDF/i)).not.toBeInTheDocument(); + }); + + it('reveals the Save-to-PDF steps and an upload control when expanded', async () => { + await gotoCvStep(); + fireEvent.click(screen.getByRole('button', { name: /import from linkedin/i })); + + expect(screen.getByText(/Save to PDF/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /upload linkedin pdf/i })).toBeInTheDocument(); + }); +}); diff --git a/ui/src/Onboarding.tsx b/ui/src/Onboarding.tsx index cebf282..55521df 100644 --- a/ui/src/Onboarding.tsx +++ b/ui/src/Onboarding.tsx @@ -21,6 +21,9 @@ import spinnerStyles from './styles/Spinner.module.css'; // Profile-tab empty state handles re-setup). type CvFormat = 'pdf' | 'docx' | 'md' | 'txt'; +// Mirrors BriefSource in src/lib/brief-prompt.ts — kept as a local literal so +// the UI doesn't import server code. 'linkedin' just switches the LLM prompt. +type BriefSource = 'cv' | 'linkedin'; const FORMAT_BY_EXT: Record = { pdf: 'pdf', @@ -111,7 +114,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { }, [available]); const uploadCv = useCallback( - async (file: File) => { + async (file: File, source: BriefSource = 'cv') => { const format = detectFormatFromName(file.name); if (!format) { setError(`Unsupported file: ${file.name}. Use .pdf, .docx, .md, or .txt.`); @@ -122,7 +125,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { try { const data = format === 'pdf' || format === 'docx' ? await fileToBase64(file) : await file.text(); - const done = await cv.start({ format, data }); + const done = await cv.start({ format, data, source }); if (!done?.body) { // hook already set its own error+status; mirror it into the // wizard-level error banner so the user sees a single message. @@ -312,7 +315,8 @@ export function Onboarding({ onComplete }: OnboardingProps) { candidate brief. The original file stays on disk at config/cv.<ext>{' '} (gitignored) so AI Apply can re-attach it later.

- + uploadCv(f, 'cv')} /> + uploadCv(f, 'linkedin')} /> ); } + +interface LinkedinImportProps { + busy: boolean; + onFile: (f: File) => void; +} + +// Optional alternative to dropping a CV: import a LinkedIn profile. There's no +// supported way to scrape LinkedIn (auth + ToS), so we use the self-serve +// "Save to PDF" export — a 5-minute, no-login-needed action — and feed that PDF +// through the same parse→LLM brief pipeline with a LinkedIn-tuned prompt. +// Collapsed by default so it stays out of the way of the primary CV drop. +function LinkedinImport({ busy, onFile }: LinkedinImportProps) { + const [open, setOpen] = useState(false); + const fileInputRef = useRef(null); + return ( +
+ + {open && ( +
+

+ Haven't updated a CV in a while? Export your LinkedIn profile as a PDF and we'll build + the brief from that: +

+
    +
  1. + On your LinkedIn profile, click MoreSave to PDF. +
  2. +
  3. Upload the downloaded PDF here.
  4. +
+
+ +
+ { + const f = e.target.files?.[0]; + if (f) onFile(f); + e.target.value = ''; + }} + /> +
+ )} +
+ ); +} diff --git a/ui/src/Profile.tsx b/ui/src/Profile.tsx index 34ded41..d736445 100644 --- a/ui/src/Profile.tsx +++ b/ui/src/Profile.tsx @@ -6,6 +6,9 @@ import bannerStyles from './styles/Banner.module.css'; import buttonStyles from './styles/Button.module.css'; type CvFormat = 'pdf' | 'docx' | 'md' | 'txt'; +// Mirrors BriefSource in src/lib/brief-prompt.ts. 'linkedin' = a profile +// exported via "Save to PDF"; only switches the LLM prompt framing. +type BriefSource = 'cv' | 'linkedin'; const FORMAT_BY_EXT: Record = { pdf: 'pdf', @@ -47,8 +50,10 @@ export function Profile() { const [info, setInfo] = useState(null); const [pasteMode, setPasteMode] = useState(false); const [pasteText, setPasteText] = useState(''); + const [linkedinMode, setLinkedinMode] = useState(false); const [dragActive, setDragActive] = useState(false); const fileInputRef = useRef(null); + const linkedinInputRef = useRef(null); useEffect(() => { const ctrl = new AbortController(); @@ -71,27 +76,29 @@ export function Profile() { return () => ctrl.abort(); }, []); - const summarizeFile = useCallback(async (file: File) => { + const summarizeFile = useCallback(async (file: File, source: BriefSource = 'cv') => { const format = detectFormatFromName(file.name); if (!format) { setError(`Unsupported file: ${file.name}. Use .pdf, .docx, .md, or .txt.`); return; } + const label = source === 'linkedin' ? 'LinkedIn profile' : file.name; setBusy(true); setError(null); - setInfo(`Parsing ${file.name} and running LLM CLI…`); + setInfo(`Parsing ${label} and running LLM CLI…`); const data = format === 'pdf' || format === 'docx' ? await fileToBase64(file) : await fileToText(file); - const r = await api.cv.upload({ format, data }); + const r = await api.cv.upload({ format, data, source }); setBusy(false); if (!r.ok) { - setError(`CV summarization failed: ${formatError(r.error)}`); + setError(`Brief generation failed: ${formatError(r.error)}`); setInfo(null); return; } setBody(r.value.body); setDraft(r.value.body); - setInfo(`✓ Brief regenerated from ${file.name}.`); + setLinkedinMode(false); + setInfo(`✓ Brief regenerated from ${label}.`); }, []); const summarizePasted = useCallback(async () => { @@ -146,6 +153,12 @@ export function Profile() { e.target.value = ''; } + function onLinkedinPicked(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) void summarizeFile(file, 'linkedin'); + e.target.value = ''; + } + if (loading) { return (
@@ -215,6 +228,14 @@ export function Profile() { > {pasteMode ? 'Cancel paste' : 'Paste text'} +
+ {linkedinMode && ( +
+

+ No recent CV? Export your LinkedIn profile as a PDF (More →{' '} + Save to PDF on your profile) and upload it here — we'll build the brief + from that. +

+
+ +
+ +
+ )} + {pasteMode && (