Skip to content
Open
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
e634ebf
docs(specs): ATS Resume Screener design spec
Nikiyolo Apr 29, 2026
a19e96e
docs(plans): ATS Resume Screener implementation plan
Nikiyolo Apr 29, 2026
85e0908
feat(ats): add synonym normalization utility
Nikiyolo Apr 29, 2026
8480ec5
fix(ats): prevent email address corruption in synonym normalization
Nikiyolo Apr 29, 2026
a8f34d1
feat(ats): add ATS Pydantic schemas
Nikiyolo Apr 29, 2026
d57bd88
feat(ats): add ATS prompt templates
Nikiyolo Apr 29, 2026
fc79bde
feat(ats): add Pass 1 scorer service with unit tests
Nikiyolo Apr 29, 2026
ced11de
feat(ats): add Pass 2 optimizer service
Nikiyolo Apr 29, 2026
6b33186
fix(ats): add ValidationError logging and unit tests for Pass 2 optim…
Nikiyolo Apr 29, 2026
7e3036c
feat(ats): add ATS router with integration tests and wire up
Nikiyolo Apr 29, 2026
9b05f8f
test(ats): add missing 500 and 409 integration test cases
Nikiyolo Apr 30, 2026
7f2cbb3
fix(health): correctly reflect degraded LLM status in health endpoint
Nikiyolo Apr 30, 2026
77f88fd
feat(ats): add frontend API client and display components
Nikiyolo Apr 30, 2026
81e811f
feat(ats): add optimization panel, resume input, and screen panel orc…
Nikiyolo Apr 30, 2026
afe71ee
feat(ats): add standalone /ats page
Nikiyolo Apr 30, 2026
3b5ae86
feat(ats): integrate ATS pre-screen into tailor page and add dashboar…
Nikiyolo Apr 30, 2026
2db2720
fix(ats): correct save flow, skip Pass 2 in paste mode, sync editedRe…
Nikiyolo Apr 30, 2026
91d82cd
feat(frontend): redesign dashboard, add ATS page nav, downgrade to Ne…
Nikiyolo Apr 30, 2026
344d68d
fix: remove real API key from .env.example, replace with placeholder
Nikiyolo Apr 30, 2026
8a5af6e
docs: add Chrome extension ATS screen design spec
Nikiyolo Apr 30, 2026
f83b4f5
feat(extension): scaffold manifest v3 chrome extension
Nikiyolo Apr 30, 2026
5827b4d
feat(extension): add language and visa parsers with tests
Nikiyolo Apr 30, 2026
76e37b7
fix(extension): add null guards, word-boundary matching, remove dead …
Nikiyolo Apr 30, 2026
2daf8aa
feat(extension): add content script shared DOM utilities
Nikiyolo Apr 30, 2026
86828a9
feat(extension): add LinkedIn content script
Nikiyolo Apr 30, 2026
0f9f4f3
feat(extension): add Indeed content script
Nikiyolo Apr 30, 2026
7fc412a
feat(extension): add Glassdoor content script
Nikiyolo Apr 30, 2026
d3b9391
feat(extension): add service worker with API dispatch
Nikiyolo Apr 30, 2026
63734b4
feat(extension): add popup UI and logic
Nikiyolo Apr 30, 2026
ebd7361
fix(extension): prevent ready view from overwriting backend-offline e…
Nikiyolo Apr 30, 2026
37b09b7
feat(extension): add options page for backend/frontend URL config
Nikiyolo Apr 30, 2026
03aca01
fix(backend): allow chrome-extension:// origins via CORS regex
Nikiyolo Apr 30, 2026
7f84d93
feat(frontend): pre-fill job description from ?jd= URL param (extensi…
Nikiyolo Apr 30, 2026
26c92f1
fix(extension): support LinkedIn feed/collections URLs and SPA naviga…
Nikiyolo Apr 30, 2026
e0592fe
fix(extension): rewrite LinkedIn script to find apply button by text,…
Nikiyolo Apr 30, 2026
47a9bca
fix(extension): stop console spam — use interval for SPA nav, guard i…
Nikiyolo Apr 30, 2026
9c0a958
fix(extension): add debug helper, floating button fallback, broader J…
Nikiyolo May 1, 2026
1d8f1d8
fix(extension): scope JD extraction to detail panel, fix job title on…
Nikiyolo May 1, 2026
951f708
fix(frontend): wrap useSearchParams in Suspense boundary for Next.js 15
Nikiyolo May 1, 2026
b7a9f7c
feat: View Full Results opens with resume, JD, and results pre-filled
Nikiyolo May 1, 2026
43ff227
fix(extension): raise ATS screen timeout to 120s, improve timeout err…
Nikiyolo May 1, 2026
dadb127
fix: sync initialResult prop into ATSScreenPanel state; hide lang lab…
Nikiyolo May 1, 2026
c62024b
feat: add 'Create ATS Tailored Resume' button to ATS page and extensi…
Nikiyolo May 1, 2026
05cca6a
feat: name saved resume as 'role_company', add Download Resume button
Nikiyolo May 1, 2026
36bca68
fix: use backend PDF renderer for download; auto-expand LinkedIn JD
Nikiyolo May 1, 2026
8c245e8
fix(extension): prevent infinite mutation loop from expandJD()
Nikiyolo May 1, 2026
c19023c
feat: Chrome extension ATS flow, PDF layout fix, dashboard UX improve…
Nikiyolo May 2, 2026
0a9f326
fix: address 14 code review issues from Cubic Dev
Nikiyolo May 2, 2026
254e701
docs: rewrite README to focus on ATS Copilot and Chrome extension wor…
Nikiyolo May 13, 2026
fc080d2
ci: fix Docker publish workflow to target nikiyolo/ats-copilot registry
Nikiyolo May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
# openai_compatible = llama.cpp, vLLM, LM Studio, and any self-hosted server
# that exposes the OpenAI Chat Completions API. API key is optional for this
# provider (backend passes a sentinel when blank).
LLM_PROVIDER=openai
LLM_MODEL=gpt-5-nano-2025-08-07
LLM_API_KEY=sk-your-api-key-here
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-5-20250929
LLM_API_KEY=your-api-key-here

# For Ollama (local models)
# LLM_PROVIDER=ollama
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from app.config import settings
from app.database import db
from app.pdf import close_pdf_renderer, init_pdf_renderer
from app.routers import config_router, enrichment_router, health_router, jobs_router, resumes_router
from app.routers import ats_router, config_router, enrichment_router, health_router, jobs_router, resumes_router


def _configure_application_logging() -> None:
Expand Down Expand Up @@ -61,6 +61,7 @@ async def lifespan(app: FastAPI):
app.add_middleware(
CORSMiddleware,
allow_origins=settings.effective_cors_origins,
allow_origin_regex=r"chrome-extension://.*",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand All @@ -72,6 +73,7 @@ async def lifespan(app: FastAPI):
app.include_router(resumes_router, prefix="/api/v1")
app.include_router(jobs_router, prefix="/api/v1")
app.include_router(enrichment_router, prefix="/api/v1")
app.include_router(ats_router, prefix="/api/v1")


@app.get("/")
Expand Down
77 changes: 77 additions & 0 deletions apps/backend/app/prompts/ats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Prompt templates for the ATS two-pass pipeline."""

ATS_SCORE_PROMPT = """You are an ATS (Applicant Tracking System) engine. Analyze the job description and resume below.

Score using these weighted categories. Do NOT exceed the listed maximum for each:
- skills_match: 0-30 points (explicit hard skills: programming languages, domain skills, certifications)
- experience_match: 0-25 points (years of experience, seniority level, role progression match)
- domain_match: 0-20 points (industry knowledge, domain terminology, sector-specific language)
- tools_match: 0-15 points (specific tools, platforms, software, technologies named in JD)
- achievement_match: 0-10 points (quantified results, measurable impacts, specific accomplishments)

Decision rules (apply AFTER scoring):
- total_score >= 75 → decision = "PASS"
- 60 <= total_score < 75 → decision = "BORDERLINE"
- total_score < 60 → decision = "REJECT" — you MUST provide at least 10 distinct warning_flags

For keyword_table: extract the 20-30 most important keywords/phrases from the JD. For each, state whether it appears in the resume and in which section. Use section names: "summary", "workExperience", "education", "additional", or null if not found.

For warning_flags: be concrete and specific. Write "Missing 3+ years of product management experience — resume shows 1 year" not "lacks experience". Every flag must name a specific gap.

Output ONLY the following JSON object. No explanation, no markdown:
{{
"score_breakdown": {{
"skills_match": <integer 0-30>,
"experience_match": <integer 0-25>,
"domain_match": <integer 0-20>,
"tools_match": <integer 0-15>,
"achievement_match": <integer 0-10>
}},
"total_score": <integer 0-100>,
"decision": "PASS" | "BORDERLINE" | "REJECT",
"keyword_table": [
{{"keyword": "...", "found_in_resume": true, "section": "workExperience"}},
{{"keyword": "...", "found_in_resume": false, "section": null}}
],
"missing_keywords": ["keyword1", "keyword2"],
"warning_flags": ["Specific flag 1", "Specific flag 2"]
}}

Job Description:
{job_description}

Resume:
{resume_text}"""


ATS_OPTIMIZE_PROMPT = """You are an ATS resume optimizer. Improve the resume below to better match the job description, guided by the gap analysis.

{critical_truthfulness_rules}

Gap Analysis:
Missing Keywords: {missing_keywords}

Warning Flags:
{warning_flags}

Score Breakdown: {score_breakdown}

Optimization rules:
- Weave missing keywords into existing bullets ONLY where the candidate's actual experience supports them
- If the resume contains any of these exact phrases — "product judgment", "operating in ambiguity", "structured thinking", "data-driven decision making" — preserve them verbatim in the output
- Do NOT add those PM phrases if they do not appear anywhere in the original resume text
- Strengthen vague action verbs ("worked on" → "led", "helped with" → "drove")
- Improve the summary to lead with the most JD-relevant experience
- Provide 5-10 specific, actionable optimization_suggestions explaining what changed and why

Job Description:
{job_description}

Original Resume (JSON):
{resume_json}

Output ONLY this JSON. The optimized_resume field must match the schema exactly:
{{
"optimized_resume": {schema},
"optimization_suggestions": ["suggestion1", "suggestion2"]
}}"""
2 changes: 2 additions & 0 deletions apps/backend/app/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""API routers."""

from app.routers.ats import router as ats_router
from app.routers.config import router as config_router
from app.routers.enrichment import router as enrichment_router
from app.routers.health import router as health_router
from app.routers.jobs import router as jobs_router
from app.routers.resumes import router as resumes_router

__all__ = [
"ats_router",
"resumes_router",
"jobs_router",
"config_router",
Expand Down
144 changes: 144 additions & 0 deletions apps/backend/app/routers/ats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""ATS screening endpoint."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Custom agent: Flag Security Vulnerabilities

ATS endpoints create an IDOR/authorization gap by trusting client-supplied resume/job/parent IDs for reads and writes without ownership scoping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/routers/ats.py, line 39:

<comment>ATS endpoints create an IDOR/authorization gap by trusting client-supplied resume/job/parent IDs for reads and writes without ownership scoping.</comment>

<file context>
@@ -0,0 +1,144 @@
+    from_db = False
+
+    if request.resume_id:
+        resume = db.get_resume(request.resume_id)
+        if not resume:
+            raise HTTPException(status_code=404, detail="Resume not found.")
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Custom agent: Flag Security Vulnerabilities

ATS endpoints create an IDOR/authorization gap by trusting client-supplied resume/job/parent IDs for reads and writes without ownership scoping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/routers/ats.py, line 39:

<comment>ATS endpoints create an IDOR/authorization gap by trusting client-supplied resume/job/parent IDs for reads and writes without ownership scoping.</comment>

<file context>
@@ -0,0 +1,144 @@
+    from_db = False
+
+    if request.resume_id:
+        resume = db.get_resume(request.resume_id)
+        if not resume:
+            raise HTTPException(status_code=404, detail="Resume not found.")
</file context>


import json
import logging

from fastapi import APIRouter, HTTPException

from app.database import db
from app.schemas.ats import ATSScreenRequest, ATSScreeningResult, ATSSaveResumeRequest, ATSSaveResumeResponse
from app.services.ats_optimizer import run_pass2
from app.services.ats_scorer import run_pass1

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/ats", tags=["ATS"])


@router.post("/screen", response_model=ATSScreeningResult)
async def screen_resume(request: ATSScreenRequest) -> ATSScreeningResult:
"""Run ATS screening: score + optimize resume vs job description."""
# Validate at least one resume source and one job source
if not request.resume_id and not request.resume_text:
raise HTTPException(
status_code=422,
detail="Either resume_id or resume_text is required.",
)
if not request.job_id and not request.job_description:
raise HTTPException(
status_code=422,
detail="Either job_id or job_description is required.",
)

# Resolve resume
resume_text = request.resume_text or ""
resume_json: dict = {}
from_db = False

if request.resume_id:
resume = db.get_resume(request.resume_id)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found.")
resume_json = resume.get("processed_data") or {}
resume_text = resume.get("content", resume_text)
from_db = True

# Only enforce the minimum length check on directly-supplied resume_text,
# not on content fetched from the database (which may be a stored reference).
if not from_db and len(resume_text.strip()) < 100:
raise HTTPException(
status_code=400,
detail="Resume text too short to analyze (minimum 100 characters).",
)

# Resolve job description
job_text = request.job_description or ""

if request.job_id:
job = db.get_job(request.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found.")
job_text = job.get("content", job_text)

# Pass 1: Score

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Job description lacks minimum length validation before ATS scoring, allowing whitespace-only or empty content to reach the LLM and trigger wasteful calls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/routers/ats.py, line 63:

<comment>Job description lacks minimum length validation before ATS scoring, allowing whitespace-only or empty content to reach the LLM and trigger wasteful calls.</comment>

<file context>
@@ -0,0 +1,144 @@
+            raise HTTPException(status_code=404, detail="Job not found.")
+        job_text = job.get("content", job_text)
+
+    # Pass 1: Score
+    try:
+        pass1 = await run_pass1(resume_text, job_text)
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Job description lacks minimum length validation before ATS scoring, allowing whitespace-only or empty content to reach the LLM and trigger wasteful calls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/routers/ats.py, line 63:

<comment>Job description lacks minimum length validation before ATS scoring, allowing whitespace-only or empty content to reach the LLM and trigger wasteful calls.</comment>

<file context>
@@ -0,0 +1,144 @@
+            raise HTTPException(status_code=404, detail="Job not found.")
+        job_text = job.get("content", job_text)
+
+    # Pass 1: Score
+    try:
+        pass1 = await run_pass1(resume_text, job_text)
</file context>

try:
pass1 = await run_pass1(resume_text, job_text)
except Exception as exc:
logger.error("ATS Pass 1 failed: %s", exc)
raise HTTPException(
status_code=500,
detail="ATS scoring failed. Please try again.",
) from exc

# Pass 2: Optimize (non-fatal — partial result returned on failure)
# Skip if no structured resume JSON available (paste mode only)
optimized_resume = None
optimization_suggestions: list[str] = []
if resume_json: # Only run if we have structured resume data
try:
pass2 = await run_pass2(
resume_json=resume_json,
job_text=job_text,
score_data=pass1,
)
optimized_resume = pass2["optimized_resume"]
optimization_suggestions = pass2["optimization_suggestions"]
except Exception as exc:
logger.warning("ATS Pass 2 failed (non-fatal): %s", exc)

# Optionally persist the optimized resume
saved_resume_id: str | None = None
if request.save_optimized:
if optimized_resume is None:
raise HTTPException(
status_code=409,
detail="Optimization unavailable — cannot save.",
)
try:
optimized_dict = optimized_resume.model_dump()
new_resume = db.create_resume(
content=json.dumps(optimized_dict),
content_type="json",
processed_data=optimized_dict,
processing_status="ready",
parent_id=request.resume_id,
title="ATS Optimized Resume",
)
saved_resume_id = new_resume["resume_id"]
except Exception as exc:
logger.error("Failed to save optimized resume: %s", exc)
raise HTTPException(
status_code=500,
detail="Failed to save optimized resume.",
) from exc

return ATSScreeningResult(
score=pass1["score"],
decision=pass1["decision"],
keyword_table=pass1["keyword_table"],
missing_keywords=pass1["missing_keywords"],
warning_flags=pass1["warning_flags"],
optimization_suggestions=optimization_suggestions,
optimized_resume=optimized_resume,
saved_resume_id=saved_resume_id,
)


@router.post("/save-resume", response_model=ATSSaveResumeResponse)
async def save_ats_resume(request: ATSSaveResumeRequest) -> ATSSaveResumeResponse:
"""Save an ATS-optimized resume directly without re-running the LLM."""
import json as _json
try:
optimized_dict = request.resume_data.model_dump()
new_resume = db.create_resume(
content=_json.dumps(optimized_dict),
content_type="json",
processed_data=optimized_dict,
processing_status="ready",
parent_id=request.parent_id,
title=request.title,
)
return ATSSaveResumeResponse(resume_id=new_resume["resume_id"])
except Exception as exc:
logger.error("Failed to save ATS resume: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save resume.") from exc
10 changes: 7 additions & 3 deletions apps/backend/app/routers/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@

@router.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
"""Lightweight liveness check for Docker HEALTHCHECK.
"""Liveness and readiness check.

Does NOT call the LLM provider. Use GET /status for full LLM health.
Calls the LLM provider to verify connectivity. Returns 'degraded' if the
LLM is unreachable or misconfigured, 'healthy' otherwise.
"""
return HealthResponse(status="healthy")
config = get_llm_config()
llm_status = await check_llm_health(config)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Flag Security Vulnerabilities

Unauthenticated /health now triggers an outbound LLM call on every request, creating a DoS/cost-amplification vector.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/routers/health.py, line 20:

<comment>Unauthenticated `/health` now triggers an outbound LLM call on every request, creating a DoS/cost-amplification vector.</comment>

<file context>
@@ -11,11 +11,15 @@
     """
-    return HealthResponse(status="healthy")
+    config = get_llm_config()
+    llm_status = await check_llm_health(config)
+    status = "healthy" if llm_status["healthy"] else "degraded"
+    return HealthResponse(status=status)
</file context>

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
status = "healthy" if llm_status["healthy"] else "degraded"
return HealthResponse(status=status)


@router.get("/status", response_model=StatusResponse)
Expand Down
63 changes: 63 additions & 0 deletions apps/backend/app/schemas/ats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Pydantic schemas for ATS screening."""

from typing import Literal

from pydantic import BaseModel

from app.schemas.models import ResumeData


class ATSScreenRequest(BaseModel):
"""Request body for POST /api/v1/ats/screen."""

resume_id: str | None = None
resume_text: str | None = None
job_id: str | None = None
job_description: str | None = None
save_optimized: bool = False


class ScoreBreakdown(BaseModel):
"""Weighted ATS score per dimension."""

skills_match: float # max 30
experience_match: float # max 25
domain_match: float # max 20
tools_match: float # max 15
achievement_match: float # max 10
total: float # 0-100


class KeywordRow(BaseModel):
"""One row in the keyword match table."""

keyword: str
found_in_resume: bool
section: str | None = None # e.g. "workExperience", "summary", null if not found


class ATSScreeningResult(BaseModel):
"""Full ATS screening report returned by the endpoint."""

score: ScoreBreakdown
decision: Literal["PASS", "BORDERLINE", "REJECT"]
keyword_table: list[KeywordRow]
missing_keywords: list[str]
warning_flags: list[str] # >= 10 items when decision == "REJECT"
optimization_suggestions: list[str]
optimized_resume: ResumeData | None = None
saved_resume_id: str | None = None # populated when save_optimized=True


class ATSSaveResumeRequest(BaseModel):
"""Request body for POST /api/v1/ats/save-resume."""

resume_data: ResumeData
parent_id: str | None = None
title: str = "ATS Optimized Resume"


class ATSSaveResumeResponse(BaseModel):
"""Response body for POST /api/v1/ats/save-resume."""

resume_id: str
Loading