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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,19 +37,19 @@ 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)

## What It Does

| 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. |

Expand Down Expand Up @@ -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.
Expand Down
126 changes: 92 additions & 34 deletions apps/extension/src/content/linkedin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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')),
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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 = ({
Expand All @@ -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<HTMLInputElement>(
'input[type="radio"][value*="5"], input[type="radio"][value*="10"]',
);
const preferred =
Array.from(group.querySelectorAll<HTMLInputElement>('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();
});

Expand All @@ -1409,7 +1467,7 @@ const answerKnockoutQuestions = ({
return;
}

const candidate = chooseRadioCandidate(radios);
const candidate = chooseRadioCandidate(radios, preference);
candidate?.click();
});

Expand All @@ -1422,7 +1480,7 @@ const answerKnockoutQuestions = ({
return;
}

const inferredYears = inferYearsAnswer(getFieldContextText(element));
const inferredYears = inferYearsAnswer(getFieldContextText(element), preference, profile);
if (inferredYears) {
setFieldValue(element, inferredYears);
}
Expand Down
Loading
Loading