Skip to content
Open
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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# Copy to ~/.applypilot/.env and fill in your values.

# LLM Provider (pick one)
GEMINI_API_KEY= # Gemini 2.0 Flash (recommended, cheapest)
# OPENAI_API_KEY= # OpenAI (GPT-4o-mini)
# LLM_URL=http://127.0.0.1:8080/v1 # Local LLM (llama.cpp, Ollama)
# LLM_MODEL= # Override model name
GEMINI_API_KEY= # Gemini (recommended, cheapest)
# OPENAI_API_KEY= # OpenAI
# ANTHROPIC_API_KEY= # Anthropic Claude
# LLM_URL=http://127.0.0.1:8080/v1 # Local LLM (OpenAI-compatible: llama.cpp, Ollama, vLLM)
# LLM_MODEL= # Override model name (provider-specific)

# Auto-Apply (optional)
CAPSOLVER_API_KEY= # For CAPTCHA solving during auto-apply
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ applypilot apply --dry-run # fill forms without submitting
## Two Paths

### Full Pipeline (recommended)
**Requires:** Python 3.11+, Node.js (for npx), Gemini API key (free), Claude Code CLI, Chrome
**Requires:** Python 3.11+, Node.js (for npx), an LLM key (Gemini/OpenAI/Claude) or `LLM_URL`, Claude Code CLI, Chrome

Runs all 6 stages, from job discovery to autonomous application submission. This is the full power of ApplyPilot.

### Discovery + Tailoring Only
**Requires:** Python 3.11+, Gemini API key (free)
**Requires:** Python 3.11+, an LLM key (Gemini/OpenAI/Claude) or `LLM_URL`

Runs stages 1-5: discovers jobs, scores them, tailors your resume, generates cover letters. You submit applications manually with the AI-prepared materials.

Expand Down Expand Up @@ -88,18 +88,25 @@ Each stage is independent. Run them all or pick what you need.
|-----------|-------------|---------|
| Python 3.11+ | Everything | Core runtime |
| Node.js 18+ | Auto-apply | Needed for `npx` to run Playwright MCP server |
| Gemini API key | Scoring, tailoring, cover letters | Free tier (15 RPM / 1M tokens/day) is enough |
| LLM credentials or local endpoint | Scoring, tailoring, cover letters | Set one of `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `LLM_URL`. Optional: set `LLM_MODEL` (for example `gemini/gemini-3.0-flash`) to override the default model. |
| Chrome/Chromium | Auto-apply | Auto-detected on most systems |
| Claude Code CLI | Auto-apply | Install from [claude.ai/code](https://claude.ai/code) |

**Gemini API key is free.** Get one at [aistudio.google.com](https://aistudio.google.com). OpenAI and local models (Ollama/llama.cpp) are also supported.
**Gemini API key is free.** Get one at [aistudio.google.com](https://aistudio.google.com). OpenAI, Claude, and local models (Ollama/llama.cpp/vLLM) are also supported.
ApplyPilot uses Gemini through LiteLLM's native Gemini provider path, and Gemini API version routing is owned by LiteLLM.

### Optional

| Component | What It Does |
|-----------|-------------|
| CapSolver API key | Solves CAPTCHAs during auto-apply (hCaptcha, reCAPTCHA, Turnstile, FunCaptcha). Without it, CAPTCHA-blocked applications just fail gracefully |

### Gemini Smoke Check (optional)

```bash
GEMINI_API_KEY=your_key_here pytest -m smoke -q tests/test_gemini_smoke.py
```

> **Note:** python-jobspy is installed separately with `--no-deps` because it pins an exact numpy version in its metadata that conflicts with pip's resolver. It works fine with modern numpy at runtime.

---
Expand All @@ -115,7 +122,7 @@ Your personal data in one structured file: contact info, work authorization, com
Job search queries, target titles, locations, boards. Run multiple searches with different parameters.

### `.env`
API keys and runtime config: `GEMINI_API_KEY`, `LLM_MODEL`, `CAPSOLVER_API_KEY` (optional).
API keys and runtime config: `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_URL`, optional `LLM_MODEL`, optional `LLM_API_KEY`, and `CAPSOLVER_API_KEY`.

### Package configs (shipped with ApplyPilot)
- `config/employers.yaml` - Workday employer registry (48 preconfigured)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ classifiers = [
dependencies = [
"typer>=0.9.0",
"rich>=13.0",
"litellm~=1.63.0",
"httpx>=0.24",
"beautifulsoup4>=4.12",
"playwright>=1.40",
Expand Down
2 changes: 1 addition & 1 deletion src/applypilot/apply/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import logging
import threading
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path

Expand Down
4 changes: 2 additions & 2 deletions src/applypilot/apply/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from applypilot import config
from applypilot.database import get_connection
from applypilot.apply import chrome, dashboard, prompt as prompt_mod
from applypilot.apply import prompt as prompt_mod
from applypilot.apply.chrome import (
launch_chrome, cleanup_worker, kill_all_chrome,
reset_worker_dir, cleanup_on_exit, _kill_process_tree,
Expand Down Expand Up @@ -125,7 +125,7 @@ def acquire_job(target_url: str | None = None, min_score: int = 7,
params.extend(blocked_sites)
url_clauses = ""
if blocked_patterns:
url_clauses = " ".join(f"AND url NOT LIKE ?" for _ in blocked_patterns)
url_clauses = " ".join("AND url NOT LIKE ?" for _ in blocked_patterns)
params.extend(blocked_patterns)
row = conn.execute(f"""
SELECT url, title, site, application_url, tailored_resume_path,
Expand Down
77 changes: 54 additions & 23 deletions src/applypilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import os
from typing import Optional

import typer
Expand All @@ -11,11 +12,37 @@

from applypilot import __version__

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%H:%M:%S",
)

def _configure_logging() -> None:
"""Set consistent logging output for CLI runs."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%H:%M:%S",
)

# Keep LiteLLM internals quiet by default; warnings/errors still surface.
for name in ("LiteLLM", "litellm"):
noisy = logging.getLogger(name)
noisy.handlers.clear()
noisy.setLevel(logging.WARNING)
noisy.propagate = True

# Route verbose tailor/cover loggers to a file instead of the terminal.
# Per-attempt warnings and validation details are useful for debugging
# but too noisy for normal CLI output.
from applypilot.config import LOG_DIR
LOG_DIR.mkdir(parents=True, exist_ok=True)
_file_fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S")
for logger_name in ("applypilot.scoring.tailor", "applypilot.scoring.cover_letter"):
file_log = logging.getLogger(logger_name)
file_log.propagate = False # suppress terminal output
fh = logging.FileHandler(LOG_DIR / f"{logger_name.split('.')[-1]}.log", encoding="utf-8")
fh.setFormatter(_file_fmt)
file_log.addHandler(fh)


_configure_logging()

app = typer.Typer(
name="applypilot",
Expand Down Expand Up @@ -211,7 +238,7 @@ def apply(
raise typer.Exit(code=1)

if gen:
from applypilot.apply.launcher import gen_prompt, BASE_CDP_PORT
from applypilot.apply.launcher import gen_prompt
target = url or ""
if not target:
console.print("[red]--gen requires --url to specify which job.[/red]")
Expand All @@ -222,7 +249,7 @@ def apply(
raise typer.Exit(code=1)
mcp_path = _profile_path.parent / ".mcp-apply-0.json"
console.print(f"[green]Wrote prompt to:[/green] {prompt_file}")
console.print(f"\n[bold]Run manually:[/bold]")
console.print("\n[bold]Run manually:[/bold]")
console.print(
f" claude --model {model} -p "
f"--mcp-config {mcp_path} "
Expand Down Expand Up @@ -338,7 +365,7 @@ def doctor() -> None:
import shutil
from applypilot.config import (
load_env, PROFILE_PATH, RESUME_PATH, RESUME_PDF_PATH,
SEARCH_CONFIG_PATH, ENV_PATH, get_chrome_path,
SEARCH_CONFIG_PATH, get_chrome_path,
)

load_env()
Expand Down Expand Up @@ -379,21 +406,25 @@ def doctor() -> None:
"pip install --no-deps python-jobspy && pip install pydantic tls-client requests markdownify regex"))

# --- Tier 2 checks ---
import os
has_gemini = bool(os.environ.get("GEMINI_API_KEY"))
has_openai = bool(os.environ.get("OPENAI_API_KEY"))
has_local = bool(os.environ.get("LLM_URL"))
if has_gemini:
model = os.environ.get("LLM_MODEL", "gemini-2.0-flash")
results.append(("LLM API key", ok_mark, f"Gemini ({model})"))
elif has_openai:
model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
results.append(("LLM API key", ok_mark, f"OpenAI ({model})"))
elif has_local:
results.append(("LLM API key", ok_mark, f"Local: {os.environ.get('LLM_URL')}"))
else:
results.append(("LLM API key", fail_mark,
"Set GEMINI_API_KEY in ~/.applypilot/.env (run 'applypilot init')"))
from applypilot.llm import resolve_llm_config

try:
llm_cfg = resolve_llm_config()
if llm_cfg.api_base:
results.append(("LLM API key", ok_mark, f"Custom endpoint: {llm_cfg.api_base} ({llm_cfg.model})"))
else:
label = {
"gemini": "Gemini",
"openai": "OpenAI",
"anthropic": "Anthropic",
}.get(llm_cfg.provider, llm_cfg.provider)
results.append(("LLM API key", ok_mark, f"{label} ({llm_cfg.model})"))
except RuntimeError:
results.append(
("LLM API key", fail_mark,
"Set one of GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, LLM_URL, "
"or set LLM_MODEL with LLM_API_KEY in ~/.applypilot/.env")
)

# --- Tier 3 checks ---
# Claude Code CLI
Expand Down
24 changes: 21 additions & 3 deletions src/applypilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,14 @@ def get_tier() -> int:
"""
load_env()

has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL"))
has_provider_source = any(
os.environ.get(k)
for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_URL")
)
has_model_and_generic_key = bool((os.environ.get("LLM_MODEL") or "").strip()) and bool(
(os.environ.get("LLM_API_KEY") or "").strip()
)
has_llm = has_provider_source or has_model_and_generic_key
if not has_llm:
return 1

Expand Down Expand Up @@ -238,8 +245,19 @@ def check_tier(required: int, feature: str) -> None:
_console = Console(stderr=True)

missing: list[str] = []
if required >= 2 and not any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")):
missing.append("LLM API key — run [bold]applypilot init[/bold] or set GEMINI_API_KEY")
has_provider_source = any(
os.environ.get(k)
for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_URL")
)
has_model_and_generic_key = bool((os.environ.get("LLM_MODEL") or "").strip()) and bool(
(os.environ.get("LLM_API_KEY") or "").strip()
)
if required >= 2 and not (has_provider_source or has_model_and_generic_key):
missing.append(
"LLM config — run [bold]applypilot init[/bold] or set one of "
"GEMINI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / LLM_URL "
"(or set LLM_MODEL with LLM_API_KEY)"
)
if required >= 3:
if not shutil.which("claude"):
missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]")
Expand Down
2 changes: 1 addition & 1 deletion src/applypilot/discovery/jobspy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from jobspy import scrape_jobs

from applypilot import config
from applypilot.database import get_connection, init_db, store_jobs
from applypilot.database import get_connection, init_db

log = logging.getLogger(__name__)

Expand Down
10 changes: 4 additions & 6 deletions src/applypilot/discovery/smartextract.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,15 @@
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote_plus

import httpx
import yaml
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

from applypilot import config
from applypilot.config import CONFIG_DIR
from applypilot.database import get_connection, init_db, store_jobs, get_stats
from applypilot.database import init_db, get_stats
from applypilot.llm import get_client

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -393,7 +391,7 @@ def judge_api_responses(api_responses: list[dict]) -> list[dict]:
)

try:
raw = client.ask(prompt, temperature=0.0, max_tokens=1024)
raw = client.chat([{"role": "user", "content": prompt}], max_output_tokens=1024)
verdict = extract_json(raw)
is_relevant = verdict.get("relevant", False)
reason = verdict.get("reason", "?")
Expand Down Expand Up @@ -424,7 +422,7 @@ def format_strategy_briefing(intel: dict) -> str:
sections.append(f"\nJSON-LD: {len(job_postings)} JobPosting entries found (usable!)")
sections.append(f"First JobPosting:\n{json.dumps(job_postings[0], indent=2)[:3000]}")
else:
sections.append(f"\nJSON-LD: NO JobPosting entries (json_ld strategy will NOT work)")
sections.append("\nJSON-LD: NO JobPosting entries (json_ld strategy will NOT work)")
if other:
types = [j.get("@type", "?") if isinstance(j, dict) else "?" for j in other]
sections.append(f"Other JSON-LD types (NOT job data): {types}")
Expand Down Expand Up @@ -642,7 +640,7 @@ def ask_llm(prompt: str) -> tuple[str, float, dict]:
"""Send prompt to LLM. Returns (response_text, seconds_taken, metadata)."""
client = get_client()
t0 = time.time()
text = client.ask(prompt, temperature=0.0, max_tokens=4096)
text = client.chat([{"role": "user", "content": prompt}], max_output_tokens=4096)
elapsed = time.time() - t0
meta = {
"finish_reason": "stop",
Expand Down
6 changes: 2 additions & 4 deletions src/applypilot/enrichment/detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

from applypilot import config
from applypilot.config import DB_PATH
from applypilot.database import get_connection, init_db, ensure_columns
from applypilot.database import init_db
from applypilot.llm import get_client

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -465,7 +463,7 @@ def extract_with_llm(page, url: str) -> dict:
try:
client = get_client()
t0 = time.time()
raw = client.ask(prompt, temperature=0.0, max_tokens=4096)
raw = client.chat([{"role": "user", "content": prompt}], max_output_tokens=4096)
elapsed = time.time() - t0
log.info("LLM: %d chars in, %.1fs", len(prompt), elapsed)

Expand Down
Loading