diff --git a/.env.example b/.env.example index c9aae7d..7ff06dc 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ SUPABASE_SERVICE_ROLE_KEY= SUPABASE_STORAGE_BUCKET=applypilot-assets OPENAI_API_KEY= OPENAI_MODEL=gpt-4.1-mini +APPLYPILOT_LOCAL_STORE_PATH= ENABLE_DEMO_DATA=false VITE_DASHBOARD_URL=http://localhost:3000 VITE_API_BASE_URL=http://localhost:3000 diff --git a/README.md b/README.md index ddf72fc..a324e83 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The product is intentionally single-user and local-first: - Public-safe career stories live in `knowledge_base/`. - Private interview notes can live in `local_workspace/knowledge_base_private/`, which is ignored by Git. -- The app works with an in-memory demo store by default, so it runs without Supabase or OpenAI. +- The app works with a gitignored local JSON store by default, so it runs without Supabase or OpenAI. - AI calls have deterministic fallbacks, keeping the workflow usable without external services. ## Product Tour @@ -37,7 +37,7 @@ The product is intentionally single-user and local-first: | --- | --- | --- | | ![Knowledge Base](docs/assets/knowledge-base.png) | ![Daily Picks](docs/assets/daily-picks.png) | ![Application Tracker](docs/assets/application-tracker.png) | -### Application Workflow V0 +### Application Workflow ![Application Workflow](docs/assets/application-workflow.svg) @@ -45,11 +45,11 @@ The product is intentionally single-user and local-first: | Capability | What it means | | --- | --- | -| Role matching V0 | Scores saved jobs against profile, preferences, keywords, skills, region, salary, remote policy, and application friction. | +| Role matching | Scores saved jobs against profile, preferences, keywords, skills, region, salary, remote policy, and application friction. | | Resume and story retrieval | Retrieves resume evidence plus reusable stories, interview notes, job profiles, and answer playbooks for a role. | | Local Markdown/JSON knowledge base | Reads structured Markdown, JSON sidecars, standalone JSON entries, and private local-only entries. | | Daily Picks | Ranks saved jobs and attaches prep assets under each role so review starts with evidence, not a blank page. | -| Application workflow V0 | Turns a saved role into a human-reviewable checklist with matched resume evidence, story assets, next actions, and tracker state. | +| Application workflow | Turns a saved role into a human-reviewable checklist with matched resume evidence, story assets, next actions, and tracker state. | | Application tracker sync | Manual job saves create `drafted` tracker records, and repeat saves do not reset existing statuses. | | Human-in-the-loop automation | The Chrome extension can assist LinkedIn and MyCareersFuture flows, while risky cases route to review. | @@ -156,27 +156,26 @@ pnpm dev:extension Open [http://localhost:3000](http://localhost:3000). -For a persistent database-backed setup, copy `.env.example` to `.env.local`, fill in Supabase/OpenAI values as needed, and apply the SQL migrations under `supabase/migrations/`. +Without Supabase, saved local data is written to `local_workspace/applypilot-store.json`. +For a database-backed setup, copy `.env.example` to `.env.local`, fill in Supabase/OpenAI values as needed, and apply the SQL migrations under `supabase/migrations/`. ## Verification ```bash -pnpm test # 21 tests covering scoring, KB parsing/retrieval, workflow prep, tracker sync, store behavior +pnpm test # 22 tests covering scoring, KB parsing/retrieval, workflow prep, tracker sync, store behavior pnpm build # production build for all workspace packages pnpm lint # typecheck and lint ``` -The current portfolio packaging lives on `codex/application-workflow-v0-new`. - ## Privacy Boundary Commit only sanitized, reusable material under `knowledge_base/`. Keep private recruiter details, interview schedules, exact compensation expectations, personal documents, local resume paths, and sensitive application notes under `local_workspace/knowledge_base_private/`. -`local_workspace/` is ignored by Git. +`local_workspace/` is ignored by Git, including the fallback store at `local_workspace/applypilot-store.json`. ## Project Status -This is a complete portfolio-ready V0 of the knowledge-backed job-search workflow: +This is a portfolio-ready slice of the knowledge-backed job-search workflow: - Match saved jobs. - Retrieve resume evidence and career-story assets. diff --git a/apps/extension/src/content/linkedin.ts b/apps/extension/src/content/linkedin.ts index b070bdc..426106d 100644 --- a/apps/extension/src/content/linkedin.ts +++ b/apps/extension/src/content/linkedin.ts @@ -6,18 +6,26 @@ type BootstrapPayload = { summary: { dailyTarget: number; }; - profile: { - fullName: string; - phone: string; - email: string; - location: string; - } | null; - preference?: { - regions?: string[]; - minSalary?: number; - salaryCurrency?: string; - } | null; -}; + profile: { + fullName: string; + phone: string; + email: string; + location: string; + yearsExperience?: number; + } | null; + preference?: { + targetRoles?: string[]; + regions?: string[]; + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: 'yes' | 'no' | 'unknown'; + requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; + willingToRelocate?: 'yes' | 'no' | 'unknown'; + } | null; + }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -1152,22 +1160,44 @@ const getPreferredLocationValue = ({ ); const getPreferredSalaryValue = (preference?: BootstrapPayload['preference'] | null) => { - if (!preference?.minSalary || preference.minSalary <= 0) { - return ''; + if (preference?.applicationSalaryAmount && preference.applicationSalaryAmount > 0) { + return String(preference.applicationSalaryAmount); } - const monthly = Math.max(1, Math.round(preference.minSalary / 12)); - return String(monthly); + if (preference?.minSalary && preference.minSalary > 0) { + return String(Math.max(1, Math.round(preference.minSalary / 12))); + } + + return ''; +}; + +const getPreferredYearsValue = ( + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { + const years = preference?.yearsExperienceOverride ?? profile?.yearsExperience ?? null; + + return typeof years === 'number' && Number.isFinite(years) && years > 0 ? String(years) : ''; +}; + +const getNoticePeriodValue = (preference?: BootstrapPayload['preference'] | null) => { + const weeks = preference?.noticePeriodWeeks; + + return typeof weeks === 'number' && Number.isFinite(weeks) && weeks >= 0 ? String(weeks) : ''; }; -const inferYearsAnswer = (context: string) => { +const inferYearsAnswer = ( + context: string, + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { const normalized = normalizeToken(context); if (!/(years|experience|经验|多久)/.test(normalized)) { return ''; } - return '8'; + return getPreferredYearsValue(preference, profile); }; const looksLikeSuspiciousFullName = (value: string) => @@ -1223,7 +1253,7 @@ const fillKnownTextFields = ({ continue; } - const inferredYears = inferYearsAnswer(context); + const inferredYears = inferYearsAnswer(context, preference, profile); if (inferredYears && /^(number|text|tel|search|url|email)?$/i.test(control.type || 'text')) { setFieldValue(control, inferredYears); } @@ -1290,7 +1320,13 @@ const getRadioLabelText = (radio: HTMLInputElement) => ), ); -const chooseRadioCandidate = (radios: HTMLInputElement[]) => { +const choiceTokens = (value?: 'yes' | 'no' | 'unknown') => + value === 'yes' || value === 'no' ? [value] : []; + +const chooseRadioCandidate = ( + radios: HTMLInputElement[], + preference?: BootstrapPayload['preference'] | null, +) => { const questionText = normalizeToken( firstNonEmpty( textOf(radios[0]?.closest('fieldset')), @@ -1299,12 +1335,20 @@ const chooseRadioCandidate = (radios: HTMLInputElement[]) => { ), ); - const preferredTokens = /sponsor|sponsorship|visa|work authorization|work authorisation/.test(questionText) - ? ['no'] - : /degree|education|bachelor|master|phd|completed|complete the following level/.test(questionText) + const preferredTokens = /sponsor|sponsorship|visa/.test(questionText) + ? choiceTokens(preference?.requiresVisaSponsorship) + : /work authorization|work authorisation|authorized to work|authorised to work/.test(questionText) + ? choiceTokens(preference?.workAuthorization) + : /relocat|willing to move/.test(questionText) + ? choiceTokens(preference?.willingToRelocate) + : /degree|education|bachelor|master|phd|completed|complete the following level/.test(questionText) ? ['yes'] : ['yes', 'no']; + if (preferredTokens.length === 0) { + return null; + } + for (const token of preferredTokens) { const candidate = radios.find((radio) => new RegExp(`\\b${token}\\b`).test(getRadioLabelText(radio))); if (candidate) { @@ -1339,6 +1383,7 @@ const getFallbackFieldValue = ({ const context = getFieldContextText(element); const preferredLocation = getPreferredLocationValue({ profile, preference, jobLocation }); const preferredSalary = getPreferredSalaryValue(preference); + const noticePeriod = getNoticePeriodValue(preference); if (/email/.test(context) && profile?.email) { return profile.email; @@ -1353,22 +1398,22 @@ const getFallbackFieldValue = ({ } if (/salary|compensation|pay|薪资|薪酬/.test(context)) { - return preferredSalary || '10000'; + return preferredSalary; } if (/notice/.test(context)) { - return '2'; + return noticePeriod; } if (/(years|experience|经验|多久)/.test(context)) { - return '8'; + return inferYearsAnswer(context, preference, profile); } if (element instanceof HTMLInputElement && element.type === 'number') { - return '1'; + return ''; } - return '1'; + return ''; }; const answerKnockoutQuestions = ({ @@ -1392,15 +1437,28 @@ const answerKnockoutQuestions = ({ } const dialog = getEasyApplyDialog() ?? document; + const preferredYears = Number(getPreferredYearsValue(preference, profile)); const requiredGroups = Array.from(dialog.querySelectorAll('fieldset')).filter((fieldset) => isVisible(fieldset) && - fieldset.textContent?.toLowerCase().includes('years'), + fieldset.textContent?.toLowerCase().includes('years') && + Number.isFinite(preferredYears) && + preferredYears > 0, ); requiredGroups.forEach((group) => { - const preferred = group.querySelector( - 'input[type="radio"][value*="5"], input[type="radio"][value*="10"]', - ); + const preferred = + Array.from(group.querySelectorAll('input[type="radio"]')) + .map((radio) => { + const label = getRadioLabelText(radio); + const numericValue = Number(label.match(/\d+/)?.[0] ?? radio.value.match(/\d+/)?.[0] ?? NaN); + return { + radio, + numericValue, + }; + }) + .filter((candidate) => Number.isFinite(candidate.numericValue) && candidate.numericValue <= preferredYears) + .sort((left, right) => right.numericValue - left.numericValue)[0]?.radio ?? null; + preferred?.click(); }); @@ -1409,7 +1467,7 @@ const answerKnockoutQuestions = ({ return; } - const candidate = chooseRadioCandidate(radios); + const candidate = chooseRadioCandidate(radios, preference); candidate?.click(); }); @@ -1422,7 +1480,7 @@ const answerKnockoutQuestions = ({ return; } - const inferredYears = inferYearsAnswer(getFieldContextText(element)); + const inferredYears = inferYearsAnswer(getFieldContextText(element), preference, profile); if (inferredYears) { setFieldValue(element, inferredYears); } diff --git a/apps/extension/src/content/mycareersfuture.ts b/apps/extension/src/content/mycareersfuture.ts index a3081f3..2146907 100644 --- a/apps/extension/src/content/mycareersfuture.ts +++ b/apps/extension/src/content/mycareersfuture.ts @@ -13,18 +13,26 @@ type BootstrapPayload = { summary: { dailyTarget: number; }; - profile: { - fullName: string; - phone: string; - email: string; - location: string; - } | null; - preference?: { - regions?: string[]; - minSalary?: number; - salaryCurrency?: string; - } | null; -}; + profile: { + fullName: string; + phone: string; + email: string; + location: string; + yearsExperience?: number; + } | null; + preference?: { + targetRoles?: string[]; + regions?: string[]; + minSalary?: number; + salaryCurrency?: string; + applicationSalaryAmount?: number; + yearsExperienceOverride?: number | null; + noticePeriodWeeks?: number | null; + workAuthorization?: 'yes' | 'no' | 'unknown'; + requiresVisaSponsorship?: 'yes' | 'no' | 'unknown'; + willingToRelocate?: 'yes' | 'no' | 'unknown'; + } | null; + }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -238,41 +246,63 @@ const getPreferredLocationValue = ({ profile: BootstrapPayload['profile']; preference?: BootstrapPayload['preference'] | null; jobLocation: string; -}) => - firstNonEmpty( - preference?.regions?.[0], - jobLocation, - profile?.location, - 'Singapore', - ); + }) => + firstNonEmpty( + preference?.regions?.[0], + jobLocation, + profile?.location, + ); const getPreferredSalaryValue = (preference?: BootstrapPayload['preference'] | null) => { - if (typeof preference?.minSalary === 'number') { + if (preference?.applicationSalaryAmount && preference.applicationSalaryAmount > 0) { + return String(preference.applicationSalaryAmount); + } + + if (preference?.minSalary && preference.minSalary > 0) { return String(preference.minSalary); } - return '10000'; + return ''; }; -const inferYearsAnswer = (context: string) => { +const getPreferredYearsValue = ( + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { + const years = preference?.yearsExperienceOverride ?? profile?.yearsExperience ?? null; + + return typeof years === 'number' && Number.isFinite(years) && years > 0 ? String(years) : ''; +}; + +const getNoticePeriodValue = (preference?: BootstrapPayload['preference'] | null) => { + const weeks = preference?.noticePeriodWeeks; + + return typeof weeks === 'number' && Number.isFinite(weeks) && weeks >= 0 ? String(weeks) : ''; +}; + +const inferYearsAnswer = ( + context: string, + preference?: BootstrapPayload['preference'] | null, + profile?: BootstrapPayload['profile'], +) => { const normalized = normalizeToken(context); if (!normalized) { return ''; } if (/notice period/.test(normalized)) { - return '2'; + return getNoticePeriodValue(preference); } if (/salary|pay|compensation/.test(normalized)) { - return '10000'; + return getPreferredSalaryValue(preference); } if (/year|experience/.test(normalized)) { - return '5'; + return getPreferredYearsValue(preference, profile); } - return '1'; + return ''; }; const fillKnownTextFields = ({ @@ -351,7 +381,13 @@ const getRadioLabelText = (radio: HTMLInputElement) => ), ); -const chooseRadioCandidate = (radios: HTMLInputElement[]) => { +const choiceTokens = (value?: 'yes' | 'no' | 'unknown') => + value === 'yes' || value === 'no' ? [value] : []; + +const chooseRadioCandidate = ( + radios: HTMLInputElement[], + preference?: BootstrapPayload['preference'] | null, +) => { const questionText = normalizeToken( firstNonEmpty( textOf(radios[0]?.closest('fieldset')), @@ -360,11 +396,19 @@ const chooseRadioCandidate = (radios: HTMLInputElement[]) => { ), ); - const preferredTokens = /sponsor|sponsorship|visa|work authorization|work authorisation/.test(questionText) - ? ['no'] - : /comfortable|onsite|on-site|willing|degree|education|completed/.test(questionText) - ? ['yes', 'no'] - : ['yes', 'no']; + const preferredTokens = /sponsor|sponsorship|visa/.test(questionText) + ? choiceTokens(preference?.requiresVisaSponsorship) + : /work authorization|work authorisation|authorized to work|authorised to work/.test(questionText) + ? choiceTokens(preference?.workAuthorization) + : /relocat|willing to move/.test(questionText) + ? choiceTokens(preference?.willingToRelocate) + : /comfortable|onsite|on-site|willing|degree|education|completed/.test(questionText) + ? ['yes', 'no'] + : ['yes', 'no']; + + if (preferredTokens.length === 0) { + return null; + } for (const token of preferredTokens) { const candidate = radios.find((radio) => new RegExp(`\\b${token}\\b`).test(getRadioLabelText(radio))); @@ -376,7 +420,13 @@ const chooseRadioCandidate = (radios: HTMLInputElement[]) => { return radios[0] ?? null; }; -const answerQuestions = () => { +const answerQuestions = ({ + profile, + preference, +}: { + profile: BootstrapPayload['profile']; + preference?: BootstrapPayload['preference'] | null; +}) => { const controls = getFormControls(); controls.forEach((element) => { @@ -391,7 +441,7 @@ const answerQuestions = () => { return; } - const candidate = chooseRadioCandidate(radios); + const candidate = chooseRadioCandidate(radios, preference); candidate?.click(); }); @@ -404,13 +454,17 @@ const answerQuestions = () => { return; } - const inferred = inferYearsAnswer(getFieldContextText(element)); + const inferred = inferYearsAnswer(getFieldContextText(element), preference, profile); if (inferred) { setFieldValue(element, inferred); } }); - return controls.every((element) => { + const unresolvedRequiredRadioGroups = getRadioGroups().filter( + (radios) => radios.some((radio) => isRequiredControl(radio)) && !radios.some((radio) => radio.checked), + ); + + return unresolvedRequiredRadioGroups.length === 0 && controls.every((element) => { if (!isRequiredControl(element)) { return true; } @@ -434,7 +488,7 @@ const fillCurrentStep = ({ }) => { fillStandardFields(profile); fillKnownTextFields({ profile, preference, jobLocation }); - return answerQuestions(); + return answerQuestions({ profile, preference }); }; const uploadResumeIfNeeded = async ({ diff --git a/apps/extension/src/manifest.ts b/apps/extension/src/manifest.ts index ce33e07..4c5c291 100644 --- a/apps/extension/src/manifest.ts +++ b/apps/extension/src/manifest.ts @@ -5,11 +5,10 @@ export default defineManifest({ name: 'ApplyPilot', version: '0.1.0', description: 'Job application copilot for LinkedIn and MyCareersFuture', - permissions: ['storage', 'tabs', 'activeTab', 'scripting', 'cookies'], + permissions: ['storage', 'tabs', 'activeTab', 'scripting'], host_permissions: [ 'https://www.linkedin.com/*', 'https://www.mycareersfuture.gov.sg/*', - 'https://agent.tinyfish.ai/*', 'http://localhost:3000/*', ], background: { diff --git a/apps/extension/src/popup/popup.tsx b/apps/extension/src/popup/popup.tsx index 64cb107..5335f3d 100644 --- a/apps/extension/src/popup/popup.tsx +++ b/apps/extension/src/popup/popup.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { extensionEnv } from '../shared/env'; import type { ExtensionMessage, PopupState } from '../shared/messages'; -import { getTinyFishApiKey, saveTinyFishApiKey } from '../tinyfish/client'; const defaultState: PopupState = { runStatus: 'idle', @@ -25,8 +24,6 @@ export function PopupApp() { const [extensionState, setExtensionState] = useState(defaultState); const [summary, setSummary] = useState(null); const [targetCount, setTargetCount] = useState(10); - const [tinyFishApiKey, setTinyFishApiKey] = useState(''); - const [tinyFishSaved, setTinyFishSaved] = useState(false); const [error, setError] = useState(''); useEffect(() => { @@ -47,14 +44,6 @@ export function PopupApp() { } catch { setError('Dashboard API is unavailable.'); } - - try { - const savedApiKey = await getTinyFishApiKey(); - setTinyFishApiKey(savedApiKey); - setTinyFishSaved(true); - } catch { - setTinyFishSaved(false); - } }; void bootstrap(); @@ -126,17 +115,6 @@ export function PopupApp() { } }; - const saveTinyFishKey = async () => { - setError(''); - try { - await saveTinyFishApiKey(tinyFishApiKey); - setTinyFishSaved(true); - } catch (error) { - setTinyFishSaved(false); - setError(error instanceof Error ? error.message : 'Could not save the TinyFish API key.'); - } - }; - return (
@@ -174,25 +152,6 @@ export function PopupApp() { /> - - -
- -
-