JAA-ATS-Tool / README.md
saitejatirunagari's picture
feat: natural keyword sentence placement across resume sections
8c3cb9e
|
Raw
History Blame Contribute Delete
35.4 kB
metadata
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

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_spam removes 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 β€” multipart jd_text + (resume_latex or resume PDF; LaTeX wins) β†’ tailored resume. PDF path returns base64 DOCX/PDF; LaTeX path returns compiled pdf_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 by candidate_fit and woven honestly into bullets/skills (or injected into your LaTeX when resume_latex is 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-English below_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_text comes from latex_to_text while score_text comes from the extracted PDF, and those wrap lines differently. The old per-line _detect_stuffing therefore measured the same rΓ©sumΓ© at 25.0 unwrapped and 0.0 wrapped β€” a 25-point swing of pure rendering artifact (66 - 25 = 41). Any new text heuristic must be whitespace-normalized first.
  • pdf_validation goes 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_before is 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 >=6 consecutive 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:

  1. 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; floor V2_RANK_FLOOR, default 15). The top-ranked keywords flow into the most prominent sections (summary first).
  2. Fan-out β€” every pool model independently rewrites the rΓ©sumΓ©, weaving the top-ranked keywords into natural, recruiter-credible bullets.
  3. Judge β€” Kimi scores all candidates and picks the one that reads most naturally while covering the most top keywords.
  4. 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:

  1. Run a search from the UI
  2. Switch to the πŸ“‹ Logs tab
  3. Errors show in red, warnings in yellow
  4. Use Download Full Log File to share or inspect offline
  5. 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