Spaces:
Running
title: JAA ATS Tool
emoji: π€
colorFrom: blue
colorTo: indigo
sdk: docker
app_port: 7860
pinned: false
license: mit
Job Automation Agent β PM Edition
Automated Product Manager job search, AI-powered assessment, ATS-optimized resume generation, and Google Sheets reporting β all in one pipeline.
What It Does
| Step | What Happens |
|---|---|
| 1 | Parses your PDF resume (Kimi-K2.6, ~5s) |
| 2 | Scrapes PM-only jobs from LinkedIn, Indeed, Glassdoor, Remotive, WeWorkRemotely β ALL selected roles Γ locations, last N days |
| 3 | Filters non-PM roles at scrape time; dedup by URL + (title, company) + 30-day SQLite history; platform-level total cap |
| 4 | Fetches FULL job descriptions in bulk (parallel HTTP / single browser session) |
| 5 | Assesses ALL jobs using 7 parallel AI models (10-model pool via NVIDIA API) |
| 6 | Generates ATS-optimized resumes for ALL jobs β DOCX and PDF, After β₯ Before guaranteed, target 95% |
| 7 | Writes everything to your Google Sheet with direct job links |
| 8 | Saves local Excel report + resumes in data/output/resumes/YYYY-MM-DD/ |
| 9 | Saves run to history β reload past runs in one click from the History panel |
Quick Start
# 1. Install dependencies
pip install -r requirements.txt
playwright install chromium
# 2. Copy and fill in your API keys
copy .env.example .env
# Edit .env with your NVIDIA_API_KEY, GOOGLE_SHEET_ID, etc.
# 3. Place your resume PDF
# Copy your resume to: data/resume/resume.pdf
# 4. Connect Google (one-time browser login)
python connect_google.py
# 5. Run a test (5 jobs)
# In config.py: ASSESSMENT["test_mode"] = True
python main.py
# 6. Run full production mode (all 100+ PM jobs)
# In config.py: ASSESSMENT["test_mode"] = False
python main.py
# 7. Or use the Streamlit UI
streamlit run ui.py
ever-jobs Integration (160+ Platforms)
This project integrates the ever-jobs NestJS service, which provides REST API access to 160+ job board scrapers.
Prerequisites
- Docker Desktop (preferred): Install Docker Desktop
- OR Node.js 24.x for npm subprocess fallback
Setup (one-time)
# Clone ever-jobs to vendor/ directory (done automatically during setup)
git clone https://github.com/ever-jobs/ever-jobs.git vendor/ever-jobs --depth=1
# Start via Docker (preferred)
cd vendor/ever-jobs && docker compose up -d
# Verify API is running
curl http://localhost:3001/health
# OR from Python:
python -c "from src.ever_jobs_bridge.server import is_running; print(is_running())"
Automatic Startup
The pipeline calls ensure_running() automatically before any ever-jobs platforms are scraped. It tries Docker first, falls back to npm run start if Docker is unavailable.
Platform Selection
The UI has a single "π Job Platforms" section with three groups. Selecting LinkedIn, Indeed, Glassdoor, Remotive, WeWorkRemotely, or Naukri uses their dedicated high-quality scrapers; all other platforms go through the ever-jobs REST API.
| Group | Count | Description |
|---|---|---|
| Search Boards | 94 | General job boards. India-relevant defaults pre-selected. |
| ATS Platforms | 37 | Greenhouse, Lever, Workday etc. β companies post jobs here. NOT resume scoring. |
| Company Pages | 39 | Direct career pages (Flipkart, Swiggy, Amazon, Google, etc.) |
India default platforms: linkedin, naukri, indeed, glassdoor, google, bdjobs, internshala, bayt, iimjobs, foundit
ATS Clarification
Important: "ATS" in ever-jobs means Applicant Tracking System PLATFORMS (tools companies use to post jobs, like Greenhouse or Lever). It does NOT mean ATS resume scoring.
Our ATS resume scoring (
src/ats_scorer.py) uses a 70% JD keyword match + 30% resume quality hybrid and is NOT changed by this integration.
Performance Notes
| Selection | Expected Time |
|---|---|
| Default 10 platforms | ~1β3 minutes |
| 30 platforms | ~3β5 minutes |
| 100+ platforms | 5β10 minutes (warning shown in UI) |
Playwright-based scrapers (some company pages) are inherently slower than API-based boards.
Cross-Platform Deduplication
In addition to URL-based dedup, a content fingerprint (SHA-256 of normalized title+company) catches cross-platform duplicates. For example, the same "Product Manager at Google" posting on LinkedIn AND Greenhouse (different URLs) is detected and deduplicated.
Project Structure
Job Automation Agent/
βββ main.py # Main pipeline (6-step orchestrator)
βββ config.py # All configuration β models, platforms, ATS settings
βββ ui.py # Streamlit UI (4 tabs: Search, Results, Job Details, Research)
βββ connect_google.py # One-time Google OAuth setup
βββ setup_google.py # Service account alternative
βββ requirements.txt
βββ .env # API keys (never commit)
βββ .env.example # Template for .env
β
βββ src/
β βββ resume_parser.py # PDF β plain text (pdfplumber)
β βββ llm_client.py # GLM 5.1 wrapper (profile extract, resume customize)
β βββ model_pool.py # 10-model parallel AI pool (NVIDIA API)
β βββ job_assessor.py # Phase 1 keyword scoring + Phase 2 LLM assessment
β βββ job_history.py # SQLite dedup store (data/job_history.db)
β βββ ats_scorer.py # Hybrid ATS scoring (70% JD match + 30% quality)
β βββ resume_customizer.py # LLM-tailored DOCX resume generator
β βββ gsheets.py # Google Sheets + Drive upload
β βββ excel_reporter.py # Local Excel report
β βββ odysseus_llm_core.py # Deep research LLM core
β β
β βββ ever_jobs_bridge/ # ever-jobs NestJS REST API adapter
β β βββ __init__.py # Package init
β β βββ server.py # Docker/npm server lifecycle (start/stop/health)
β β βββ client.py # HTTP client for POST /api/jobs/search
β β βββ mapper.py # IJob JSON β Job dataclass mapper
β β βββ platforms.py # 170 platform catalog with group metadata
β β
β βββ scrapers/
β β βββ base.py # Job dataclass + BaseScraper + is_pm_role() filter
β β βββ linkedin.py # LinkedIn scraper (requests + BeautifulSoup)
β β βββ indeed.py # Indeed scraper (Playwright for JS rendering)
β β βββ glassdoor.py # Glassdoor scraper (Playwright)
β β βββ naukri.py # Naukri (disabled β blocked by Akamai)
β β βββ ever_jobs.py # EverJobsScraper (REST adapter for 160+ platforms)
β β
β βββ research/
β βββ deep_researcher.py # Odysseus IterResearch engine (ThinkβSearchβExtractβSynthesize)
β βββ search.py # DuckDuckGo + Bing fallback, 12h cache
β
βββ data/
βββ resume/resume.pdf # Your resume (add this)
βββ job_history.db # Dedup SQLite DB (auto-created)
βββ research_cache/ # 12h DuckDuckGo result cache
βββ output/
βββ resumes/ # Generated DOCX resumes (Company_JobTitle.docx)
βββ reports/ # Excel reports
Configuration (config.py)
AI Models (10-model pool via NVIDIA API)
| Model | Speed | Phase 2 | Notes |
|---|---|---|---|
| Kimi-K2.6 | ~5s/batch | β | Fastest, handles most work |
| Step-3.7-Flash | ~8-35s | β | |
| Qwen3.5-397b | ~9s | β | |
| Qwen3.5-122b-v2 | ~12s | β | |
| GPT-OSS-120b | ~11s | β | |
| Qwen3.5-122b | ~40s | β | |
| DeepSeek-v4-Pro | ~42s | β | |
| DeepSeek-v4-Flash | ~229s | β | Too slow for phase 2 |
| GLM-5.1 | ~234s | β | Used for resume parsing only |
| MiniMax-M2.7 | ~908s | β | Blocked/rate-limited |
Key Settings
ASSESSMENT = {
"min_score_for_llm_resume": 6, # LLM-tailored resume for score >= this
"generate_all_resumes": True, # Template resume for ALL PM jobs
"max_llm_resumes": 30, # Max LLM resumes per run
"dedup_days": 30, # Skip jobs seen in last 30 days
"test_mode": True, # β Set False for full production run
"test_jobs_limit": 5, # Max jobs in test mode
}
ATS Scoring Method
Natural keyword sentence placement
When remaining JD keywords are not covered by the V1 evidence-gated rewriter,
place_keywords_naturally() distributes them as natural PM resume sentences
across project, experience, and skills sections. Each sentence packs 8-10
keywords into a single 20-25 word bullet using semantic role categorization
(primary/context/method/outcome). Keywords are inserted bottom-to-top in the
LaTeX to prevent offset corruption. Credentials, education, licences, and
seniority requirements are never placed this way.
Hybrid scoring: 70% JD Match + 30% Resume Quality
- JD Match (70%): Extract keywords FROM the specific job description β match against resume using word-boundary regex (
(?<!\w)kw(?!\w)) β same approach as Resume-Matcher - Resume Quality (30%): 6-factor structural analysis (measurable achievements, contact info, education, etc.)
- Iterative optimization: Up to 3 LLM attempts to push score up, gap report fed back each iteration
- Fallback:
_inject_missing_keywords()weaves still-missing terms naturally
Keyword extraction is JD-driven, skills-only, and honest (industry standard)
- Keywords are extracted from each JD itself (not a fixed stored list), but skills-only: a term is kept only if it's a recognised skill/tool/method/ domain/soft-skill in our gazetteer. Prose nouns (Goals, Authority, Enterprise, Productivityβ¦) can never become "keywords" β exactly how real checkers like Jobalytics work (they match against a curated skills list, not every word).
- Dedicated, categorized SKILLS section (Tools & Analytics / Methodologies / Domains / Core Competencies) β the #1 ATS keyword vehicle, placed after the summary, parsed and counted by Jobalytics/Resume Worded.
- Quality over quantity: cover the JD's real skills (~20β26), each 1β3Γ, across the skills section + summary + woven bullets. Best practice is 15β25 keywords at 60β80% coverage β NOT 100 stuffed terms.
- No cap, distributed smartly. Every meaningful missing keyword is kept β
woven into relevant bullets first (the ideal, never-penalised place), then any
remainder spread across several short sentences (each its own paragraph, β€10
items). A single long comma-dump is deliberately not produced:
_strip_keyword_spamremoves any 15+-separator line before scoring, exactly as real ATS checkers and recruiters discount stuffing β so distributing keeps them all counted without looking like spam. - Buzzwords removed. Vague abstractions (innovation, solutions, tools, leadership, leverage, scalableβ¦) are never injected β real checkers like Resume Worded penalise them.
- Honest expectation: 90%+ is reached on JDs that genuinely fit the candidate's background. Out-of-domain JDs score honestly lower β that reflects reality (and matches third-party checkers), rather than a faked number.
- The displayed score is a conservative estimate (leans low vs our raw internal coverage); always verify the final resume on Jobalytics/Simplify.
Benchmark results (correctly differentiates β full resume + capable LLM):
- In-domain PM JDs (Airtel, generic PM) β ~83β87
- Out-of-domain JDs (SecOps, lending) β ~60β70, honestly
Telegram Bot (mobile touchpoint, via Cloudflare relay)
Third touchpoint alongside the Streamlit app and Chrome extension: send a job link (or paste the JD) to your Telegram bot and get back a tailored resume PDF.
Architecture note: HF Spaces block outbound traffic to api.telegram.org, so
the HF app can receive webhooks but can't send replies. The bot therefore runs
through a tiny Cloudflare Worker relay (free, always-on, can reach Telegram):
Telegram β Worker β HF /api/generate (does JD-fetch + tailoring + Tectonic
compile) β Worker sends the PDF back. Worker code + full setup:
relay/cloudflare-worker.js and
relay/README.md.
/api/generate accepts jd_url (server fetches/extracts the JD) or jd_text, and
falls back to the bundled default resume when no resume is supplied β so the relay
stays a thin Telegram-I/O layer.
(The legacy in-Space POST /telegram/webhook + GET /telegram/diag remain for
diagnostics, but the working path is the Cloudflare relay because of the egress block.)
Chrome Extension + JSON API
Apply one job at a time in the browser (no bulk scraping β no IP-blocking). The
HF Space serves BOTH the Streamlit app and a JSON API from one codebase β so the
score is identical to the web app (proven by tests/test_api_parity.py).
Setup: load extension/ unpacked β Options β paste your resume LaTeX (or
upload a PDF) + set API URL (https://<your-space>.hf.space) + API_SECRET_TOKEN.
Then open any job β Run β tailored resume download.
Resume LaTeX (recommended): paste your resume's LaTeX source in Options. When
present it is prioritised over the PDF β we extract its text for keyword
matching, inject the honestly-includable JD keywords distributed across a Summary
sentence, Experience bullets, and one compact competencies line (never a dump),
and compile it to a clean PDF (Tectonic on the Space) you download directly
(plus the modified .tex). This fixes the generated-PDF design issues because the
layout is your own LaTeX. Anti-faking is identical: certs, seniority, employers,
and specialised hands-on engineering terms are never injected.
Verify: python scripts/verify_latex_resume.py.
Recruiter-grade placement + resilient Run (Phase 7, v1.4.0): JD extraction now
strips page/extension UI chrome (Simplify/Jobalytics overlays) so junk like "Show
Match Details" / "People Clicked Apply" never becomes a keyword; the DOCX Skills
section is one capped line per category (18-28 items, no repeated "Core
Competencies:" dumps) with surplus keywords woven into Summary + Experience; and
clicking Run now survives the popup closing β the background owns the run and
the popup restores the spinner/result on reopen. Verify:
python scripts/verify_clean_jd_extraction.py, verify_skills_distribution.py,
verify_resilient_run.py.
Trust + usability (Phase 8, v1.5.0):
- Download a real PDF, not just DOCX. The DOCX path now always renders a PDF
sidecar (reportlab on Linux/HF) so the PDF button works even without LaTeX; the
LaTeX path compiles a clean PDF via Tectonic and, if compilation fails, tells you
why (no engine vs LaTeX error) while still offering the
.tex. - Your resume is preserved β non-destructive tailoring is the default. Role titles, companies, dates, and your existing bullets are kept VERBATIM; keywords are added only in the Professional Summary and as β€3 appended lines at the end of each experience. Nothing you wrote is renamed or rewritten.
- Persistent left side panel (Jobalytics-style). The UI is now a collapsible panel docked to the left of the page instead of a popup that vanishes β click Run, then click anywhere on the page; the run keeps going and stays visible.
- History. A History list in the panel shows your previous generations per job
(title/company/time/scores/status); click any entry to restore it and re-download
DOCX/PDF/.tex. Verify:
python scripts/verify_pdf_download.py,verify_non_destructive.py,verify_side_panel.py,verify_history.py.
API endpoints (api_server.py, port 7860, X-Api-Token header):
GET /api/healthβ{status: ok}POST /api/generateβ multipartjd_text+ (resume_latexorresumePDF; LaTeX wins) β tailored resume. PDF path returns base64 DOCX/PDF; LaTeX path returns compiledpdf_b64+tex_b64+ external coverage report.POST /api/repair-with-feedbackβ External ATS Feedback Repair Mode: paste Jobalytics/Simplify feedback (score + missing keywords); every missing keyword is risk-classified bycandidate_fitand woven honestly into bullets/skills (or injected into your LaTeX whenresume_latexis supplied).
Feedback-repair honesty rules (never relaxed):
- LOW β auto-weave; MEDIUM β auto-weave + review flag; HIGH β not added, returned as "needs your confirmation"; BLOCKED β excluded. No fake skills/seniority/employers/degrees/certs.
- Scores come from the re-parsed exported file; READY needs internal AND independent β₯ 90 AND ATS readability β₯ 90 AND parse validation.
Statuses: READY_95_EXTERNAL_ALIGNED (β₯90 both + pasted gaps mostly resolved)
Β· READY_90_PLUS_REVIEW_RECOMMENDED (medium-risk added) Β· NEEDS_USER_INPUT
(95 needs high-risk terms you must confirm) Β· NOT_ELIGIBLE_LOW_FIT (can't reach
90/95 without fabrication).
Verify: PYTHONPATH=. python scripts/verify_feedback_repair.py (deterministic;
proves blocked/high terms never enter the resume).
Maximum ATS Mode (User-Confirmed Skill Expansion)
For your target role family (Product / Product Manager / AI Product Manager /
SaaS / B2B), the uploaded resume is treated as an incomplete base profile.
Enable Maximum ATS Mode (extension toggle, on by default; API field
maximum_ats_mode=1) to treat normal PM/Product/AI/agile vocabulary β AI, ML,
generative AI, prompt design, model evaluation, QA, experimentation, data-driven,
analytics, roadmap, product strategy, product ownership, agile, scrum, user
stories, acceptance criteria, PRD, stakeholder management, SaaS, B2B, enterprise
platform, product discovery, go-to-market, retention, growth, etc. β as
user-confirmed / interview-supportable and aggressively place them across
Summary, Skills, Key Achievements and Experience. It targets 95 external ATS
and treats anything below 90 for in-family roles as a failure state to keep
repairing (unless the only gaps are genuinely blocked).
Still never fabricated (hard boundaries, unchanged): degrees, certifications/ licenses, employers, titles, years/seniority, regulated credentials, and specialized hands-on engineering/security tools (those surface as "needs your confirmation", with a one-click Confirm & regenerate).
For the extension's Generate Application flow, the resume owner has opted in to this confirmed-skill expansion. Every professional JD skill that is not already in the base resume is placed in a Confirmed Skills section as short, role-relevant sentences instead of a keyword list. This applies only to skills; the factual boundaries above remain excluded. External ATS scores cannot be guaranteed, but all includable JD skills are physically present in the export.
- Both endpoints accept
maximum_ats_mode/user_confirmed_expansion,confirmed_terms,target_external_score(default 95). Backward compatible. - Responses include a rich coverage report (per keyword: category, risk,
disposition, the resume section it was placed in, and why anything was
excluded),
still_missing_repairable, and a plain-Englishbelow_target_explanation. - Confirmed expansion terms persist to the Candidate Vault (
user_confirmed) so future resumes treat them as safe.
Maximum-ATS statuses: READY_MAX_ATS_95_PLUS (gates pass + external coverage
β₯ 95) Β· READY_90_PLUS_EXTERNAL_ALIGNED (gates pass + coverage β₯ 90) Β·
BELOW_TARGET_REPAIRABLE (below target, remaining gaps LOW/MEDIUM β keep
repairing) Β· NEEDS_USER_CONFIRMATION (only high-risk-but-supportable terms left).
External-coverage is the success signal (not the internal score). Our internal
scorer uses a narrow taxonomy (β 90%+ easily); external checkers extract a broad
40-46 term set (β can be ~54% on the same resume). So in Maximum ATS Mode the
system builds a broad Jobalytics-style expected set (src/external_ats.py),
physically guarantees every includable term into the exported DOCX (Skills
verbatim + woven into Experience bullets; HIGH/BLOCKED stay gated), re-parses the
file, and measures coverage from the export. Status is then READY_MAX_ATS_95_PLUS
(β₯95) / READY_90_PLUS_EXTERNAL_ALIGNED (β₯90) / BELOW_TARGET_REPAIRABLE β an
internal-96 / external-54 result is treated as a bug, never "done". Both endpoints
return external_coverage + a per-term coverage_report (keyword / found in export
/ section / why-missing) and log maximum_ats_mode.
Verify (deterministic, no keys):
PYTHONPATH=. python scripts/verify_maximum_ats.pyβ PM/AI terms become user-confirmed; certs/seniority/engineering stay gated; coverage improves.PYTHONPATH=. python scripts/verify_max_ats_coverage.pyβ the 26/46 live-failure regression: exported DOCX covers β₯90% of includable PM terms; credentials/fake seniority excluded; status driven by external coverage.
Generation Modes (V1 / V2)
Two resume tailoring modes, selectable per surface:
V1 β Structured Keyword Placement (default)
Evidence-gated extraction + natural sentence placement into the hardcoded resume.
The V1 rewriter aligns existing bullets to JD terminology, then
place_keywords_naturally() distributes remaining keywords as 20-25 word
natural sentences across project and experience sections (8-10 keywords per
bullet, bottom-to-top insertion). Overflow keywords go to categorized Skills
lines. Fast (no LLM call for placement); keywords appear as genuine resume
content, not comma-separated dumps.
Before/after score symmetry (required invariant). The V1 alignment estimate
reports a before and an after score, and the delta is the headline number the
extension shows. Anything that reaches only one of those two calls becomes a fake
delta. Three such leaks were fixed on 2026-08-05 after the extension reported a
decrease (66 β 41) with zero content change:
- Never score per-line.
before_textcomes fromlatex_to_textwhilescore_textcomes from the extracted PDF, and those wrap lines differently. The old per-line_detect_stuffingtherefore measured the same rΓ©sumΓ© at25.0unwrapped and0.0wrapped β a 25-point swing of pure rendering artifact (66 - 25 = 41). Any new text heuristic must be whitespace-normalized first. pdf_validationgoes to both sides or neither. It was passed only to the AFTER score, making parseability a one-sided penalty worth up to 20 points against the tailored rΓ©sumΓ©.score_beforeis now computed after the compile so it receives the same value and parseability cancels from the delta.- A Skills section is formatting, not stuffing. The old ">=6 commas on a line"
rule flagged every real rΓ©sumΓ©'s Skills block and cost a correctly formatted
document 15-25 points. The rule now needs
>=6consecutive single-word tokens, which separates a bare dump (Kubernetes, Docker, Go, Rust, β¦) from multi-word competencies (Roadmap Planning, Feature Prioritization, β¦).
Calibration is evidence-aware. calibrate() distributes a 100-point weight
budget across 4-6 match-critical criteria. Because the pipeline never fabricates,
weight spent on an unsupported criterion can never be earned β it only lowers the
reachable ceiling. It previously ranked on JD-side priority alone and routinely
gave half the budget to gaps, which is why runs reported supported phrases added (0) and the score could not move. It now takes supported_concepts (from
the evidence gate, mapped before calibration) and ranks evidence-backed criteria
ahead of gaps of equal JD priority. Gaps are still fully reported β just not
weighted. Measured end-to-end: 56 β 56 (flat) became 59 β 71 (+12).
tests/test_score_symmetry.py locks all four invariants. When touching the scorer,
run it β a flat or negative delta on an unchanged rΓ©sumΓ© is a defect, not a result.
V2 β Multi-Agent Natural Integration
V2 is a multi-agent pipeline, not keyword-filling:
- Rank β after extraction, every model in the fast pool (Kimi-K2.6,
Qwen3.5-397b, GPT-OSS-120b, Step-3.7-Flash) scores each keyword 0β100 for its
potential/relevance to the role. Scores are averaged into a consensus ranking and
the low-relevance tail is dropped (
V2_RANK_MIN_SCORE, default 40; floorV2_RANK_FLOOR, default 15). The top-ranked keywords flow into the most prominent sections (summary first). - Fan-out β every pool model independently rewrites the rΓ©sumΓ©, weaving the top-ranked keywords into natural, recruiter-credible bullets.
- Judge β Kimi scores all candidates and picks the one that reads most naturally while covering the most top keywords.
- Refine β Kimi runs one more pass on the winner to remove any residual stuffing, ensure a single cohesive summary, and keep every claim honest.
(judge_note in the API response shows the path, e.g. ranked:4agents+fan_out:4models+refined.)
Atomic coverage scoring (R32). V2 scores keyword coverage against atomic
keywords, not multi-word run-grams: a JD list like "SaaS, cloud, AWS, Azure" is
decomposed into individual terms before matching (real ATS checkers score individual
keywords, not exact 4-word phrases), while genuine multi-word skills (go-to-market,
machine learning, product roadmap) are preserved. This removes a large false-negative
deflation (a well-fit Porter PM rΓ©sumΓ©: +21pt on the same keyword set). A
coverage-aware weave pass then closes the remaining claimable gap with one targeted
call, and feed/collections URLs are truncated to a single job before extraction.
Scoring stays calibrated to real checkers (validated against the Experian fixture).
Note: keyword coverage is one signal for recruiter search discoverability, not an
auto-reject gate (most ATS organize for human review rather than score-and-reject).
Honest 90%+ scoring (R33). The V2 scoring denominator drops prose-noise via
skill_relevant_filter (a blocklist β an allowlist would self-grade) and doubles
acronym forms via _expand_acronyms (AWS β Amazon Web Services) for recruiter
search. A weave-to-target loop (up to V2_WEAVE_MAX_PASSES, default 3) then
weaves only claimable missing skills naturally until coverage clears 90% on a
genuinely-fit role β never fabricating, honesty-reverting each pass. Scoring stays
calibrated to real checkers (the Experian fixture holds at ~60%, Jobalytics Β±10). A
parseability verifier (src/parseability.py) reports structural ATS issues
(section headers, contact info, text-extractability, column layout) on every
generated rΓ©sumΓ©. All V2-only; V1 unchanged.
Key properties:
- No keyword filling. The verbatim-every-keyword rule is gone; if a keyword can't be woven naturally it's dropped. There is no comma-dump fallback β a section with no natural sentence gets nothing.
- Summary is merged, not appended. V2 rewrites the existing summary into one paragraph (no duplicate "5+ years" opening).
- Skills "Other" is a short, curated list of genuine tools only β never a raw keyword dump.
- Honesty boundaries preserved. Falls back to single-model, then V1 placement, if the whole pool fails. ~30β60s single rΓ©sumΓ©; capped/parallelized for bulk.
Scraped-noise filter (V2-only). Before allocation, V2 passes the keyword pool
through filter_scraped_noise() (src/external_ats.py), which drops tokens a raw
JD scrape drags in but that are never resume keywords: company geography
(Amsterdam, Latin Americaβ¦), the hiring company's own name, executive/recruiter
and other person names (cue-gated, e.g. "Recruiter: β¦" / "β¦ is the CEO"),
corporate-entity/boilerplate nouns (corporation, members, backbasersβ¦), and
job-board page chrome ("actively engaged", "easy apply"β¦). Genuine skill/tool/
domain keywords (REST, SOAP, GraphQL, iPaaS, reconciliation, Agile, banking
integrationsβ¦) are preserved. V1 placement is intentionally NOT filtered β
this is scoped to V2 only.
PM Keyword Placer Skill (V3 Corpus)
A reusable keyword assignment rule set derived from 39 real PM JDs (AI/GenAI PM,
SaaS/Platform, Growth/B2C archetypes; India-market weighted). Installed at
~/.claude/skills/pm-keyword-placer/SKILL.md.
Purpose: Given keywords extracted from a specific JD, the skill returns the correct placement for each β which Skills category, which Experience bullet, or "already present" β so the V2 pipeline weaves them into the right place without guessing. Rules are derived from corpus frequency (how many of the 39 JDs contain each keyword) and confirmed by the user.
Key properties:
- Operates on the current JD's keywords only β does not dump all 129 corpus keywords into every resume.
- Keywords go into sentences (V2 natural weaving), never comma-dumped.
- 32 already-present rules prevent redundant additions.
- 3 explicit skip rules (red teaming, GRC, RICE/ICE) prevent out-of-domain fabrication.
- Extensible: any new JD keyword not in the 129 rules triggers an assign+draft flow β user picks the section once, I draft the statement, rule is added permanently.
Coverage (129 rules): skills.ai (25) Β· skills.growth (19) Β·
skills.product (21) Β· skills.acq (10) Β· skills.tools (19) Β·
experience bullet targets with statement templates (20) Β· already-present (32) Β· skip (3).
Automatic (V2 pipeline): src/keyword_placer.py runs as step 1d in
generate_v2 β after keyword allocation, before fan-out. The placement brief is
injected into every fan-out model's prompt automatically, so the extension, bot,
and HF Space all benefit with zero configuration.
Manual (Claude Code skill): paste a JD and use /pm-keyword-placer to get the
placement plan interactively before a V2 generation pass.
Selecting a version
| Surface | How to choose |
|---|---|
/api/generate |
version=v1 or version=v2 form field; default from GEN_VERSION_DEFAULT env (v2) |
| HF Streamlit | V1/V2 radio on the home page; bulk pipeline honors the selection |
| Chrome extension | Default in Options (gen_version_default) + per-run toggle in the popup |
| Telegram bot | /v1, /v2 commands set per-user default; /mode shows current |
Env knobs
| Variable | Default | Description |
|---|---|---|
GEN_VERSION_DEFAULT |
v2 |
Default version when not specified (V2 is now the default across HF, extension, and Telegram; V1 still available on explicit request) |
V2_JUDGE_MODEL |
Kimi-K2.6 |
Model that judges candidates + runs the refine pass |
V2_FAN_OUT_MODELS |
Kimi-K2.6,Qwen3.5-397b,GPT-OSS-120b,Step-3.7-Flash |
Fast pool that ranks keywords + fans out (each writes a full candidate) |
V2_RANK_MIN_SCORE |
40 |
Min consensus score (0β100) for a keyword to be kept after multi-agent ranking |
V2_RANK_FLOOR |
15 |
Always keep at least this many top-ranked keywords |
Google Sheet Columns
| Column | Description |
|---|---|
| Batch Date | When the run happened |
| Rank | Score rank within this batch |
| Job Title / Company / Location | Job details |
| Platform | LinkedIn / Indeed / Glassdoor |
| Relevance Score | AI assessment (1β10) |
| ATS Before (%) | ATS score on original resume |
| ATS After (%) | ATS score on tailored resume |
| ATS Improvement | After β Before |
| Resume Quality | Structural quality score |
| Priority | High / Medium / Low |
| Matching / Missing Skills | Gap analysis |
| AI Recommendation | LLM reasoning |
| Apply Link | Direct job URL (clickable) |
| Resume Link | Google Drive link to tailored resume |
| Application Status | Dropdown: Not Applied β Offer |
Environment Variables (.env)
NVIDIA_API_KEY=nvapi-... # GLM 5.1 + primary key
NVIDIA_API_KEY_2=nvapi-... # DeepSeek-v4-Pro, MiniMax
NVIDIA_API_KEY_3=nvapi-... # Kimi-K2.6
NVIDIA_API_KEY_4=nvapi-... # Qwen3.5-122b
NVIDIA_API_KEY_5=nvapi-... # GPT-OSS-120b
NVIDIA_API_KEY_6=nvapi-... # DeepSeek-v4-Flash
NVIDIA_API_KEY_7=nvapi-... # Qwen3.5-397b, Qwen3.5-122b-v2
NVIDIA_API_KEY_8=nvapi-... # Step-3.7-Flash
GOOGLE_SHEET_ID=1Ehxt3eo... # Your Google Sheet ID
RESUME_PATH=data/resume/resume.pdf
Logging & Debugging
Every pipeline run writes a timestamped log to data/logs/run_YYYY-MM-DD_HH-MM-SS.log.
To diagnose failures:
- Run a search from the UI
- Switch to the π Logs tab
- Errors show in red, warnings in yellow
- Use Download Full Log File to share or inspect offline
- Previous runs are also listed in the selector
The log captures:
- Every scrape attempt (role, location, raw result count)
- Full Python tracebacks on any exception
- All
print()output from scrapers and LLM clients - Playwright browser output
Known Issues / Pending
| Issue | Status | Notes |
|---|---|---|
Google Drive upload 'Client' object has no attribute 'auth' |
Pending fix | gspread auth method mismatch |
| LLM resumes = 0 (GLM timeout during customization) | Pending fix | Switch to Kimi/Step for resume generation |
| Naukri blocked by Akamai | Permanent skip | Returns 406 / "Access Denied" with Playwright |
| Google OAuth "Access blocked" | Fixed | Add email as test user at GCP console |
| V2 silently dead β NVIDIA NIM returns 404 | Open | 404 "Function '23d4f03aβ¦': Not found for account 'LaXPGKs0β¦'" at resume_v2_natural.py L747/L803/L976. V2 falls back to regex/V1 but still reports itself as V2. The 404 names the account, so likely a credential/entitlement issue rather than another EOL model. V1 is unaffected (deterministic by default). Caught only by test_v1_quality::test_v2_natural_ai. |
| ATS before/after score could decrease | Fixed (dba70c7) |
Three one-sided asymmetries in the before/after comparison β see HISTORY.md and tests/test_score_symmetry.py |
test_atomic_scoring::test_porter_fixture_atomic_vs_gram |
Open (pre-existing) | external_ats atomic coverage: 51 vs required 52. Fails on d5a4481 too β not a regression |
test_resume_v2::test_place_sentences |
Open (pre-existing) | V2 sentence placement. Fails on d5a4481 too |
test_api_parity crashes on Windows |
Environmental | 0x800706be in pdf_writer._word_available β tries to launch MS Word over COM |
Running the UI
streamlit run ui.py
# Opens at http://localhost:8501
# Tabs:
# 1. Search β configure and run the full pipeline
# 2. Results β view all assessed jobs with scores
# 3. Job Details β expand any job for full AI breakdown
# 4. Deep Research β Odysseus engine to research companies