diff --git a/.env.example b/.env.example index df7cc386..cdda63bf 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -# ApplyPilot Environment Configuration -# Copy to ~/.applypilot/.env and fill in your values. +# ApplyAssist Environment Configuration +# Copy to ~/.applyassist/.env and fill in your values. # LLM Provider (pick one) GEMINI_API_KEY= # Gemini 2.0 Flash (recommended, cheapest) @@ -7,8 +7,10 @@ GEMINI_API_KEY= # Gemini 2.0 Flash (recommended, cheapest) # LLM_URL=http://127.0.0.1:8080/v1 # Local LLM (llama.cpp, Ollama) # LLM_MODEL= # Override model name -# Auto-Apply (optional) -CAPSOLVER_API_KEY= # For CAPTCHA solving during auto-apply +# Apply (optional) +# ApplyAssist does NOT solve CAPTCHAs — by design. If a site challenges you, +# you finish it yourself in the open browser. There is no solver key to set. +# APPLYASSIST_DAILY_CAP=30 # Max applications prepared per rolling 24h (default 30) # Proxy (optional, for scraping) # PROXY=host:port:user:pass diff --git a/.gitignore b/.gitignore index 835589f1..3b451cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ __pycache__/ dist/ build/ *.egg +.venv/ +venv/ # IDE .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5682b270..29ae1304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,43 @@ # Changelog -All notable changes to ApplyPilot will be documented in this file. +All notable changes to ApplyAssist will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - ApplyAssist fork + +ApplyAssist forks [ApplyPilot](https://github.com/Pickle-Pixel/ApplyPilot) (AGPL-3.0) and +re-purposes it from an autonomous submitter into a **human-in-the-loop assistant**. The goal +shifts from volume to relevance: prepare applications, but keep the human reviewing and clicking +Submit. + +### Changed +- **Renamed** the project, package, CLI command, and data dir to `applyassist` + (`~/.applyassist/`, env `APPLYASSIST_DIR`). +- **Review mode is now the default** for `applyassist apply`: the agent fills the entire form in + a visible browser, then STOPS so you review and submit. Sequential and human-paced — no Live + dashboard or parallel workers in this mode. +- Autopilot (auto-submit) is now **opt-in** behind `--autopilot`, with a confirmation prompt and + prominent warnings. Replaces the old default-on auto-submit and the `--dry-run` apply flag. + +### Removed +- **CapSolver CAPTCHA-solving** entirely (prompt instructions, `CAPSOLVER_API_KEY`, wizard prompt, + doctor check). ApplyAssist does not defeat anti-bot challenges; it hands the open browser to the + human (`RESULT:NEEDS_HUMAN:captcha`). + +### Added +- **`RESULT:READY_FOR_REVIEW`** and **`RESULT:NEEDS_HUMAN:reason`** outcomes; `pending_review` and + `needs_human` job statuses. +- **Daily cap** (`config.DEFAULTS["daily_cap"]`, default 30, env `APPLYASSIST_DAILY_CAP`) enforced + in both review and autopilot loops to keep applying human-paced. +- **Responsible-use notice** printed before applying, plus README philosophy, attribution, and an + "Open source vs hosted" (open-core) section. + +--- + +_The history below is inherited from ApplyPilot._ + ## [0.2.0] - 2026-02-17 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5f18856..5157490b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to ApplyPilot +# Contributing to ApplyAssist -Thank you for your interest in contributing to ApplyPilot. This guide covers everything you need to get started. +Thank you for your interest in contributing to ApplyAssist. This guide covers everything you need to get started. ## Development Setup @@ -12,18 +12,18 @@ Thank you for your interest in contributing to ApplyPilot. This guide covers eve ### Clone and Install ```bash -git clone https://github.com/Pickle-Pixel/ApplyPilot.git -cd ApplyPilot +git clone https://github.com/Pickle-Pixel/ApplyAssist.git +cd ApplyAssist pip install -e ".[dev]" playwright install chromium ``` -This installs ApplyPilot in editable mode with all development dependencies (pytest, ruff, etc.) and downloads the Chromium browser binary for Playwright. +This installs ApplyAssist in editable mode with all development dependencies (pytest, ruff, etc.) and downloads the Chromium browser binary for Playwright. ### Verify Installation ```bash -applypilot --version +applyassist --version pytest tests/ -v ruff check src/ ``` @@ -45,7 +45,7 @@ Workday employer portals are configured in `config/employers.yaml`. To add a new url: "https://company.wd5.myworkdaysite.com/en-US/recruiting" ``` -4. Test discovery: `applypilot discover --employer "Company Name"` +4. Test discovery: `applyassist discover --employer "Company Name"` 5. Submit a PR with the new entry ### Adding New Career Sites @@ -66,12 +66,12 @@ Direct career site scrapers are configured in `config/sites.yaml`. To add a new description: ".job-description" ``` -3. Test: `applypilot discover --site "Company Name"` +3. Test: `applyassist discover --site "Company Name"` 4. Submit a PR ### Bug Fixes and Features -1. Check existing [issues](https://github.com/Pickle-Pixel/ApplyPilot/issues) to avoid duplicating work +1. Check existing [issues](https://github.com/Pickle-Pixel/ApplyAssist/issues) to avoid duplicating work 2. For new features, open an issue first to discuss the approach 3. Fork the repo and create a feature branch from `main` 4. Write your code with type hints and docstrings @@ -89,12 +89,12 @@ pytest tests/ -v pytest tests/test_scoring.py -v # Run with coverage -pytest tests/ --cov=src/applypilot --cov-report=term-missing +pytest tests/ --cov=src/applyassist --cov-report=term-missing ``` ## Linting and Code Style -ApplyPilot uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting. +ApplyAssist uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting. ```bash # Check for issues @@ -127,8 +127,8 @@ ruff format src/ ## Project Structure ``` -ApplyPilot/ -├── src/applypilot/ # Main package +ApplyAssist/ +├── src/applyassist/ # Main package │ ├── __init__.py │ ├── cli.py # CLI entry points │ ├── discover/ # Stage 1: job discovery scrapers @@ -146,4 +146,4 @@ ApplyPilot/ ## License -By contributing to ApplyPilot, you agree that your contributions will be licensed under the [GNU Affero General Public License v3.0](LICENSE). +By contributing to ApplyAssist, you agree that your contributions will be licensed under the [GNU Affero General Public License v3.0](LICENSE). diff --git a/README.md b/README.md index 136b4f7b..9067c73d 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,142 @@ - +# ApplyAssist -> **⚠️ ApplyPilot** is the original open-source project, created by [Pickle-Pixel](https://github.com/Pickle-Pixel) and first published on GitHub on **February 17, 2026**. We are **not affiliated** with applypilot.app, useapplypilot.com, or any other product using the "ApplyPilot" name. These sites are **not associated with this project** and may misrepresent what they offer. If you're looking for the autonomous, open-source job application agent — you're in the right place. +**An AI job-search copilot. It does the tedious work — discovering, scoring, tailoring, and filling out applications — then *you* review and click Submit.** -# ApplyPilot - -**Applied to 1,000 jobs in 2 days. Fully autonomous. Open source.** - -[](https://pypi.org/project/applypilot/) [](https://www.python.org/downloads/) [](LICENSE) -[](https://github.com/Pickle-Pixel/ApplyPilot) -[](https://ko-fi.com/S6S01UL5IO) +> ApplyAssist is a fork of [**ApplyPilot**](https://github.com/Pickle-Pixel/ApplyPilot) by [Pickle-Pixel](https://github.com/Pickle-Pixel) (AGPL-3.0). It keeps the genuinely useful machinery — multi-board discovery, AI fit-scoring, per-job resume tailoring and cover letters — and **deliberately removes the "fully autonomous, apply to 1,000 jobs" behavior**. See [Why this fork exists](#why-this-fork-exists). +--- +## Why this fork exists -https://github.com/user-attachments/assets/7ee3417f-43d4-4245-9952-35df1e77f2df +The original tool optimizes for **volume**: apply to as many jobs as possible, hands-free, solving CAPTCHAs along the way. In practice that is the wrong goal: +- **Volume isn't the bottleneck — relevance and signal are.** 30 targeted applications beat 1,000 sprayed ones. Recruiters and ATS systems detect and down-rank bulk submissions. +- **It gets accounts banned.** LinkedIn and others actively flag high-volume automated applying, especially combined with scraping and CAPTCHA-solving. Losing your account mid-search is a real setback. +- **Auto-submitting without review replicates mistakes at scale** — one wrong field, one mismatched role, multiplied by hundreds. +- **Solving CAPTCHAs is bot-detection evasion.** A CAPTCHA is a site explicitly asking for a human. ApplyAssist's answer is to give it one — you. ---- +So ApplyAssist keeps a human in the loop. It removes the friction (finding jobs, tailoring, filling forms) but leaves the **judgment and the Submit click** with you. You review every application in seconds instead of filling it out in minutes — that's how you scale responsibly. -## What It Does +--- -ApplyPilot is a 6-stage autonomous job application pipeline. It discovers jobs across 5+ boards, scores them against your resume with AI, tailors your resume per job, writes cover letters, and **submits applications for you**. It navigates forms, uploads documents, answers screening questions, all hands-free. +## What it does -Three commands. That's it. +A pipeline that prepares applications, plus an **assisted-apply** step that fills the form and stops: ```bash -pip install applypilot +pip install applyassist pip install --no-deps python-jobspy && pip install pydantic tls-client requests markdownify regex -applypilot init # one-time setup: resume, profile, preferences, API keys -applypilot doctor # verify your setup — shows what's installed and what's missing -applypilot run # discover > enrich > score > tailor > cover letters -applypilot run -w 4 # same but parallel (4 threads for discovery/enrichment) -applypilot apply # autonomous browser-driven submission -applypilot apply -w 3 # parallel apply (3 Chrome instances) -applypilot apply --dry-run # fill forms without submitting +applyassist init # one-time setup: resume, profile, preferences, API keys +applyassist doctor # verify setup — shows what's installed and what's missing +applyassist run # discover → enrich → score → tailor → cover letters → pdf +applyassist apply # REVIEW MODE (default): opens a browser, fills the form, STOPS for you to submit ``` -> **Why two install commands?** `python-jobspy` pins an exact numpy version in its metadata that conflicts with pip's resolver, but works fine at runtime with any modern numpy. The `--no-deps` flag bypasses the resolver; the second command installs jobspy's actual runtime dependencies. Everything except `python-jobspy` installs normally. +> **Why two install commands?** `python-jobspy` pins an exact numpy version in its metadata that conflicts with pip's resolver but works fine at runtime. `--no-deps` bypasses the resolver; the second command installs jobspy's actual runtime deps. --- -## Two Paths - -### Full Pipeline (recommended) -**Requires:** Python 3.11+, Node.js (for npx), Gemini API key (free), Claude Code CLI, Chrome +## How applying works -Runs all 6 stages, from job discovery to autonomous application submission. This is the full power of ApplyPilot. +`applyassist apply` defaults to **review mode**: -### Discovery + Tailoring Only -**Requires:** Python 3.11+, Gemini API key (free) +1. It picks your highest-fit prepared job and opens it in a **visible** browser. +2. The agent navigates the form, uploads your tailored resume + cover letter, answers screening questions, and fills every field. +3. It **stops before Submit**, leaves the browser open, and hands control to you. +4. You eyeball the form, fix anything, and **click Submit yourself**. Then tell ApplyAssist whether you submitted (`a`), want to skip (`s`), or quit (`q`). -Runs stages 1-5: discovers jobs, scores them, tailors your resume, generates cover letters. You submit applications manually with the AI-prepared materials. +It never solves CAPTCHAs. If a challenge appears, it pauses and you finish that step in the open browser. ---- +A **daily cap** (default 30, set `APPLYASSIST_DAILY_CAP`) keeps your pace human — recruiters down-rank bursts and platforms flag them. -## The Pipeline +### Autopilot (opt-in, not recommended) -| Stage | What Happens | -|-------|-------------| -| **1. Discover** | Scrapes 5 job boards (Indeed, LinkedIn, Glassdoor, ZipRecruiter, Google Jobs) + 48 Workday employer portals + 30 direct career sites | -| **2. Enrich** | Fetches full job descriptions via JSON-LD, CSS selectors, or AI-powered extraction | -| **3. Score** | AI rates every job 1-10 based on your resume and preferences. Only high-fit jobs proceed | -| **4. Tailor** | AI rewrites your resume per job: reorganizes, emphasizes relevant experience, adds keywords. Never fabricates | -| **5. Cover Letter** | AI generates a targeted cover letter per job | -| **6. Auto-Apply** | Claude Code navigates application forms, fills fields, uploads documents, answers questions, and submits | +```bash +applyassist apply --autopilot # also clicks Submit. Prompts for confirmation. Higher ban/ToS risk. +``` -Each stage is independent. Run them all or pick what you need. +Autopilot still never solves CAPTCHAs and still respects the daily cap. Use it sparingly, if at all. --- -## ApplyPilot vs The Alternatives +## The pipeline -| Feature | ApplyPilot | AIHawk | Manual | -|---------|-----------|--------|--------| -| Job discovery | 5 boards + Workday + direct sites | LinkedIn only | One board at a time | -| AI scoring | 1-10 fit score per job | Basic filtering | Your gut feeling | -| Resume tailoring | Per-job AI rewrite | Template-based | Hours per application | -| Auto-apply | Full form navigation + submission | LinkedIn Easy Apply only | Click, type, repeat | -| Supported sites | Indeed, LinkedIn, Glassdoor, ZipRecruiter, Google Jobs, 46 Workday portals, 28 direct sites | LinkedIn | Whatever you open | -| License | AGPL-3.0 | MIT | N/A | +| Stage | What happens | +|-------|-------------| +| **1. Discover** | Scrapes job boards (Indeed, LinkedIn, Glassdoor, ZipRecruiter, Google Jobs) + Workday employer portals + direct career sites | +| **2. Enrich** | Fetches full job descriptions via JSON-LD, CSS selectors, or AI extraction | +| **3. Score** | AI rates every job 1–10 against your resume and preferences; only high-fit jobs proceed | +| **4. Tailor** | AI reorganizes your resume per job to emphasize relevant experience. **Never fabricates** | +| **5. Cover letter** | AI drafts a targeted cover letter per job | +| **6. PDF** | Converts tailored resumes and cover letters to PDF for upload | + +Then **assisted apply** (review mode) fills the form for you to submit. Each stage is independent — run them all or pick what you need. --- ## Requirements -| Component | Required For | Details | +| Component | Required for | Details | |-----------|-------------|---------| | 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 | -| 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. - -### Optional +| Gemini API key | Scoring, tailoring, cover letters | Free tier is enough — get one at [aistudio.google.com](https://aistudio.google.com) | +| Node.js 18+ | Assisted apply | Needed for `npx` to run the Playwright MCP server | +| Chrome/Chromium | Assisted apply | Auto-detected on most systems | +| Claude Code CLI | Assisted apply | Install from [claude.ai/code](https://claude.ai/code) | -| Component | What It Does | -|-----------|-------------| -| CapSolver API key | Solves CAPTCHAs during auto-apply (hCaptcha, reCAPTCHA, Turnstile, FunCaptcha). Without it, CAPTCHA-blocked applications just fail gracefully | +OpenAI and local models (Ollama/llama.cpp) are also supported. -> **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. +There is **no CAPTCHA-solver dependency** — that was removed by design. --- -## Configuration +## Open source vs hosted -All generated by `applypilot init`: +ApplyAssist is **free and fully featured when self-hosted.** Every capability above runs locally with your own API keys; nothing is paywalled. -### `profile.json` -Your personal data in one structured file: contact info, work authorization, compensation, experience, skills, resume facts (preserved during tailoring), and EEO defaults. Powers scoring, tailoring, and form auto-fill. +A future **paid hosted** tier would sell *convenience*, not features: managed API keys, multi-device sync, and zero local setup. Because ApplyAssist is AGPL-3.0, **any hosted version's source stays public** — that's a deliberate constraint, not an afterthought. Open-core via managed hosting is compatible with AGPL; closed-source SaaS is not. -### `searches.yaml` -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). - -### Package configs (shipped with ApplyPilot) -- `config/employers.yaml` - Workday employer registry (48 preconfigured) -- `config/sites.yaml` - Direct career sites (30+), blocked sites, base URLs, manual ATS domains -- `config/searches.example.yaml` - Example search configuration +The capability tiers (`applyassist doctor` shows yours) describe what your *local install* can do based on installed dependencies — they are not a billing boundary. --- -## How Stages Work - -### Discover -Queries Indeed, LinkedIn, Glassdoor, ZipRecruiter, Google Jobs via JobSpy. Scrapes 48 Workday employer portals (configurable in `employers.yaml`). Hits 30 direct career sites with custom extractors. Deduplicates by URL. - -### Enrich -Visits each job URL and extracts the full description. 3-tier cascade: JSON-LD structured data, then CSS selector patterns, then AI-powered extraction for unknown layouts. +## CLI reference -### Score -AI scores every job 1-10 against your profile. 9-10 = strong match, 7-8 = good, 5-6 = moderate, 1-4 = skip. Only jobs above your threshold proceed to tailoring. - -### Tailor -Generates a custom resume per job: reorders experience, emphasizes relevant skills, incorporates keywords from the job description. Your `resume_facts` (companies, projects, metrics) are preserved exactly. The AI reorganizes but never fabricates. - -### Cover Letter -Writes a targeted cover letter per job referencing the specific company, role, and how your experience maps to their requirements. +``` +applyassist init # First-time setup wizard +applyassist doctor # Verify setup, diagnose missing requirements +applyassist run [stages...] # Run pipeline stages (discover enrich score tailor cover pdf, or 'all') +applyassist run --workers 4 # Parallel discovery/enrichment +applyassist run --min-score 8 # Override score threshold +applyassist apply # REVIEW MODE: fill the form, stop for you to submit (default) +applyassist apply --url URL # Prepare a specific job +applyassist apply --autopilot # Opt-in: also clicks Submit (confirmation required) +applyassist apply --mark-applied URL # Mark a job applied after you submitted it +applyassist apply --mark-failed URL # Mark a job failed +applyassist apply --reset-failed # Reset failed jobs for retry +applyassist apply --gen --url URL # Generate the agent prompt for manual debugging +applyassist status # Pipeline statistics +applyassist dashboard # Open HTML results dashboard +``` -### Auto-Apply -Claude Code launches a Chrome instance, navigates to each application page, detects the form type, fills personal information and work history, uploads the tailored resume and cover letter, answers screening questions with AI, and submits. A live dashboard shows progress in real-time. +--- -The Playwright MCP server is configured automatically at runtime per worker. No manual MCP setup needed. +## Responsible use -```bash -# Utility modes (no Chrome/Claude needed) -applypilot apply --mark-applied URL # manually mark a job as applied -applypilot apply --mark-failed URL # manually mark a job as failed -applypilot apply --reset-failed # reset all failed jobs for retry -applypilot apply --gen --url URL # generate prompt file for manual debugging -``` +- More applications is not the goal — more **interviews per application** is. +- Review and submit every application yourself. +- No CAPTCHA solving, no bot-detection evasion. +- Respect the daily cap; vary your pace. +- The hours you save on form-filling are best spent on **referrals and follow-ups** — they convert far better than cold applications. --- -## CLI Reference +## Attribution -``` -applypilot init # First-time setup wizard -applypilot doctor # Verify setup, diagnose missing requirements -applypilot run [stages...] # Run pipeline stages (or 'all') -applypilot run --workers 4 # Parallel discovery/enrichment -applypilot run --stream # Concurrent stages (streaming mode) -applypilot run --min-score 8 # Override score threshold -applypilot run --dry-run # Preview without executing -applypilot run --validation lenient # Relax validation (recommended for Gemini free tier) -applypilot run --validation strict # Strictest validation (retries on any banned word) -applypilot apply # Launch auto-apply -applypilot apply --workers 3 # Parallel browser workers -applypilot apply --dry-run # Fill forms without submitting -applypilot apply --continuous # Run forever, polling for new jobs -applypilot apply --headless # Headless browser mode -applypilot apply --url URL # Apply to a specific job -applypilot status # Pipeline statistics -applypilot dashboard # Open HTML results dashboard -``` +ApplyAssist is a fork of [ApplyPilot](https://github.com/Pickle-Pixel/ApplyPilot) by [Pickle-Pixel](https://github.com/Pickle-Pixel), licensed under AGPL-3.0. Significant credit for the discovery, scoring, and tailoring machinery belongs to the original project. ApplyAssist re-purposes it as a human-in-the-loop assistant rather than an autonomous submitter. --- @@ -190,6 +148,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, ## License -ApplyPilot is licensed under the [GNU Affero General Public License v3.0](LICENSE). +ApplyAssist is licensed under the [GNU Affero General Public License v3.0](LICENSE), inherited from ApplyPilot. -You are free to use, modify, and distribute this software. If you deploy a modified version as a service, you must release your source code under the same license. +You are free to use, modify, and distribute this software. **If you deploy a modified version as a service, you must release your source code under the same license.** diff --git a/pyproject.toml b/pyproject.toml index f5116d81..c6e92556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [project] -name = "applypilot" -version = "0.3.0" -description = "AI-powered end-to-end job application pipeline" +name = "applyassist" +version = "0.4.0" +description = "AI job-search copilot: discover, score, and tailor — then you review and submit. Human-in-the-loop, not a spray bot." readme = "README.md" license = "AGPL-3.0-only" requires-python = ">=3.11" -authors = [{name = "Pickle-Pixel"}] -keywords = ["job-search", "automation", "ai", "resume", "apply", "job-application"] +authors = [{name = "Waleed Akram Khan"}] +# ApplyAssist is a fork of ApplyPilot by Pickle-Pixel (https://github.com/Pickle-Pixel/ApplyPilot), AGPL-3.0. +keywords = ["job-search", "ai", "resume", "apply", "job-application", "human-in-the-loop", "assistant", "copilot"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", @@ -32,22 +33,22 @@ dependencies = [ dev = ["pytest>=7.0", "ruff>=0.1"] [project.scripts] -applypilot = "applypilot.cli:app" +applyassist = "applyassist.cli:app" [project.urls] -Homepage = "https://github.com/Pickle-Pixel/ApplyPilot" -Repository = "https://github.com/Pickle-Pixel/ApplyPilot" -Issues = "https://github.com/Pickle-Pixel/ApplyPilot/issues" +Homepage = "https://github.com/waleedakramkhan/ApplyAssist" +Repository = "https://github.com/waleedakramkhan/ApplyAssist" +Issues = "https://github.com/waleedakramkhan/ApplyAssist/issues" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/applypilot"] +packages = ["src/applyassist"] [tool.hatch.build] -artifacts = ["src/applypilot/config/*.yaml"] +artifacts = ["src/applyassist/config/*.yaml"] [tool.ruff] target-version = "py311" diff --git a/src/applyassist/__init__.py b/src/applyassist/__init__.py new file mode 100644 index 00000000..1e6a7a00 --- /dev/null +++ b/src/applyassist/__init__.py @@ -0,0 +1,7 @@ +"""ApplyAssist — an AI job-search copilot. Discover, score, and tailor automatically; +you review and submit. Human-in-the-loop by design, not an autonomous spray bot. + +Fork of ApplyPilot by Pickle-Pixel (https://github.com/Pickle-Pixel/ApplyPilot), AGPL-3.0. +""" + +__version__ = "0.4.0" diff --git a/src/applyassist/__main__.py b/src/applyassist/__main__.py new file mode 100644 index 00000000..4e44ff8f --- /dev/null +++ b/src/applyassist/__main__.py @@ -0,0 +1,5 @@ +"""Enable `python -m applyassist`.""" + +from applyassist.cli import app + +app() diff --git a/src/applypilot/apply/__init__.py b/src/applyassist/apply/__init__.py similarity index 100% rename from src/applypilot/apply/__init__.py rename to src/applyassist/apply/__init__.py diff --git a/src/applypilot/apply/chrome.py b/src/applyassist/apply/chrome.py similarity index 99% rename from src/applypilot/apply/chrome.py rename to src/applyassist/apply/chrome.py index 690211a8..a1319653 100644 --- a/src/applypilot/apply/chrome.py +++ b/src/applyassist/apply/chrome.py @@ -13,7 +13,7 @@ import time from pathlib import Path -from applypilot import config +from applyassist import config logger = logging.getLogger(__name__) diff --git a/src/applypilot/apply/dashboard.py b/src/applyassist/apply/dashboard.py similarity index 98% rename from src/applypilot/apply/dashboard.py rename to src/applyassist/apply/dashboard.py index c2860091..b65a8351 100644 --- a/src/applypilot/apply/dashboard.py +++ b/src/applyassist/apply/dashboard.py @@ -112,7 +112,7 @@ def render_dashboard() -> Table: Returns: A Rich Table object ready for display. """ - table = Table(title="ApplyPilot Dashboard", expand=True, show_lines=False) + table = Table(title="ApplyAssist Dashboard", expand=True, show_lines=False) table.add_column("W", style="bold", width=3, justify="center") table.add_column("Job", min_width=30, max_width=50, no_wrap=True) table.add_column("Status", width=12, justify="center") diff --git a/src/applypilot/apply/launcher.py b/src/applyassist/apply/launcher.py similarity index 74% rename from src/applypilot/apply/launcher.py rename to src/applyassist/apply/launcher.py index 341a11a3..883a73f4 100644 --- a/src/applypilot/apply/launcher.py +++ b/src/applyassist/apply/launcher.py @@ -17,21 +17,21 @@ import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from rich.console import Console from rich.live import Live -from applypilot import config -from applypilot.database import get_connection -from applypilot.apply import chrome, dashboard, prompt as prompt_mod -from applypilot.apply.chrome import ( +from applyassist import config +from applyassist.database import get_connection +from applyassist.apply import chrome, dashboard, prompt as prompt_mod +from applyassist.apply.chrome import ( launch_chrome, cleanup_worker, kill_all_chrome, reset_worker_dir, cleanup_on_exit, _kill_process_tree, BASE_CDP_PORT, ) -from applypilot.apply.dashboard import ( +from applyassist.apply.dashboard import ( init_worker, update_state, add_event, get_state, render_full, get_totals, ) @@ -40,7 +40,7 @@ # Blocked sites loaded from config/sites.yaml def _load_blocked(): - from applypilot.config import load_blocked_sites + from applyassist.config import load_blocked_sites return load_blocked_sites() # How often to poll the DB when the queue is empty (seconds) @@ -146,7 +146,7 @@ def acquire_job(target_url: str | None = None, min_score: int = 7, return None # Skip manual ATS sites (unsolvable CAPTCHAs) - from applypilot.config import is_manual_ats + from applyassist.config import is_manual_ats apply_url = row["application_url"] or row["url"] if is_manual_ats(apply_url): conn.execute( @@ -185,6 +185,16 @@ def mark_result(url: str, status: str, error: str | None = None, apply_duration_ms = ?, apply_task_id = ? WHERE url = ? """, (now, duration_ms, task_id, url)) + elif status in ("pending_review", "needs_human"): + # Review-mode outcomes: the form is filled/handed off, NOT a failure. + # Stamp last_attempted_at (used by the daily cap) without bumping the + # failure attempt counter so the job isn't treated as a failed retry. + conn.execute(""" + UPDATE jobs SET apply_status = ?, apply_error = ?, agent_id = NULL, + last_attempted_at = ?, + apply_duration_ms = ?, apply_task_id = ? + WHERE url = ? + """, (status, error, now, duration_ms, task_id, url)) else: attempts = 99 if permanent else "COALESCE(apply_attempts, 0) + 1" conn.execute(f""" @@ -206,6 +216,23 @@ def release_lock(url: str) -> None: conn.commit() +def daily_count() -> int: + """Count applications submitted or prepared-for-review in the last 24 hours. + + Powers the human-pace daily cap. Counts both 'applied' (submitted) and + 'pending_review' (filled, awaiting the human's click) so a burst of either + counts against the cap. + """ + conn = get_connection() + cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() + row = conn.execute(""" + SELECT COUNT(*) FROM jobs + WHERE (applied_at IS NOT NULL AND applied_at >= ?) + OR (apply_status = 'pending_review' AND last_attempted_at >= ?) + """, (cutoff, cutoff)).fetchone() + return row[0] if row else 0 + + # --------------------------------------------------------------------------- # Utility modes (--gen, --mark-applied, --mark-failed, --reset-failed) # --------------------------------------------------------------------------- @@ -295,13 +322,16 @@ def reset_failed() -> int: # --------------------------------------------------------------------------- def run_job(job: dict, port: int, worker_id: int = 0, - model: str = "sonnet", dry_run: bool = False) -> tuple[str, int]: + model: str = "sonnet", submit_mode: str = "review") -> tuple[str, int]: """Spawn a Claude Code session for one job application. + Args: + submit_mode: "review" (fill, don't submit) or "autopilot" (also submit). + Returns: Tuple of (status_string, duration_ms). Status is one of: - 'applied', 'expired', 'captcha', 'login_issue', - 'failed:reason', or 'skipped'. + 'ready_for_review', 'needs_human:reason', 'applied', 'expired', + 'login_issue', 'failed:reason', or 'skipped'. """ # Read tailored resume text resume_path = job.get("tailored_resume_path") @@ -314,7 +344,7 @@ def run_job(job: dict, port: int, worker_id: int = 0, agent_prompt = prompt_mod.build_prompt( job=job, tailored_resume=resume_text, - dry_run=dry_run, + submit_mode=submit_mode, ) # Write per-worker MCP config @@ -465,7 +495,23 @@ def run_job(job: dict, port: int, worker_id: int = 0, def _clean_reason(s: str) -> str: return re.sub(r'[*`"]+$', '', s).strip() - for result_status in ["APPLIED", "EXPIRED", "CAPTCHA", "LOGIN_ISSUE"]: + # Review-mode handoffs (check before the generic statuses). + if "RESULT:READY_FOR_REVIEW" in output: + add_event(f"[W{worker_id}] READY ({elapsed}s): {job['title'][:30]}") + update_state(worker_id, status="ready", last_action=f"ready for review ({elapsed}s)") + return "ready_for_review", duration_ms + + if "RESULT:NEEDS_HUMAN" in output: + reason = "manual" + for out_line in output.split("\n"): + if "RESULT:NEEDS_HUMAN" in out_line and "NEEDS_HUMAN:" in out_line: + reason = _clean_reason(out_line.split("NEEDS_HUMAN:")[-1].strip()) or "manual" + break + add_event(f"[W{worker_id}] NEEDS HUMAN ({elapsed}s): {reason[:25]}") + update_state(worker_id, status="needs_human", last_action=f"needs human: {reason[:20]}") + return f"needs_human:{reason}", duration_ms + + for result_status in ["APPLIED", "EXPIRED", "LOGIN_ISSUE"]: if f"RESULT:{result_status}" in output: add_event(f"[W{worker_id}] {result_status} ({elapsed}s): {job['title'][:30]}") update_state(worker_id, status=result_status.lower(), @@ -548,9 +594,12 @@ def _is_permanent_failure(result: str) -> bool: def worker_loop(worker_id: int = 0, limit: int = 1, target_url: str | None = None, min_score: int = 7, headless: bool = False, - model: str = "sonnet", dry_run: bool = False) -> tuple[int, int]: + model: str = "sonnet", submit_mode: str = "autopilot") -> tuple[int, int]: """Run jobs sequentially until limit is reached or queue is empty. + This is the AUTOPILOT path (parallel workers, auto-submit). Review mode uses + review_loop() instead so the browser stays open for the human. + Args: worker_id: Numeric worker identifier. limit: Max jobs to process (0 = continuous). @@ -558,7 +607,7 @@ def worker_loop(worker_id: int = 0, limit: int = 1, min_score: Minimum fit_score threshold. headless: Run Chrome headless. model: Claude model name. - dry_run: Don't click Submit. + submit_mode: "autopilot" or "review". Returns: Tuple of (applied_count, failed_count). @@ -574,6 +623,12 @@ def worker_loop(worker_id: int = 0, limit: int = 1, if not continuous and jobs_done >= limit: break + cap = config.DEFAULTS["daily_cap"] + if daily_count() >= cap: + add_event(f"[W{worker_id}] Daily cap ({cap}) reached") + update_state(worker_id, status="done", last_action=f"daily cap {cap} reached") + break + update_state(worker_id, status="idle", job_title="", company="", last_action="waiting for job", actions=0) @@ -602,7 +657,7 @@ def worker_loop(worker_id: int = 0, limit: int = 1, chrome_proc = launch_chrome(worker_id, port=port, headless=headless) result, duration_ms = run_job(job, port=port, worker_id=worker_id, - model=model, dry_run=dry_run) + model=model, submit_mode=submit_mode) if result == "skipped": release_lock(job["url"]) @@ -613,6 +668,17 @@ def worker_loop(worker_id: int = 0, limit: int = 1, applied += 1 update_state(worker_id, jobs_applied=applied, jobs_done=applied + failed) + elif result == "ready_for_review": + mark_result(job["url"], "pending_review", duration_ms=duration_ms) + applied += 1 + update_state(worker_id, jobs_applied=applied, + jobs_done=applied + failed) + elif result.startswith("needs_human"): + reason = result.split(":", 1)[-1] if ":" in result else "manual" + mark_result(job["url"], "needs_human", reason, duration_ms=duration_ms) + failed += 1 + update_state(worker_id, jobs_failed=failed, + jobs_done=applied + failed) else: reason = result.split(":", 1)[-1] if ":" in result else result mark_result(job["url"], "failed", reason, @@ -646,13 +712,139 @@ def worker_loop(worker_id: int = 0, limit: int = 1, return applied, failed +# --------------------------------------------------------------------------- +# Review loop (human-in-the-loop, the default) +# --------------------------------------------------------------------------- + +def review_loop(limit: int = 1, target_url: str | None = None, + min_score: int = 7, headless: bool = False, + model: str = "sonnet") -> None: + """Fill applications one at a time and hand each to the human to submit. + + For each job: open a visible browser, let the agent fill the entire form + (never submitting), then PAUSE with the browser open so the human reviews, + fixes anything, and clicks Submit themselves. The human then says whether + they submitted; only then do we close that browser and move to the next job. + + This is deliberately sequential and human-paced — the whole point is that a + person stays in the loop, so there is no Live dashboard or parallelism here. + """ + _console = Console() + init_worker(0) + port = BASE_CDP_PORT + continuous = limit == 0 + prepared = 0 + cap = config.DEFAULTS["daily_cap"] + interactive = sys.stdin.isatty() + + while not _stop_event.is_set(): + if not continuous and prepared >= limit: + break + + if daily_count() >= cap: + _console.print( + f"\n[yellow]Daily cap of {cap} reached.[/yellow] That's a healthy day's worth — " + "more volume won't help. Spend the rest of today on referrals and follow-ups. " + "Raise it with APPLYASSIST_DAILY_CAP if you really need to." + ) + break + + job = acquire_job(target_url=target_url, min_score=min_score, worker_id=0) + if not job: + _console.print( + "\n[dim]No more prepared jobs to apply to.[/dim] " + "Run [bold]applyassist run[/bold] to discover, score, and tailor more." + ) + break + + chrome_proc = None + try: + _console.print( + f"\n[bold blue]Preparing[/bold blue] {job['title']} @ {job.get('site', '')} " + f"(fit {job.get('fit_score', '?')}/10)" + ) + _console.print("[dim]Opening a browser and filling the form — do not touch it yet...[/dim]") + chrome_proc = launch_chrome(0, port=port, headless=headless) + result, duration_ms = run_job(job, port=port, worker_id=0, + model=model, submit_mode="review") + + if result == "ready_for_review": + mark_result(job["url"], "pending_review", duration_ms=duration_ms) + _console.print( + "[green]✓ Filled and ready.[/green] Review every field in the open browser, " + "fix anything that's off, then [bold]click Submit yourself[/bold]." + ) + elif result.startswith("needs_human"): + reason = result.split(":", 1)[-1] if ":" in result else "manual" + mark_result(job["url"], "needs_human", reason, duration_ms=duration_ms) + _console.print( + f"[yellow]Needs you:[/yellow] {reason}. Take over in the open browser " + "(e.g. solve the CAPTCHA / finish a step the agent shouldn't), then submit." + ) + elif result == "skipped": + release_lock(job["url"]) + _console.print("[dim]Skipped.[/dim]") + continue + else: + reason = result.split(":", 1)[-1] if ":" in result else result + mark_result(job["url"], "failed", reason, + permanent=_is_permanent_failure(result), duration_ms=duration_ms) + _console.print(f"[red]Couldn't prepare this one:[/red] {reason}") + if target_url: + break + continue + + # Human-in-the-loop pause. The browser stays open during this prompt. + if interactive: + _console.print( + "\nAfter you've reviewed and submitted in the browser:\n" + " [bold]a[/bold] = I submitted it " + "[bold]s[/bold] = skip this one " + "[bold]q[/bold] = quit" + ) + choice = input("> ").strip().lower() + else: + # Non-interactive (e.g. piped): leave it marked pending_review and stop. + _console.print( + "[dim]Non-interactive shell — left as pending_review. " + "Submit in the browser, then run " + f"`applyassist apply --mark-applied '{job['url']}'`.[/dim]" + ) + break + + if choice == "a": + mark_job(job["url"], "applied") + _console.print("[green]Marked as applied.[/green]") + elif choice == "q": + break + + except KeyboardInterrupt: + release_lock(job["url"]) + break + except Exception as e: + logger.exception("Review loop error") + _console.print(f"[red]Error:[/red] {str(e)[:120]}") + release_lock(job["url"]) + finally: + if chrome_proc: + cleanup_worker(0, chrome_proc) + + prepared += 1 + if target_url: + break + + _stop_event.set() + kill_all_chrome() + _console.print(f"\n[bold]Done.[/bold] Prepared {prepared} application(s) this run. Logs: {config.LOG_DIR}") + + # --------------------------------------------------------------------------- # Main entry point (called from cli.py) # --------------------------------------------------------------------------- def main(limit: int = 1, target_url: str | None = None, min_score: int = 7, headless: bool = False, model: str = "sonnet", - dry_run: bool = False, continuous: bool = False, + submit_mode: str = "review", continuous: bool = False, poll_interval: int = 60, workers: int = 1) -> None: """Launch the apply pipeline. @@ -662,7 +854,7 @@ def main(limit: int = 1, target_url: str | None = None, min_score: Minimum fit_score threshold. headless: Run Chrome in headless mode. model: Claude model name. - dry_run: Don't click Submit. + submit_mode: "review" (default, human-in-the-loop) or "autopilot". continuous: Run forever, polling for new jobs. poll_interval: Seconds between DB polls when queue is empty. workers: Number of parallel workers (default 1). @@ -674,6 +866,13 @@ def main(limit: int = 1, target_url: str | None = None, config.ensure_dirs() console = Console() + # Review mode is human-paced and one-at-a-time: no Live dashboard, no parallel + # workers. The browser stays open after each fill so the human can submit. + if submit_mode != "autopilot": + review_loop(limit=limit, target_url=target_url, min_score=min_score, + headless=headless, model=model) + return + if continuous: effective_limit = 0 mode_label = "continuous" @@ -736,7 +935,7 @@ def _refresh(): min_score=min_score, headless=headless, model=model, - dry_run=dry_run, + submit_mode="autopilot", ) else: # Multi-worker — distribute limit across workers @@ -759,7 +958,7 @@ def _refresh(): min_score=min_score, headless=headless, model=model, - dry_run=dry_run, + submit_mode="autopilot", ): i for i in range(workers) } diff --git a/src/applypilot/apply/prompt.py b/src/applyassist/apply/prompt.py similarity index 61% rename from src/applypilot/apply/prompt.py rename to src/applyassist/apply/prompt.py index 37c3790a..ab2d9aa6 100644 --- a/src/applypilot/apply/prompt.py +++ b/src/applyassist/apply/prompt.py @@ -1,8 +1,10 @@ -"""Prompt builder for the autonomous job application agent. +"""Prompt builder for the assisted job application agent. Constructs the full instruction prompt that tells Claude Code / the AI agent -how to fill out a job application form using Playwright MCP tools. All -personal data is loaded from the user's profile -- nothing is hardcoded. +how to fill out a job application form using Playwright MCP tools. In the +default review mode the agent fills the form but never submits -- it hands the +open browser to the human. All personal data is loaded from the user's profile +-- nothing is hardcoded. """ import logging @@ -11,7 +13,7 @@ from datetime import datetime from pathlib import Path -from applypilot import config +from applyassist import config logger = logging.getLogger(__name__) @@ -215,211 +217,28 @@ def _build_hard_rules(profile: dict) -> str: def _build_captcha_section() -> str: - """Build the CAPTCHA detection and solving instructions. + """Build the CAPTCHA handling instructions. - Reads the CapSolver API key from environment. The CAPTCHA section - contains no personal data -- it's the same for every user. + ApplyAssist does NOT auto-solve CAPTCHAs. Defeating a CAPTCHA is exactly the + kind of bot-detection evasion that gets accounts banned and violates site + terms. When a CAPTCHA appears, the agent stops and hands the open browser to + the human, who solves it and resumes -- the same review-and-act seam the + whole tool is built around. Contains no personal data. """ - config.load_env() - capsolver_key = os.environ.get("CAPSOLVER_API_KEY", "") - - return f"""== CAPTCHA == -You solve CAPTCHAs via the CapSolver REST API. No browser extension. You control the entire flow. -API key: {capsolver_key or 'NOT CONFIGURED — skip to MANUAL FALLBACK for all CAPTCHAs'} -API base: https://api.capsolver.com - -CRITICAL RULE: When ANY CAPTCHA appears (hCaptcha, reCAPTCHA, Turnstile -- regardless of what it looks like visually), you MUST: -1. Run CAPTCHA DETECT to get the type and sitekey -2. Run CAPTCHA SOLVE (createTask -> poll -> inject) with the CapSolver API -3. ONLY go to MANUAL FALLBACK if CapSolver returns errorId > 0 -Do NOT skip the API call based on what the CAPTCHA looks like. CapSolver solves CAPTCHAs server-side -- it does NOT need to see or interact with images, puzzles, or games. Even "drag the pipe" or "click all traffic lights" hCaptchas are solved via API token, not visually. ALWAYS try the API first. - ---- CAPTCHA DETECT --- -Run this browser_evaluate after every navigation, Apply/Submit/Login click, or when a page feels stuck. -IMPORTANT: Detection order matters. hCaptcha elements also have data-sitekey, so check hCaptcha BEFORE reCAPTCHA. - -browser_evaluate function: () => {{{{ - const r = {{}}; - const url = window.location.href; - // 1. hCaptcha (check FIRST -- hCaptcha uses data-sitekey too) - const hc = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]'); - if (hc) {{{{ - r.type = 'hcaptcha'; r.sitekey = hc.dataset.sitekey || hc.dataset.hcaptchaSitekey; - }}}} - if (!r.type && document.querySelector('script[src*="hcaptcha.com"], iframe[src*="hcaptcha.com"]')) {{{{ - const el = document.querySelector('[data-sitekey]'); - if (el) {{{{ r.type = 'hcaptcha'; r.sitekey = el.dataset.sitekey; }}}} - }}}} - // 2. Cloudflare Turnstile - if (!r.type) {{{{ - const cf = document.querySelector('.cf-turnstile, [data-turnstile-sitekey]'); - if (cf) {{{{ - r.type = 'turnstile'; r.sitekey = cf.dataset.sitekey || cf.dataset.turnstileSitekey; - if (cf.dataset.action) r.action = cf.dataset.action; - if (cf.dataset.cdata) r.cdata = cf.dataset.cdata; - }}}} - }}}} - if (!r.type && document.querySelector('script[src*="challenges.cloudflare.com"]')) {{{{ - r.type = 'turnstile_script_only'; r.note = 'Wait 3s and re-detect.'; - }}}} - // 3. reCAPTCHA v3 (invisible, loaded via render= param) - if (!r.type) {{{{ - const s = document.querySelector('script[src*="recaptcha"][src*="render="]'); - if (s) {{{{ - const m = s.src.match(/render=([^&]+)/); - if (m && m[1] !== 'explicit') {{{{ r.type = 'recaptchav3'; r.sitekey = m[1]; }}}} - }}}} - }}}} - // 4. reCAPTCHA v2 (checkbox or invisible) - if (!r.type) {{{{ - const rc = document.querySelector('.g-recaptcha'); - if (rc) {{{{ r.type = 'recaptchav2'; r.sitekey = rc.dataset.sitekey; }}}} - }}}} - if (!r.type && document.querySelector('script[src*="recaptcha"]')) {{{{ - const el = document.querySelector('[data-sitekey]'); - if (el) {{{{ r.type = 'recaptchav2'; r.sitekey = el.dataset.sitekey; }}}} - }}}} - // 5. FunCaptcha (Arkose Labs) - if (!r.type) {{{{ - const fc = document.querySelector('#FunCaptcha, [data-pkey], .funcaptcha'); - if (fc) {{{{ r.type = 'funcaptcha'; r.sitekey = fc.dataset.pkey; }}}} - }}}} - if (!r.type && document.querySelector('script[src*="arkoselabs"], script[src*="funcaptcha"]')) {{{{ - const el = document.querySelector('[data-pkey]'); - if (el) {{{{ r.type = 'funcaptcha'; r.sitekey = el.dataset.pkey; }}}} - }}}} - if (r.type) {{{{ r.url = url; return r; }}}} - return null; -}}}} - -Result actions: -- null -> no CAPTCHA. Continue normally. -- "turnstile_script_only" -> browser_wait_for time: 3, re-run detect. -- Any other type -> proceed to CAPTCHA SOLVE below. - ---- CAPTCHA SOLVE --- -Three steps: createTask -> poll -> inject. Do each as a separate browser_evaluate call. - -STEP 1 -- CREATE TASK (copy this exactly, fill in the 3 placeholders): -browser_evaluate function: async () => {{{{ - const r = await fetch('https://api.capsolver.com/createTask', {{{{ - method: 'POST', - headers: {{{{'Content-Type': 'application/json'}}}}, - body: JSON.stringify({{{{ - clientKey: '{capsolver_key}', - task: {{{{ - type: 'TASK_TYPE', - websiteURL: 'PAGE_URL', - websiteKey: 'SITE_KEY' - }}}} - }}}}) - }}}}); - return await r.json(); -}}}} - -TASK_TYPE values (use EXACTLY these strings): - hcaptcha -> HCaptchaTaskProxyLess - recaptchav2 -> ReCaptchaV2TaskProxyLess - recaptchav3 -> ReCaptchaV3TaskProxyLess - turnstile -> AntiTurnstileTaskProxyLess - funcaptcha -> FunCaptchaTaskProxyLess - -PAGE_URL = the url from detect result. SITE_KEY = the sitekey from detect result. -For recaptchav3: add "pageAction": "submit" to the task object (or the actual action found in page scripts). -For turnstile: add "metadata": {{"action": "...", "cdata": "..."}} if those were in detect result. - -Response: {{"errorId": 0, "taskId": "abc123"}} on success. -If errorId > 0 -> CAPTCHA SOLVE failed. Go to MANUAL FALLBACK. - -STEP 2 -- POLL (replace TASK_ID with the taskId from step 1): -Loop: browser_wait_for time: 3, then run: -browser_evaluate function: async () => {{{{ - const r = await fetch('https://api.capsolver.com/getTaskResult', {{{{ - method: 'POST', - headers: {{{{'Content-Type': 'application/json'}}}}, - body: JSON.stringify({{{{ - clientKey: '{capsolver_key}', - taskId: 'TASK_ID' - }}}}) - }}}}); - return await r.json(); -}}}} - -- status "processing" -> wait 3s, poll again. Max 10 polls (30s). -- status "ready" -> extract token: - reCAPTCHA: solution.gRecaptchaResponse - hCaptcha: solution.gRecaptchaResponse - Turnstile: solution.token -- errorId > 0 or 30s timeout -> MANUAL FALLBACK. - -STEP 3 -- INJECT TOKEN (replace THE_TOKEN with actual token string): - -For reCAPTCHA v2/v3: -browser_evaluate function: () => {{{{ - const token = 'THE_TOKEN'; - document.querySelectorAll('[name="g-recaptcha-response"]').forEach(el => {{{{ el.value = token; el.style.display = 'block'; }}}}); - if (window.___grecaptcha_cfg) {{{{ - const clients = window.___grecaptcha_cfg.clients; - for (const key in clients) {{{{ - const walk = (obj, d) => {{{{ - if (d > 4 || !obj) return; - for (const k in obj) {{{{ - if (typeof obj[k] === 'function' && k.length < 3) try {{{{ obj[k](token); }}}} catch(e) {{{{}}}} - else if (typeof obj[k] === 'object') walk(obj[k], d+1); - }}}} - }}}}; - walk(clients[key], 0); - }}}} - }}}} - return 'injected'; -}}}} - -For hCaptcha: -browser_evaluate function: () => {{{{ - const token = 'THE_TOKEN'; - const ta = document.querySelector('[name="h-captcha-response"], textarea[name*="hcaptcha"]'); - if (ta) ta.value = token; - document.querySelectorAll('iframe[data-hcaptcha-response]').forEach(f => f.setAttribute('data-hcaptcha-response', token)); - const cb = document.querySelector('[data-hcaptcha-widget-id]'); - if (cb && window.hcaptcha) try {{{{ window.hcaptcha.getResponse(cb.dataset.hcaptchaWidgetId); }}}} catch(e) {{{{}}}} - return 'injected'; -}}}} - -For Turnstile: -browser_evaluate function: () => {{{{ - const token = 'THE_TOKEN'; - const inp = document.querySelector('[name="cf-turnstile-response"], input[name*="turnstile"]'); - if (inp) inp.value = token; - if (window.turnstile) try {{{{ const w = document.querySelector('.cf-turnstile'); if (w) window.turnstile.getResponse(w); }}}} catch(e) {{{{}}}} - return 'injected'; -}}}} - -For FunCaptcha: -browser_evaluate function: () => {{{{ - const token = 'THE_TOKEN'; - const inp = document.querySelector('#FunCaptcha-Token, input[name="fc-token"]'); - if (inp) inp.value = token; - if (window.ArkoseEnforcement) try {{{{ window.ArkoseEnforcement.setConfig({{{{data: {{{{blob: token}}}}}}}}) }}}} catch(e) {{{{}}}} - return 'injected'; -}}}} - -After injecting: browser_wait_for time: 2, then snapshot. -- Widget gone or green check -> success. Click Submit if needed. -- No change -> click Submit/Verify/Continue button (some sites need it). -- Still stuck -> token may have expired (~2 min lifetime). Re-run from STEP 1. - ---- MANUAL FALLBACK --- -You should ONLY be here if CapSolver createTask returned errorId > 0. If you haven't tried CapSolver yet, GO BACK and try it first. -If CapSolver genuinely failed (errorId > 0): -1. Audio challenge: Look for "audio" or "accessibility" button -> click it for an easier challenge. -2. Text/logic puzzles: Solve them yourself. Think step by step. Common tricks: "All but 9 die" = 9 left. "3 sisters and 4 brothers, how many siblings?" = 7. -3. Simple text captchas ("What is 3+7?", "Type the word") -> solve them. -4. All else fails -> Output RESULT:CAPTCHA.""" + return """== CAPTCHA == +ApplyAssist never solves CAPTCHAs for you. If a CAPTCHA, "verify you are human", +hCaptcha, reCAPTCHA, Turnstile, or any anti-bot challenge appears at ANY point: +1. STOP. Do not attempt to bypass, inject tokens, call solver APIs, or click + through it programmatically. +2. Leave the browser tab open and focused on the challenge. +3. Output RESULT:NEEDS_HUMAN:captcha +The human will solve the challenge in the open browser and continue from there. +This is intentional: a CAPTCHA is the site asking for a human, so we give it one.""" def build_prompt(job: dict, tailored_resume: str, cover_letter: str | None = None, - dry_run: bool = False) -> str: + submit_mode: str = "review") -> str: """Build the full instruction prompt for the apply agent. Loads the user profile and search config internally. All personal data @@ -430,11 +249,15 @@ def build_prompt(job: dict, tailored_resume: str, application_url, fit_score, tailored_resume_path). tailored_resume: Plain-text content of the tailored resume. cover_letter: Optional plain-text cover letter content. - dry_run: If True, tell the agent not to click Submit. + submit_mode: "review" (default) -- fill everything but STOP before the + final Submit and hand the open browser to the human. "autopilot" -- + the opt-in mode that also clicks Submit. Anything else is treated as + "review" (fail safe). Returns: Complete prompt string for the AI agent. """ + autopilot = submit_mode == "autopilot" profile = config.load_profile() search_config = config.load_search_config() personal = profile["personal"] @@ -499,7 +322,7 @@ def build_prompt(job: dict, tailored_resume: str, phone_digits = "".join(c for c in personal.get("phone", "") if c.isdigit()) # SSO domains the agent cannot sign into (loaded from config/sites.yaml) - from applypilot.config import load_blocked_sso + from applyassist.config import load_blocked_sso blocked_sso = load_blocked_sso() # Preferred display name @@ -507,13 +330,30 @@ def build_prompt(job: dict, tailored_resume: str, last_name = full_name.split()[-1] if " " in full_name else "" display_name = f"{preferred_name} {last_name}".strip() - # Dry-run: override submit instruction - if dry_run: - submit_instruction = "IMPORTANT: Do NOT click the final Submit/Apply button. Review the form, verify all fields, then output RESULT:APPLIED with a note that this was a dry run." + # Submit behavior depends on mode. Review (default) is human-in-the-loop: + # fill everything, then STOP and hand off. Autopilot is the opt-in path. + if autopilot: + mission_verb = "Submit a complete, accurate application." + submit_instruction = ( + "BEFORE clicking Submit/Apply, take a snapshot and review EVERY field on the page. " + "Verify all data matches the APPLICANT PROFILE and TAILORED RESUME -- name, email, phone, " + "location, work auth, resume uploaded, cover letter if applicable. If anything is wrong or " + "missing, fix it FIRST. Only click Submit after confirming everything is correct." + ) else: - submit_instruction = "BEFORE clicking Submit/Apply, take a snapshot and review EVERY field on the page. Verify all data matches the APPLICANT PROFILE and TAILORED RESUME -- name, email, phone, location, work auth, resume uploaded, cover letter if applicable. If anything is wrong or missing, fix it FIRST. Only click Submit after confirming everything is correct." + mission_verb = ( + "Fill out a complete, accurate application and prepare it for the human to submit. " + "You do NOT submit -- a person reviews and clicks Submit themselves." + ) + submit_instruction = ( + "DO NOT click the final Submit/Apply button. This is review mode -- a human submits, not you. " + "Instead: make sure every field is filled and correct, the resume (and cover letter if asked) " + "is uploaded, and all screening questions are answered. Take a final browser_snapshot. Leave " + "the browser tab open and focused on the completed, unsubmitted form. Then output " + "RESULT:READY_FOR_REVIEW with a one-line note of anything the human should double-check." + ) - prompt = f"""You are an autonomous job application agent. Your ONE mission: get this candidate an interview. You have all the information and tools. Think strategically. Act decisively. Submit the application. + prompt = f"""You are a job application assistant working alongside a human. Your mission: do the tedious work of filling out this application accurately so the candidate has the best shot at an interview. You have all the information and tools. Think strategically. Act carefully. {mission_verb} == JOB == URL: {job.get('application_url') or job['url']} @@ -535,9 +375,9 @@ def build_prompt(job: dict, tailored_resume: str, {profile_summary} == YOUR MISSION == -Submit a complete, accurate application. Use the profile and resume as source data -- adapt to fit each form's format. +{mission_verb} Use the profile and resume as source data -- adapt to fit each form's format. -If something unexpected happens and these instructions don't cover it, figure it out yourself. You are autonomous. Navigate pages, read content, try buttons, explore the site. The goal is always the same: submit the application. Do whatever it takes to reach that goal. +If something unexpected happens and these instructions don't cover it, use judgment. Navigate pages, read content, try buttons, explore the site. But NEVER cut corners that a careful human wouldn't: do not lie, do not bypass anti-bot checks, and (in review mode) do not submit. If you get genuinely stuck or hit something only a person should decide, STOP and output a RESULT code so the human can step in. {hard_rules} @@ -559,17 +399,17 @@ def build_prompt(job: dict, tailored_resume: str, == STEP-BY-STEP == 1. browser_navigate to the job URL. -2. browser_snapshot to read the page. Then run CAPTCHA DETECT (see CAPTCHA section). If a CAPTCHA is found, solve it before continuing. +2. browser_snapshot to read the page. If a CAPTCHA / "verify you are human" challenge appears, follow the CAPTCHA section: STOP and output RESULT:NEEDS_HUMAN:captcha. 3. LOCATION CHECK. Read the page for location info. If not eligible, output RESULT and stop. 4. Find and click the Apply button. If email-only (page says "email resume to X"): - - send_email with subject "Application for {job['title']} -- {display_name}", body = 2-3 sentence pitch + contact info, attach resume PDF: ["{pdf_path}"] - - Output RESULT:APPLIED. Done. - After clicking Apply: browser_snapshot. Run CAPTCHA DETECT -- many sites trigger CAPTCHAs right after the Apply click. If found, solve before continuing. + - {"send_email with subject" if autopilot else "Prepare (DO NOT send) an email with subject"} "Application for {job['title']} -- {display_name}", body = 2-3 sentence pitch + contact info, attach resume PDF: ["{pdf_path}"] + - Output {"RESULT:APPLIED" if autopilot else "RESULT:NEEDS_HUMAN:email_apply (tell the human the recipient address and the drafted body so they can review and send)"}. Done. + After clicking Apply: browser_snapshot. If a CAPTCHA appears -> RESULT:NEEDS_HUMAN:captcha. 5. Login wall? 5a. FIRST: check the URL. If you landed on {', '.join(blocked_sso)}, or any SSO/OAuth page -> STOP. Output RESULT:FAILED:sso_required. Do NOT try to sign in to Google/Microsoft/SSO. 5b. Check for popups. Run browser_tabs action "list". If a new tab/window appeared (login popup), switch to it with browser_tabs action "select". Check the URL there too -- if it's SSO -> RESULT:FAILED:sso_required. 5c. Regular login form (employer's own site)? Try sign in: {personal['email']} / {personal.get('password', '')} - 5d. After clicking Login/Sign-in: run CAPTCHA DETECT. Login pages frequently have invisible CAPTCHAs that silently block form submissions. If found, solve it then retry login. + 5d. After clicking Login/Sign-in: if a CAPTCHA / "verify you are human" challenge appears -> STOP and output RESULT:NEEDS_HUMAN:captcha. Do not try to bypass it. 5e. Sign in failed? Try sign up with same email and password. 5f. Need email verification? Use search_emails + read_email to get the code. 5g. After login, run browser_tabs action "list" again. Switch back to the application tab if needed. @@ -581,13 +421,16 @@ def build_prompt(job: dict, tailored_resume: str, - Compare every other field to the APPLICANT PROFILE. Fix mismatches. Fill empty fields. 9. Answer screening questions using the rules above. 10. {submit_instruction} -11. After submit: browser_snapshot. Run CAPTCHA DETECT -- submit buttons often trigger invisible CAPTCHAs. If found, solve it (the form will auto-submit once the token clears, or you may need to click Submit again). Then check for new tabs (browser_tabs action: "list"). Switch to newest, close old. Snapshot to confirm submission. Look for "thank you" or "application received". +11. {"After submit: browser_snapshot. If a CAPTCHA appears -> RESULT:NEEDS_HUMAN:captcha. Otherwise check for new tabs (browser_tabs action: \"list\"), switch to newest, snapshot to confirm submission. Look for \"thank you\" or \"application received\", then output RESULT:APPLIED." if autopilot else "Do not submit. Take a final browser_snapshot of the completed form, leave the tab open, and output RESULT:READY_FOR_REVIEW."} 12. Output your result. == RESULT CODES (output EXACTLY one) == -RESULT:APPLIED -- submitted successfully +RESULT:READY_FOR_REVIEW -- form filled and ready; human reviews and clicks Submit (review mode) +RESULT:APPLIED -- submitted successfully (autopilot mode only) +RESULT:NEEDS_HUMAN:captcha -- a CAPTCHA / human-verification challenge appeared; human takes over in the open browser +RESULT:NEEDS_HUMAN:email_apply -- email-only application drafted; human reviews and sends +RESULT:NEEDS_HUMAN:reason -- anything else only a person should decide RESULT:EXPIRED -- job closed or no longer accepting applications -RESULT:CAPTCHA -- blocked by unsolvable captcha RESULT:LOGIN_ISSUE -- could not sign in or create account RESULT:FAILED:not_eligible_location -- onsite outside acceptable area, no remote option RESULT:FAILED:not_eligible_work_auth -- requires unauthorized work location @@ -599,7 +442,7 @@ def build_prompt(job: dict, tailored_resume: str, - Multi-page forms (Workday, Taleo, iCIMS): snapshot each new page, fill all fields, click Next/Continue. Repeat until final review page. - Fill ALL fields in ONE browser_fill_form call. Not one at a time. - Keep your thinking SHORT. Don't repeat page structure back. -- CAPTCHA AWARENESS: After any navigation, Apply/Submit/Login click, or when a page feels stuck -- run CAPTCHA DETECT (see CAPTCHA section). Invisible CAPTCHAs (Turnstile, reCAPTCHA v3) show NO visual widget but block form submissions silently. The detect script finds them even when invisible. +- CAPTCHA AWARENESS: If a CAPTCHA or "verify you are human" challenge appears after any navigation or click, do NOT try to solve or bypass it. STOP and output RESULT:NEEDS_HUMAN:captcha (see CAPTCHA section). A human finishes it in the open browser. == FORM TRICKS == - Popup/new window opened? browser_tabs action "list" to see all tabs. browser_tabs action "select" with the tab index to switch. ALWAYS check for new tabs after clicking login/apply/sign-in buttons. diff --git a/src/applypilot/cli.py b/src/applyassist/cli.py similarity index 72% rename from src/applypilot/cli.py rename to src/applyassist/cli.py index 6c8be912..56163211 100644 --- a/src/applypilot/cli.py +++ b/src/applyassist/cli.py @@ -1,4 +1,4 @@ -"""ApplyPilot CLI — the main entry point.""" +"""ApplyAssist CLI — the main entry point.""" from __future__ import annotations @@ -9,7 +9,7 @@ from rich.console import Console from rich.table import Table -from applypilot import __version__ +from applyassist import __version__ logging.basicConfig( level=logging.INFO, @@ -18,8 +18,8 @@ ) app = typer.Typer( - name="applypilot", - help="AI-powered end-to-end job application pipeline.", + name="applyassist", + help="AI job-search copilot: discover, score, and tailor — then you review and submit.", no_args_is_help=True, ) console = Console() @@ -35,8 +35,8 @@ def _bootstrap() -> None: """Common setup: load env, create dirs, init DB.""" - from applypilot.config import load_env, ensure_dirs - from applypilot.database import init_db + from applyassist.config import load_env, ensure_dirs + from applyassist.database import init_db load_env() ensure_dirs() @@ -45,10 +45,28 @@ def _bootstrap() -> None: def _version_callback(value: bool) -> None: if value: - console.print(f"[bold]applypilot[/bold] {__version__}") + console.print(f"[bold]applyassist[/bold] {__version__}") raise typer.Exit() +def _print_responsible_use_notice() -> None: + """Print the once-per-run responsible-use notice before applying. + + Surfaces the philosophy in front of the user, not just in the README: + volume is not the bottleneck, review everything, respect rate limits. + """ + from rich.panel import Panel + console.print(Panel( + "[bold]ApplyAssist is a copilot, not a spray bot.[/bold]\n" + "- More applications is not the goal — more [italic]interviews per application[/italic] is.\n" + "- You review and submit every application. The agent does the tedious filling.\n" + "- No CAPTCHA solving, no bot-detection evasion. If a site asks for a human, you step in.\n" + "- A daily cap keeps your pace human. Referrals beat cold applications — spend the time you save there.", + title="Responsible use", + border_style="cyan", + )) + + # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- @@ -62,13 +80,13 @@ def main( is_eager=True, ), ) -> None: - """ApplyPilot — AI-powered end-to-end job application pipeline.""" + """ApplyAssist — AI job-search copilot. It discovers, scores, and tailors; you review and submit.""" @app.command() def init() -> None: """Run the first-time setup wizard (profile, resume, search config).""" - from applypilot.wizard.init import run_wizard + from applyassist.wizard.init import run_wizard run_wizard() @@ -101,7 +119,7 @@ def run( """Run pipeline stages: discover, enrich, score, tailor, cover, pdf.""" _bootstrap() - from applypilot.pipeline import run_pipeline + from applyassist.pipeline import run_pipeline stage_list = stages if stages else ["all"] @@ -117,7 +135,7 @@ def run( # Gate AI stages behind Tier 2 llm_stages = {"score", "tailor", "cover"} if any(s in stage_list for s in llm_stages) or "all" in stage_list: - from applypilot.config import check_tier + from applyassist.config import check_tier check_tier(2, "AI scoring/tailoring") # Validate the --validation flag value @@ -144,42 +162,53 @@ def run( @app.command() def apply( - limit: Optional[int] = typer.Option(None, "--limit", "-l", help="Max applications to submit."), + limit: Optional[int] = typer.Option(None, "--limit", "-l", help="Max applications to prepare this run (default 1)."), workers: int = typer.Option(1, "--workers", "-w", help="Number of parallel browser workers."), min_score: int = typer.Option(7, "--min-score", help="Minimum fit score for job selection."), model: str = typer.Option("haiku", "--model", "-m", help="Claude model name."), continuous: bool = typer.Option(False, "--continuous", "-c", help="Run forever, polling for new jobs."), - dry_run: bool = typer.Option(False, "--dry-run", help="Preview actions without submitting."), - headless: bool = typer.Option(False, "--headless", help="Run browsers in headless mode."), + autopilot: bool = typer.Option( + False, "--autopilot", + help="OPT-IN: also click Submit (autonomous). Default is review mode, where the agent fills " + "the form and STOPS so you review and submit. Autopilot raises ban/ToS risk -- use sparingly.", + ), + headless: bool = typer.Option(False, "--headless", help="Run browsers headless. Ignored in review mode (you need to see the form to submit it)."), url: Optional[str] = typer.Option(None, "--url", help="Apply to a specific job URL."), gen: bool = typer.Option(False, "--gen", help="Generate prompt file for manual debugging instead of running."), - mark_applied: Optional[str] = typer.Option(None, "--mark-applied", help="Manually mark a job URL as applied."), + mark_applied: Optional[str] = typer.Option(None, "--mark-applied", help="Manually mark a job URL as applied (after you click Submit)."), mark_failed: Optional[str] = typer.Option(None, "--mark-failed", help="Manually mark a job URL as failed (provide URL)."), fail_reason: Optional[str] = typer.Option(None, "--fail-reason", help="Reason for --mark-failed."), reset_failed: bool = typer.Option(False, "--reset-failed", help="Reset all failed jobs for retry."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the autopilot confirmation prompt."), ) -> None: - """Launch auto-apply to submit job applications.""" + """Prepare applications for you to review and submit. + + Default (review mode): the agent finds a high-fit job, opens it in a visible browser, + fills every field, uploads your tailored resume + cover letter, answers screening + questions, then STOPS. You eyeball the form and click Submit. ApplyAssist never solves + CAPTCHAs and (without --autopilot) never submits for you. + """ _bootstrap() - from applypilot.config import check_tier, PROFILE_PATH as _profile_path - from applypilot.database import get_connection + from applyassist.config import check_tier, PROFILE_PATH as _profile_path + from applyassist.database import get_connection # --- Utility modes (no Chrome/Claude needed) --- if mark_applied: - from applypilot.apply.launcher import mark_job + from applyassist.apply.launcher import mark_job mark_job(mark_applied, "applied") console.print(f"[green]Marked as applied:[/green] {mark_applied}") return if mark_failed: - from applypilot.apply.launcher import mark_job + from applyassist.apply.launcher import mark_job mark_job(mark_failed, "failed", reason=fail_reason) console.print(f"[yellow]Marked as failed:[/yellow] {mark_failed} ({fail_reason or 'manual'})") return if reset_failed: - from applypilot.apply.launcher import reset_failed as do_reset + from applyassist.apply.launcher import reset_failed as do_reset count = do_reset() console.print(f"[green]Reset {count} failed job(s) for retry.[/green]") return @@ -187,13 +216,13 @@ def apply( # --- Full apply mode --- # Check 1: Tier 3 required (Claude Code CLI + Chrome) - check_tier(3, "auto-apply") + check_tier(3, "assisted-apply") # Check 2: Profile exists if not _profile_path.exists(): console.print( "[red]Profile not found.[/red]\n" - "Run [bold]applypilot init[/bold] to create your profile first." + "Run [bold]applyassist init[/bold] to create your profile first." ) raise typer.Exit(code=1) @@ -206,12 +235,12 @@ def apply( if ready == 0: console.print( "[red]No tailored resumes ready.[/red]\n" - "Run [bold]applypilot run score tailor[/bold] first to prepare applications." + "Run [bold]applyassist run score tailor[/bold] first to prepare applications." ) raise typer.Exit(code=1) if gen: - from applypilot.apply.launcher import gen_prompt, BASE_CDP_PORT + from applyassist.apply.launcher import gen_prompt, BASE_CDP_PORT target = url or "" if not target: console.print("[red]--gen requires --url to specify which job.[/red]") @@ -230,16 +259,34 @@ def apply( ) return - from applypilot.apply.launcher import main as apply_main + from applyassist.apply.launcher import main as apply_main + + submit_mode = "autopilot" if autopilot else "review" + + # Review mode is human-in-the-loop: the browser MUST be visible so you can submit. + if submit_mode == "review" and headless: + console.print("[yellow]Ignoring --headless in review mode (you need to see the form to submit it).[/yellow]") + headless = False + + _print_responsible_use_notice() + + if autopilot: + console.print( + "\n[bold red]Autopilot mode: the agent will click Submit itself.[/bold red]\n" + "This applies without your per-application review and carries higher ban/ToS risk.\n" + "Review mode (the default, no flag) is strongly recommended.\n" + ) + if not yes and not typer.confirm("Proceed with autopilot?"): + console.print("Aborted. Re-run without --autopilot for review mode.") + raise typer.Exit(code=0) effective_limit = limit if limit is not None else (0 if continuous else 1) - console.print("\n[bold blue]Launching Auto-Apply[/bold blue]") + console.print(f"\n[bold blue]Launching ApplyAssist[/bold blue] ([bold]{submit_mode}[/bold] mode)") console.print(f" Limit: {'unlimited' if continuous else effective_limit}") console.print(f" Workers: {workers}") console.print(f" Model: {model}") console.print(f" Headless: {headless}") - console.print(f" Dry run: {dry_run}") if url: console.print(f" Target: {url}") console.print() @@ -250,7 +297,7 @@ def apply( min_score=min_score, headless=headless, model=model, - dry_run=dry_run, + submit_mode=submit_mode, continuous=continuous, workers=workers, ) @@ -261,11 +308,11 @@ def status() -> None: """Show pipeline statistics from the database.""" _bootstrap() - from applypilot.database import get_stats + from applyassist.database import get_stats stats = get_stats() - console.print("\n[bold]ApplyPilot Pipeline Status[/bold]\n") + console.print("\n[bold]ApplyAssist Pipeline Status[/bold]\n") # Summary table summary = Table(title="Pipeline Overview", show_header=True, header_style="bold cyan") @@ -327,7 +374,7 @@ def dashboard() -> None: """Generate and open the HTML dashboard in your browser.""" _bootstrap() - from applypilot.view import open_dashboard + from applyassist.view import open_dashboard open_dashboard() @@ -336,7 +383,7 @@ def dashboard() -> None: def doctor() -> None: """Check your setup and diagnose missing requirements.""" import shutil - from applypilot.config import ( + from applyassist.config import ( load_env, PROFILE_PATH, RESUME_PATH, RESUME_PDF_PATH, SEARCH_CONFIG_PATH, ENV_PATH, get_chrome_path, ) @@ -354,7 +401,7 @@ def doctor() -> None: if PROFILE_PATH.exists(): results.append(("profile.json", ok_mark, str(PROFILE_PATH))) else: - results.append(("profile.json", fail_mark, "Run 'applypilot init' to create")) + results.append(("profile.json", fail_mark, "Run 'applyassist init' to create")) # Resume if RESUME_PATH.exists(): @@ -362,13 +409,13 @@ def doctor() -> None: elif RESUME_PDF_PATH.exists(): results.append(("resume.txt", warn_mark, "Only PDF found — plain-text needed for AI stages")) else: - results.append(("resume.txt", fail_mark, "Run 'applypilot init' to add your resume")) + results.append(("resume.txt", fail_mark, "Run 'applyassist init' to add your resume")) # Search config if SEARCH_CONFIG_PATH.exists(): results.append(("searches.yaml", ok_mark, str(SEARCH_CONFIG_PATH))) else: - results.append(("searches.yaml", warn_mark, "Will use example config — run 'applypilot init'")) + results.append(("searches.yaml", warn_mark, "Will use example config — run 'applyassist init'")) # jobspy (discovery dep installed separately) try: @@ -393,7 +440,7 @@ def doctor() -> None: 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')")) + "Set GEMINI_API_KEY in ~/.applyassist/.env (run 'applyassist init')")) # --- Tier 3 checks --- # Claude Code CLI @@ -402,7 +449,7 @@ def doctor() -> None: results.append(("Claude Code CLI", ok_mark, claude_bin)) else: results.append(("Claude Code CLI", fail_mark, - "Install from https://claude.ai/code (needed for auto-apply)")) + "Install from https://claude.ai/code (needed for assisted apply)")) # Chrome try: @@ -410,7 +457,7 @@ def doctor() -> None: results.append(("Chrome/Chromium", ok_mark, chrome_path)) except FileNotFoundError: results.append(("Chrome/Chromium", fail_mark, - "Install Chrome or set CHROME_PATH env var (needed for auto-apply)")) + "Install Chrome or set CHROME_PATH env var (needed for assisted apply)")) # Node.js / npx (for Playwright MCP) npx_bin = shutil.which("npx") @@ -418,19 +465,11 @@ def doctor() -> None: results.append(("Node.js (npx)", ok_mark, npx_bin)) else: results.append(("Node.js (npx)", fail_mark, - "Install Node.js 18+ from nodejs.org (needed for auto-apply)")) - - # CapSolver (optional) - capsolver = os.environ.get("CAPSOLVER_API_KEY") - if capsolver: - results.append(("CapSolver API key", ok_mark, "CAPTCHA solving enabled")) - else: - results.append(("CapSolver API key", "[dim]optional[/dim]", - "Set CAPSOLVER_API_KEY in .env for CAPTCHA solving")) + "Install Node.js 18+ from nodejs.org (needed for assisted apply)")) # --- Render results --- console.print() - console.print("[bold]ApplyPilot Doctor[/bold]\n") + console.print("[bold]ApplyAssist Doctor[/bold]\n") col_w = max(len(r[0]) for r in results) + 2 for check, status, note in results: @@ -440,15 +479,15 @@ def doctor() -> None: console.print() # Tier summary - from applypilot.config import get_tier, TIER_LABELS + from applyassist.config import get_tier, TIER_LABELS tier = get_tier() console.print(f"[bold]Current tier: Tier {tier} — {TIER_LABELS[tier]}[/bold]") if tier == 1: console.print("[dim] → Tier 2 unlocks: scoring, tailoring, cover letters (needs LLM API key)[/dim]") - console.print("[dim] → Tier 3 unlocks: auto-apply (needs Claude Code CLI + Chrome + Node.js)[/dim]") + console.print("[dim] → Tier 3 unlocks: assisted apply (needs Claude Code CLI + Chrome + Node.js)[/dim]") elif tier == 2: - console.print("[dim] → Tier 3 unlocks: auto-apply (needs Claude Code CLI + Chrome + Node.js)[/dim]") + console.print("[dim] → Tier 3 unlocks: assisted apply (needs Claude Code CLI + Chrome + Node.js)[/dim]") console.print() diff --git a/src/applypilot/config.py b/src/applyassist/config.py similarity index 84% rename from src/applypilot/config.py rename to src/applyassist/config.py index 8c397807..03a89179 100644 --- a/src/applypilot/config.py +++ b/src/applyassist/config.py @@ -1,4 +1,4 @@ -"""ApplyPilot configuration: paths, platform detection, user data.""" +"""ApplyAssist configuration: paths, platform detection, user data.""" import os import platform @@ -6,10 +6,10 @@ from pathlib import Path # User data directory — all user-specific files live here -APP_DIR = Path(os.environ.get("APPLYPILOT_DIR", Path.home() / ".applypilot")) +APP_DIR = Path(os.environ.get("APPLYASSIST_DIR", Path.home() / ".applyassist")) # Core paths -DB_PATH = APP_DIR / "applypilot.db" +DB_PATH = APP_DIR / "applyassist.db" PROFILE_PATH = APP_DIR / "profile.json" RESUME_PATH = APP_DIR / "resume.txt" RESUME_PDF_PATH = APP_DIR / "resume.pdf" @@ -92,17 +92,17 @@ def ensure_dirs(): def load_profile() -> dict: - """Load user profile from ~/.applypilot/profile.json.""" + """Load user profile from ~/.applyassist/profile.json.""" import json if not PROFILE_PATH.exists(): raise FileNotFoundError( - f"Profile not found at {PROFILE_PATH}. Run `applypilot init` first." + f"Profile not found at {PROFILE_PATH}. Run `applyassist init` first." ) return json.loads(PROFILE_PATH.read_text(encoding="utf-8")) def load_search_config() -> dict: - """Load search configuration from ~/.applypilot/searches.yaml.""" + """Load search configuration from ~/.applyassist/searches.yaml.""" import yaml if not SEARCH_CONFIG_PATH.exists(): # Fall back to package-shipped example @@ -168,11 +168,15 @@ def load_base_urls() -> dict[str, str | None]: "poll_interval": 60, "apply_timeout": 300, "viewport": "1280x900", + # Human-pace guard: stop after this many applications prepared/submitted in a + # rolling 24h window. Quality over volume — recruiters down-rank spray-applies + # and high-volume bursts get accounts flagged. Override with APPLYASSIST_DAILY_CAP. + "daily_cap": int(os.environ.get("APPLYASSIST_DAILY_CAP", "30")), } def load_env(): - """Load environment variables from ~/.applypilot/.env if it exists.""" + """Load environment variables from ~/.applyassist/.env if it exists.""" from dotenv import load_dotenv if ENV_PATH.exists(): load_dotenv(ENV_PATH) @@ -184,10 +188,14 @@ def load_env(): # Tier system — feature gating by installed dependencies # --------------------------------------------------------------------------- +# Tiers describe capability unlocked by locally-installed dependencies. ALL tiers +# are free in the open-source, self-hosted tool — this is not a paywall. The paid +# offering (see README "Open source vs hosted") is managed hosting, not these +# features. Auto-apply remains opt-in (--autopilot) regardless of tier. TIER_LABELS = { 1: "Discovery", 2: "AI Scoring & Tailoring", - 3: "Full Auto-Apply", + 3: "Assisted Apply (review mode)", } TIER_COMMANDS: dict[int, list[str]] = { @@ -200,9 +208,9 @@ def load_env(): def get_tier() -> int: """Detect the current tier based on available dependencies. - Tier 1 (Discovery): Python + pip - Tier 2 (AI Scoring & Tailoring): + LLM API key - Tier 3 (Full Auto-Apply): + Claude Code CLI + Chrome + Tier 1 (Discovery): Python + pip + Tier 2 (AI Scoring & Tailoring): + LLM API key + Tier 3 (Assisted Apply): + Claude Code CLI + Chrome """ load_env() @@ -239,7 +247,7 @@ def check_tier(required: int, feature: str) -> None: 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") + missing.append("LLM API key — run [bold]applyassist init[/bold] or set GEMINI_API_KEY") if required >= 3: if not shutil.which("claude"): missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]") diff --git a/src/applypilot/config/employers.yaml b/src/applyassist/config/employers.yaml similarity index 99% rename from src/applypilot/config/employers.yaml rename to src/applyassist/config/employers.yaml index 528732e7..2b5929fb 100644 --- a/src/applypilot/config/employers.yaml +++ b/src/applyassist/config/employers.yaml @@ -1,4 +1,4 @@ -# Workday employer registry for ApplyPilot +# Workday employer registry for ApplyAssist # Users can add their own employers by following this pattern. # Find your employer's Workday URL at: https://[company].wd[N].myworkdayjobs.com diff --git a/src/applypilot/config/searches.example.yaml b/src/applyassist/config/searches.example.yaml similarity index 92% rename from src/applypilot/config/searches.example.yaml rename to src/applyassist/config/searches.example.yaml index 2396fd98..ec6e044f 100644 --- a/src/applypilot/config/searches.example.yaml +++ b/src/applyassist/config/searches.example.yaml @@ -1,6 +1,6 @@ -# ApplyPilot search configuration -# Copy to ~/.applypilot/searches.yaml and customize for your job search. -# The wizard (applypilot init) generates a basic version automatically. +# ApplyAssist search configuration +# Copy to ~/.applyassist/searches.yaml and customize for your job search. +# The wizard (applyassist init) generates a basic version automatically. # Search queries -- each has a query string and priority tier. # Tier 1: High-priority exact matches (searched first, highest precision) diff --git a/src/applypilot/config/sites.yaml b/src/applyassist/config/sites.yaml similarity index 99% rename from src/applypilot/config/sites.yaml rename to src/applyassist/config/sites.yaml index 5107aca8..1fef8669 100644 --- a/src/applypilot/config/sites.yaml +++ b/src/applyassist/config/sites.yaml @@ -1,4 +1,4 @@ -# Direct career site registry for ApplyPilot +# Direct career site registry for ApplyAssist # Users can add their own sites following this pattern. # Use {location_encoded} placeholder for location (URL-encoded). # Use {query_encoded} placeholder for search terms (URL-encoded). diff --git a/src/applypilot/database.py b/src/applyassist/database.py similarity index 99% rename from src/applypilot/database.py rename to src/applyassist/database.py index a1779c02..ea070b1a 100644 --- a/src/applypilot/database.py +++ b/src/applyassist/database.py @@ -1,4 +1,4 @@ -"""ApplyPilot database layer: schema, migrations, stats, and connection helpers. +"""ApplyAssist database layer: schema, migrations, stats, and connection helpers. Single source of truth for the jobs table schema. All columns from every pipeline stage are created up front so any stage can run independently @@ -10,7 +10,7 @@ from datetime import datetime, timezone from pathlib import Path -from applypilot.config import DB_PATH +from applyassist.config import DB_PATH # Thread-local connection storage — each thread gets its own connection # (required for SQLite thread safety with parallel workers) diff --git a/src/applypilot/discovery/__init__.py b/src/applyassist/discovery/__init__.py similarity index 100% rename from src/applypilot/discovery/__init__.py rename to src/applyassist/discovery/__init__.py diff --git a/src/applypilot/discovery/jobspy.py b/src/applyassist/discovery/jobspy.py similarity index 98% rename from src/applypilot/discovery/jobspy.py rename to src/applyassist/discovery/jobspy.py index b5e54ff4..15ac9d6e 100644 --- a/src/applypilot/discovery/jobspy.py +++ b/src/applyassist/discovery/jobspy.py @@ -1,7 +1,7 @@ """JobSpy-based job discovery: searches Indeed, LinkedIn, Glassdoor, ZipRecruiter. Uses python-jobspy to scrape multiple job boards, deduplicates results, -parses salary ranges, and stores everything in the ApplyPilot database. +parses salary ranges, and stores everything in the ApplyAssist database. Search queries, locations, and filtering rules are loaded from the user's search configuration YAML (searches.yaml) rather than being hardcoded. @@ -14,8 +14,8 @@ from jobspy import scrape_jobs -from applypilot import config -from applypilot.database import get_connection, init_db, store_jobs +from applyassist import config +from applyassist.database import get_connection, init_db, store_jobs log = logging.getLogger(__name__) @@ -457,7 +457,7 @@ def run_discovery(cfg: dict | None = None) -> dict: cfg = config.load_search_config() if not cfg: - log.warning("No search configuration found. Run `applypilot init` to create one.") + log.warning("No search configuration found. Run `applyassist init` to create one.") return {"new": 0, "existing": 0, "errors": 0, "db_total": 0, "queries": 0} proxy = cfg.get("proxy") diff --git a/src/applypilot/discovery/smartextract.py b/src/applyassist/discovery/smartextract.py similarity index 99% rename from src/applypilot/discovery/smartextract.py rename to src/applyassist/discovery/smartextract.py index cf49a9a2..86c6ffd6 100644 --- a/src/applypilot/discovery/smartextract.py +++ b/src/applyassist/discovery/smartextract.py @@ -28,10 +28,10 @@ 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.llm import get_client +from applyassist import config +from applyassist.config import CONFIG_DIR +from applyassist.database import get_connection, init_db, store_jobs, get_stats +from applyassist.llm import get_client log = logging.getLogger(__name__) diff --git a/src/applypilot/discovery/workday.py b/src/applyassist/discovery/workday.py similarity index 99% rename from src/applypilot/discovery/workday.py rename to src/applyassist/discovery/workday.py index cef69fe4..9e55e8d9 100644 --- a/src/applypilot/discovery/workday.py +++ b/src/applyassist/discovery/workday.py @@ -19,9 +19,9 @@ import yaml -from applypilot import config -from applypilot.config import CONFIG_DIR -from applypilot.database import get_connection, init_db +from applyassist import config +from applyassist.config import CONFIG_DIR +from applyassist.database import get_connection, init_db log = logging.getLogger(__name__) diff --git a/src/applypilot/enrichment/__init__.py b/src/applyassist/enrichment/__init__.py similarity index 100% rename from src/applypilot/enrichment/__init__.py rename to src/applyassist/enrichment/__init__.py diff --git a/src/applypilot/enrichment/detail.py b/src/applyassist/enrichment/detail.py similarity index 98% rename from src/applypilot/enrichment/detail.py rename to src/applyassist/enrichment/detail.py index 11b79260..7bd8602d 100644 --- a/src/applypilot/enrichment/detail.py +++ b/src/applyassist/enrichment/detail.py @@ -22,10 +22,10 @@ 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.llm import get_client +from applyassist import config +from applyassist.config import DB_PATH +from applyassist.database import get_connection, init_db, ensure_columns +from applyassist.llm import get_client log = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def set_proxy(proxy_str: str | None): """Set proxy config from an external caller.""" global _PROXY_CONFIG if proxy_str: - from applypilot.discovery.jobspy import parse_proxy + from applyassist.discovery.jobspy import parse_proxy _PROXY_CONFIG = parse_proxy(proxy_str) @@ -50,7 +50,7 @@ def set_proxy(proxy_str: str | None): def _load_base_urls() -> dict[str, str | None]: """Load site base URLs from config/sites.yaml.""" - from applypilot.config import load_base_urls + from applyassist.config import load_base_urls return load_base_urls() @@ -469,7 +469,7 @@ def extract_with_llm(page, url: str) -> dict: elapsed = time.time() - t0 log.info("LLM: %d chars in, %.1fs", len(prompt), elapsed) - from applypilot.discovery.smartextract import extract_json + from applyassist.discovery.smartextract import extract_json result = extract_json(raw) desc = result.get("full_description") apply_url = result.get("application_url") diff --git a/src/applypilot/llm.py b/src/applyassist/llm.py similarity index 99% rename from src/applypilot/llm.py rename to src/applyassist/llm.py index 1fb7be64..29ec3f77 100644 --- a/src/applypilot/llm.py +++ b/src/applyassist/llm.py @@ -1,5 +1,5 @@ """ -Unified LLM client for ApplyPilot. +Unified LLM client for ApplyAssist. Auto-detects provider from environment: GEMINI_API_KEY -> Google Gemini (default: gemini-2.0-flash) diff --git a/src/applypilot/pipeline.py b/src/applyassist/pipeline.py similarity index 94% rename from src/applypilot/pipeline.py rename to src/applyassist/pipeline.py index 29881c5f..ca0745fa 100644 --- a/src/applypilot/pipeline.py +++ b/src/applyassist/pipeline.py @@ -1,13 +1,13 @@ -"""ApplyPilot Pipeline Orchestrator. +"""ApplyAssist Pipeline Orchestrator. Runs pipeline stages in sequence or concurrently (streaming mode). Usage (via CLI): - applypilot run # all stages, sequential - applypilot run --stream # all stages, concurrent - applypilot run discover enrich # specific stages - applypilot run score tailor cover # LLM-only stages - applypilot run --dry-run # preview without executing + applyassist run # all stages, sequential + applyassist run --stream # all stages, concurrent + applyassist run discover enrich # specific stages + applyassist run score tailor cover # LLM-only stages + applyassist run --dry-run # preview without executing """ from __future__ import annotations @@ -21,8 +21,8 @@ from rich.panel import Panel from rich.table import Table -from applypilot.config import load_env, ensure_dirs -from applypilot.database import init_db, get_connection, get_stats +from applyassist.config import load_env, ensure_dirs +from applyassist.database import init_db, get_connection, get_stats log = logging.getLogger(__name__) console = Console() @@ -66,7 +66,7 @@ def _run_discover(workers: int = 1) -> dict: # JobSpy console.print(" [cyan]JobSpy full crawl...[/cyan]") try: - from applypilot.discovery.jobspy import run_discovery + from applyassist.discovery.jobspy import run_discovery run_discovery() stats["jobspy"] = "ok" except Exception as e: @@ -77,7 +77,7 @@ def _run_discover(workers: int = 1) -> dict: # Workday corporate scraper console.print(" [cyan]Workday corporate scraper...[/cyan]") try: - from applypilot.discovery.workday import run_workday_discovery + from applyassist.discovery.workday import run_workday_discovery run_workday_discovery(workers=workers) stats["workday"] = "ok" except Exception as e: @@ -88,7 +88,7 @@ def _run_discover(workers: int = 1) -> dict: # Smart extract console.print(" [cyan]Smart extract (AI-powered scraping)...[/cyan]") try: - from applypilot.discovery.smartextract import run_smart_extract + from applyassist.discovery.smartextract import run_smart_extract run_smart_extract(workers=workers) stats["smartextract"] = "ok" except Exception as e: @@ -102,7 +102,7 @@ def _run_discover(workers: int = 1) -> dict: def _run_enrich(workers: int = 1) -> dict: """Stage: Detail enrichment — scrape full descriptions and apply URLs.""" try: - from applypilot.enrichment.detail import run_enrichment + from applyassist.enrichment.detail import run_enrichment run_enrichment(workers=workers) return {"status": "ok"} except Exception as e: @@ -113,7 +113,7 @@ def _run_enrich(workers: int = 1) -> dict: def _run_score() -> dict: """Stage: LLM scoring — assign fit scores 1-10.""" try: - from applypilot.scoring.scorer import run_scoring + from applyassist.scoring.scorer import run_scoring run_scoring() return {"status": "ok"} except Exception as e: @@ -124,7 +124,7 @@ def _run_score() -> dict: def _run_tailor(min_score: int = 7, validation_mode: str = "normal") -> dict: """Stage: Resume tailoring — generate tailored resumes for high-fit jobs.""" try: - from applypilot.scoring.tailor import run_tailoring + from applyassist.scoring.tailor import run_tailoring run_tailoring(min_score=min_score, validation_mode=validation_mode) return {"status": "ok"} except Exception as e: @@ -135,7 +135,7 @@ def _run_tailor(min_score: int = 7, validation_mode: str = "normal") -> dict: def _run_cover(min_score: int = 7, validation_mode: str = "normal") -> dict: """Stage: Cover letter generation.""" try: - from applypilot.scoring.cover_letter import run_cover_letters + from applyassist.scoring.cover_letter import run_cover_letters run_cover_letters(min_score=min_score, validation_mode=validation_mode) return {"status": "ok"} except Exception as e: @@ -146,7 +146,7 @@ def _run_cover(min_score: int = 7, validation_mode: str = "normal") -> dict: def _run_pdf() -> dict: """Stage: PDF conversion — convert tailored resumes and cover letters to PDF.""" try: - from applypilot.scoring.pdf import batch_convert + from applyassist.scoring.pdf import batch_convert batch_convert() return {"status": "ok"} except Exception as e: @@ -475,7 +475,7 @@ def run_pipeline( mode = "streaming" if stream else "sequential" console.print() console.print(Panel.fit( - f"[bold]ApplyPilot Pipeline[/bold] ({mode})", + f"[bold]ApplyAssist Pipeline[/bold] ({mode})", border_style="blue", )) console.print(f" Min score: {min_score}") diff --git a/src/applypilot/scoring/__init__.py b/src/applyassist/scoring/__init__.py similarity index 100% rename from src/applypilot/scoring/__init__.py rename to src/applyassist/scoring/__init__.py diff --git a/src/applypilot/scoring/cover_letter.py b/src/applyassist/scoring/cover_letter.py similarity index 97% rename from src/applypilot/scoring/cover_letter.py rename to src/applyassist/scoring/cover_letter.py index c16cdd5f..0a2ae07d 100644 --- a/src/applypilot/scoring/cover_letter.py +++ b/src/applyassist/scoring/cover_letter.py @@ -11,10 +11,10 @@ import time from datetime import datetime, timezone -from applypilot.config import COVER_LETTER_DIR, RESUME_PATH, load_profile -from applypilot.database import get_connection, get_jobs_by_stage -from applypilot.llm import get_client -from applypilot.scoring.validator import ( +from applyassist.config import COVER_LETTER_DIR, RESUME_PATH, load_profile +from applyassist.database import get_connection, get_jobs_by_stage +from applyassist.llm import get_client +from applyassist.scoring.validator import ( BANNED_WORDS, LLM_LEAK_PHRASES, sanitize_text, @@ -248,7 +248,7 @@ def run_cover_letters(min_score: int = 7, limit: int = 20, # Generate PDF (best-effort) pdf_path = None try: - from applypilot.scoring.pdf import convert_to_pdf + from applyassist.scoring.pdf import convert_to_pdf pdf_path = str(convert_to_pdf(cl_path)) except Exception: log.debug("PDF generation failed for %s", cl_path, exc_info=True) diff --git a/src/applypilot/scoring/pdf.py b/src/applyassist/scoring/pdf.py similarity index 99% rename from src/applypilot/scoring/pdf.py rename to src/applyassist/scoring/pdf.py index 2b87b673..57ca8ae7 100644 --- a/src/applypilot/scoring/pdf.py +++ b/src/applyassist/scoring/pdf.py @@ -7,7 +7,7 @@ import logging from pathlib import Path -from applypilot.config import TAILORED_DIR +from applyassist.config import TAILORED_DIR log = logging.getLogger(__name__) diff --git a/src/applypilot/scoring/scorer.py b/src/applyassist/scoring/scorer.py similarity index 97% rename from src/applypilot/scoring/scorer.py rename to src/applyassist/scoring/scorer.py index 97692d5f..23e52e27 100644 --- a/src/applypilot/scoring/scorer.py +++ b/src/applyassist/scoring/scorer.py @@ -11,9 +11,9 @@ import time from datetime import datetime, timezone -from applypilot.config import RESUME_PATH, load_profile -from applypilot.database import get_connection, get_jobs_by_stage -from applypilot.llm import get_client +from applyassist.config import RESUME_PATH, load_profile +from applyassist.database import get_connection, get_jobs_by_stage +from applyassist.llm import get_client log = logging.getLogger(__name__) diff --git a/src/applypilot/scoring/tailor.py b/src/applyassist/scoring/tailor.py similarity index 98% rename from src/applypilot/scoring/tailor.py rename to src/applyassist/scoring/tailor.py index 352fb5ff..2554ea33 100644 --- a/src/applypilot/scoring/tailor.py +++ b/src/applyassist/scoring/tailor.py @@ -16,10 +16,10 @@ from datetime import datetime, timezone from pathlib import Path -from applypilot.config import RESUME_PATH, TAILORED_DIR, load_profile -from applypilot.database import get_connection, get_jobs_by_stage -from applypilot.llm import get_client -from applypilot.scoring.validator import ( +from applyassist.config import RESUME_PATH, TAILORED_DIR, load_profile +from applyassist.database import get_connection, get_jobs_by_stage +from applyassist.llm import get_client +from applyassist.scoring.validator import ( BANNED_WORDS, FABRICATION_WATCHLIST, sanitize_text, @@ -520,7 +520,7 @@ def run_tailoring(min_score: int = 7, limit: int = 20, pdf_path = None if report["status"] in ("approved", "approved_with_judge_warning"): try: - from applypilot.scoring.pdf import convert_to_pdf + from applyassist.scoring.pdf import convert_to_pdf pdf_path = str(convert_to_pdf(txt_path)) except Exception: log.debug("PDF generation failed for %s", txt_path, exc_info=True) diff --git a/src/applypilot/scoring/validator.py b/src/applyassist/scoring/validator.py similarity index 99% rename from src/applypilot/scoring/validator.py rename to src/applyassist/scoring/validator.py index abb8f89d..8e3c3d58 100644 --- a/src/applypilot/scoring/validator.py +++ b/src/applyassist/scoring/validator.py @@ -1,7 +1,7 @@ """Resume and cover letter validation: banned words, fabrication detection, structural checks. All validation is profile-driven -- no hardcoded personal data. The validator receives -a profile dict (from applypilot.config.load_profile()) and validates against the user's +a profile dict (from applyassist.config.load_profile()) and validates against the user's actual skills, companies, projects, and school. Validation modes diff --git a/src/applypilot/view.py b/src/applyassist/view.py similarity index 98% rename from src/applypilot/view.py rename to src/applyassist/view.py index ff42fec2..a79d0200 100644 --- a/src/applypilot/view.py +++ b/src/applyassist/view.py @@ -1,4 +1,4 @@ -"""ApplyPilot HTML Dashboard Generator. +"""ApplyAssist HTML Dashboard Generator. Generates a self-contained HTML dashboard with: - Summary stats (total, enriched, scored, high-fit) @@ -17,8 +17,8 @@ from rich.console import Console -from applypilot.config import APP_DIR, DB_PATH -from applypilot.database import get_connection +from applyassist.config import APP_DIR, DB_PATH +from applyassist.database import get_connection console = Console() @@ -27,7 +27,7 @@ def generate_dashboard(output_path: str | None = None) -> str: """Generate an HTML dashboard of all jobs with fit scores. Args: - output_path: Where to write the HTML file. Defaults to ~/.applypilot/dashboard.html. + output_path: Where to write the HTML file. Defaults to ~/.applyassist/dashboard.html. Returns: Absolute path to the generated HTML file. @@ -200,7 +200,7 @@ def generate_dashboard(output_path: str | None = None) -> str:
-