"""api.py — FastAPI backend for the ResumeGPT web interface.
Run: uvicorn api:app --reload --port 8000
Then open: http://localhost:8000 """
import asyncio import collections import io import json import os import re import shutil import sys import threading from datetime import datetime, timedelta from pathlib import Path from typing import AsyncGenerator
import markdown2
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Depends from fastapi.responses import FileResponse, StreamingResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware from dotenv import load_dotenv from sqlalchemy.orm import Session
load_dotenv()
Ensure pdflatex (basictex) is on PATH
TEXLIVEBIN = "/usr/local/texlive/2026basic/bin/universal-darwin" if Path(TEXLIVEBIN).exists() and TEXLIVEBIN not in os.environ.get("PATH", ""): os.environ["PATH"] = TEXLIVEBIN + ":" + os.environ.get("PATH", "")
Add tools/ to Python path
sys.path.insert(0, str(Path(file).parent / "tools"))
from collectprofile import callclaudeenhance from enhanceexperience import enhanceentries, applyenhancements from analyzekeywordgap import analyzekeywordgap from scorecv import scorecv from parsecv import extractpdf, extractdocx from structurecv import structurecvtext from cvsections import extractsections, regeneratesection, applymanualedit from generatedocxcv import generatedocxcv import scrapejobs as scrapemodule import ratejobs as ratemodule import scrapeurljobs as scrapeurlmodule from generatelatexcv import generatelatexcv from generatecoverletter import generatecoverletter from compilelatex import compilelatex from exportexcel import exportexcel from db import initdb, getdb, User, PLANLIMITS, GENLIMITS, GENBURSTLIMIT, GENBURSTWINDOW, engine from auth import router as authrouter, getcurrentuser from billing import router as billingrouter
app = FastAPI( title="ResumeGPT", docsurl=None, # disable /docs in production redocurl=None, # disable /redoc in production openapi_url=None, # disable /openapi.json in production )
── Security headers ──────────────────────────────────────────────────────────
class SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, callnext): response = await callnext(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" # Only set HSTS on HTTPS (Railway handles TLS termination) if request.url.scheme == "https": response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response
app.add_middleware(SecurityHeadersMiddleware)
── Upload limits ─────────────────────────────────────────────────────────────
MAXCVSIZEBYTES = 10 * 1024 * 1024 # 10 MB MAXPHOTOSIZEBYTES = 5 * 1024 * 1024 # 5 MB ALLOWEDPHOTOEXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
Magic bytes for allowed image types
IMAGEMAGIC = { b"\xff\xd8\xff": "jpeg", b"\x89PNG": "png", b"RIFF": "webp", # RIFF....WEBP — checked more below }
def isvalid_image(data: bytes) -> bool: """Check magic bytes to ensure the file is actually an image.""" if data[:3] == b"\xff\xd8\xff": return True # JPEG if data[:4] == b"\x89PNG": return True # PNG if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return True # WebP return False
def safeerror(e: Exception) -> str: """Return a user-safe error string — never expose internal paths or tracebacks.""" msg = str(e) # Strip anything that looks like an absolute path msg = re.sub(r"/[^\s]+", "[path]", msg) return msg[:200]
Serve static files (screenshots, assets)
staticdir = Path(file).parent / "static" staticdir.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=str(staticdir)), name="static")
Start up the database — creates tables and adds any missing columns
@app.onevent("startup") def startup(): initdb() # Add new columns if they don't exist yet (safe to run on every startup). # Each column uses its own connection so a "column already exists" error # on one column never aborts the transaction for the next column. from sqlalchemy import text for col, typedef in [ ("isverified", "BOOLEAN DEFAULT FALSE"), ("otpcode", "VARCHAR"), ("otpexpiresat", "TIMESTAMP"), ("cvgencount", "INTEGER DEFAULT 0"), ("clgencount", "INTEGER DEFAULT 0"), ("genperiodstart", "TIMESTAMP"), ("oauthprovider", "VARCHAR"), ("oauthprovider_id", "VARCHAR"), ]: try: with engine.connect() as conn: conn.execute(text(f"ALTER TABLE users ADD COLUMN {col} {typedef}")) conn.commit() except Exception: pass # column already exists — safe to ignore
Register auth routes (/api/auth/signup, /api/auth/login, /api/auth/me)
app.includerouter(authrouter)
Register billing routes (/api/billing/checkout, /api/billing/webhook, /api/billing/portal)
app.includerouter(billingrouter)
TEMPLATES = Path("templates") DATAROOT = Path("/data") if Path("/data").isdir() else Path(".") OUTPUT = DATAROOT / "output" OUTPUT.mkdir(existok=True)
── Per-user directory helpers ────────────────────────────────────────────────
Each user gets their own private folders so their data never mixes with others
def usertmp(user: User) -> Path:
"""Returns .tmp/
def useroutput(user: User) -> Path:
"""Returns output/
── Payment gate ─────────────────────────────────────────────────────────────
def getpaiduser(currentuser: User = Depends(getcurrentuser)) -> User: """ FastAPI dependency — blocks access if the user hasn't paid yet. Use this instead of getcurrentuser on any route that requires an active plan. """ if currentuser.planstatus != "active": raise HTTPException( statuscode=402, detail="No active subscription. Please select a plan to continue.", ) return current_user
── Usage enforcement ─────────────────────────────────────────────────────────
def checkandresetusage(user: User, db: Session): """ Called before every job search. - If the monthly period has expired, resets the search counter to 0. - If the user has hit their plan limit, blocks the search with a 403 error. """ # Reset counter if the monthly period has expired if datetime.utcnow() >= user.periodresetdate: user.searchesused = 0 user.periodresetdate = datetime.utcnow() + timedelta(days=30) db.commit()
if user.searches_used >= user.searches_limit:
raise HTTPException(
status_code=403,
detail=f"You've used all {user.searches_limit} searches this month. Upgrade your plan to continue.",
)
def incrementusage(user: User, db: Session, count: int = 1): """Add scraped job count to the user's usage counter.""" user.searchesused += count db.commit()
── Generation rate limiting ──────────────────────────────────────────────────
In-memory burst tracker: user_id → deque of UTC timestamps of recent gen requests
genburst: dict = {} # user_id → collections.deque
def resetgenperiodifneeded(user: User, db: Session): """Reset monthly generation counters if the billing period has rolled over.""" now = datetime.utcnow() start = user.genperiodstart if start is None or (now - start).days >= 30: user.cvgencount = 0 user.clgencount = 0 user.genperiod_start = now db.commit()
def checkgenlimit(user: User, db: Session, gentype: str = "cv"): """ Check and enforce generation limits. gentype: "cv" or "cl" Raises HTTP 429 if burst or monthly limit exceeded. Re-reads the database each call — no session caching. """ # Refresh user from DB to pick up any mid-session plan changes db.refresh(user) resetgenperiodif_needed(user, db)
plan = user.plan or "starter"
limits = GEN_LIMITS.get(plan, GEN_LIMITS["starter"])
monthly_limit = limits.get(gen_type, 30)
count = user.cv_gen_count if gen_type == "cv" else user.cl_gen_count
# Dynamically computed remaining so upgrades reflect immediately
remaining = monthly_limit - count
reset_date = (user.gen_period_start + timedelta(days=30)).strftime("%Y-%m-%d") if user.gen_period_start else "unknown"
if remaining <= 0:
type_label = "CV" if gen_type == "cv" else "Cover Letter"
raise HTTPException(
status_code=429,
detail={
"error": "generation_limit_reached",
"type": gen_type,
"message": f"You have used all {monthly_limit} {type_label} generations included in your {plan.title()} plan this month.",
"limit": monthly_limit,
"used": count,
"resets_on": reset_date,
"upgrade_url": "/pricing",
},
)
# Burst check: max GEN_BURST_LIMIT requests in GEN_BURST_WINDOW seconds
uid = user.id
now = datetime.utcnow()
if uid not in _gen_burst:
_gen_burst[uid] = collections.deque()
dq = _gen_burst[uid]
# Remove stale timestamps
cutoff = now - timedelta(seconds=GEN_BURST_WINDOW)
while dq and dq[0] < cutoff:
dq.popleft()
if len(dq) >= GEN_BURST_LIMIT:
raise HTTPException(
status_code=429,
detail="Please wait a moment before generating again.",
)
dq.append(now)
def incrementgencount(user: User, db: Session, gentype: str = "cv"): """Atomically increment the generation counter before triggering generation.""" if gentype == "cv": user.cvgencount = (user.cvgencount or 0) + 1 else: user.clgencount = (user.clgencount or 0) + 1 db.commit()
def getgenusage(user: User, db: Session) -> dict: """Return the user's current generation usage for the frontend.""" db.refresh(user) resetgenperiodifneeded(user, db) plan = user.plan or "starter" limits = GENLIMITS.get(plan, GENLIMITS["starter"]) cvlimit = limits["cv"] cllimit = limits["cl"] resetdate = (user.genperiodstart + timedelta(days=30)).strftime("%Y-%m-%d") if user.genperiodstart else "N/A" return { "plan": plan, "cvused": user.cvgencount or 0, "cvlimit": cvlimit, "cvremaining": max(0, cvlimit - (user.cvgencount or 0)), "clused": user.clgencount or 0, "cllimit": cllimit, "clremaining": max(0, cllimit - (user.clgencount or 0)), "resetson": resetdate, }
── Global search state (per-user, keyed by user_id) ─────────────────────────
searchstate: dict = {} # user_id → {"running": bool, "done": bool, "cancelled": bool, "queue": ..., "loop": ...}
def prefilterjobs(jobspath: Path, analysispath: Path, maxjobs: int = 50) -> int: """ Before expensive Claude rating, keep only the most keyword-relevant jobs. Returns the number of jobs kept. """ jobs = json.loads(jobspath.readtext(encoding="utf-8")) if len(jobs) <= maxjobs: return len(jobs)
analysis = json.loads(analysis_path.read_text(encoding="utf-8"))
keywords = analysis.get("job_search_keywords", {})
kw_list = (
[t.lower() for t in keywords.get("primary_titles", [])] +
[t.lower() for t in keywords.get("secondary_titles", [])] +
[k.lower() for k in keywords.get("skill_keywords", [])] +
[s.lower() for s in analysis.get("core_skills", [])]
)
# Also add individual words from multi-word keywords
words = set()
for kw in kw_list:
words.update(kw.split())
kw_list = list(set(kw_list)) + [w for w in words if len(w) > 3]
# Build location terms to boost jobs matching the user's search location
location_str = analysis.get("job_search_keywords", {}).get("location_preference", "") or ""
loc_terms = [t.lower().strip() for t in re.split(r"[,\s]+", location_str) if len(t.strip()) > 2]
def score(job):
title = (job.get("title") or "").lower()
desc = (job.get("description") or "").lower()[:500]
loc = (job.get("location") or "").lower()
s = sum(3 for kw in kw_list if kw in title)
s += sum(2 for kw in kw_list if kw in desc)
# Boost jobs whose location matches the search location
s += sum(4 for lt in loc_terms if lt in loc)
return s
scored = sorted(jobs, key=score, reverse=True)
top = scored[:max_jobs]
jobs_path.write_text(json.dumps(top, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"[prefilter] {len(jobs)} jobs → {len(top)} kept for rating (keyword relevance filter)")
return len(top)
── Stdout capture for background thread ─────────────────────────────────────
class _QueueWriter(io.RawIOBase): """Redirects print() output from background thread into asyncio queue."""
def __init__(self, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
self._q = queue
self._loop = loop
def write(self, msg) -> int:
if isinstance(msg, bytes):
msg = msg.decode("utf-8", errors="replace")
if msg.strip():
try:
self._loop.call_soon_threadsafe(self._q.put_nowait, msg.rstrip())
except Exception:
pass
return len(msg) if isinstance(msg, str) else len(msg)
def flush(self):
pass
def readable(self):
return False
def writable(self):
return True
── Background search thread ──────────────────────────────────────────────────
def runsearch(userid: str, config: dict, tmp: Path, output: Path, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue, dbsession): old_stdout = sys.stdout writer = QueueWriter(queue, loop) sys.stdout = io.TextIOWrapper(writer, encoding="utf-8", linebuffering=True)
try:
loop.call_soon_threadsafe(queue.put_nowait, "── Step 1 / 2: Scraping jobs...")
location_str = config.get("location", "")
country_indeed = location_str.split(",")[-1].strip() if location_str else "UK"
_scrape_module.scrape_jobs(
analysis_path=str(tmp / "cv_analysis.json"),
output_path=str(tmp / "jobs_raw.json"),
location=location_str,
results_per_site=config.get("results_per_site", 25),
hours_old=config.get("hours_old", 168),
country_indeed=country_indeed,
)
# Pre-filter to most relevant jobs before expensive Claude rating
kept = _prefilter_jobs(tmp / "jobs_raw.json", tmp / "cv_analysis.json", max_jobs=50)
if _search_state.get(user_id, {}).get("cancelled"):
loop.call_soon_threadsafe(queue.put_nowait, "Search cancelled.")
loop.call_soon_threadsafe(queue.put_nowait, "DONE")
return
loop.call_soon_threadsafe(queue.put_nowait, f"── Step 2 / 2: Rating {kept} jobs with Claude...")
_rate_module.rate_jobs(
jobs_path=str(tmp / "jobs_raw.json"),
analysis_path=str(tmp / "cv_analysis.json"),
output_path=str(tmp / "jobs_rated.json"),
)
export_excel(
jobs_path=str(tmp / "jobs_rated.json"),
output_path=str(output / "jobs_export.xlsx"),
threshold=config.get("threshold", 70),
)
# Increment usage by number of jobs scraped
try:
import json as _j
jobs_data = _j.loads((tmp / "jobs_rated.json").read_text())
jobs_count = len(jobs_data) if isinstance(jobs_data, list) else 0
user = db_session.query(User).filter(User.id == user_id).first()
if user:
increment_usage(user, db_session, count=jobs_count)
except Exception:
pass
loop.call_soon_threadsafe(queue.put_nowait, "✓ Search complete! Loading results...")
loop.call_soon_threadsafe(queue.put_nowait, "DONE")
except SystemExit:
loop.call_soon_threadsafe(queue.put_nowait, "ERROR: A required step failed — check API keys.")
loop.call_soon_threadsafe(queue.put_nowait, "DONE")
except Exception as e:
loop.call_soon_threadsafe(queue.put_nowait, f"ERROR: {e}")
loop.call_soon_threadsafe(queue.put_nowait, "DONE")
finally:
try:
sys.stdout.flush()
except Exception:
pass
sys.stdout = old_stdout
if user_id in _search_state:
_search_state[user_id]["running"] = False
_search_state[user_id]["done"] = True
try:
db_session.close()
except Exception:
pass
── Manual job entry helpers ──────────────────────────────────────────────────
def parseentry(entry: str) -> dict: """ Parse a single job entry — either a URL or pasted description text. URLs are scraped; plain text is parsed directly. """ entry = entry.strip() if entry.startswith("http://") or entry.startswith("https://") or entry.startswith("www."): jobs = scrapeurlmodule.scrapeurls([entry]) return jobs[0] if jobs else { "title": entry, "company": "Unknown", "location": "", "description": f"Could not scrape: {entry}", "salarymin": None, "salarymax": None, "joburl": entry, "dateposted": "", "source": "Manual URL (failed)", }
# Plain text — use Claude to extract title and company from the description
import anthropic as _anthropic
_client = _anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
try:
msg = _client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=80,
messages=[{
"role": "user",
"content": (
"Extract the job title and company name from this job description. "
"Reply with ONLY: Title | Company\n"
"If company is not mentioned, reply: Title | Unknown\n\n"
f"{entry[:800]}"
)
}]
)
raw = msg.content[0].text.strip()
if "|" in raw:
parts = raw.split("|", 1)
title, company = parts[0].strip(), parts[1].strip()
else:
title, company = raw, "Unknown"
except Exception:
lines = [l.strip() for l in entry.splitlines() if l.strip()]
first_line = lines[0] if lines else "Job"
title, company = first_line, "Unknown"
for sep in [" at ", " | ", " - ", " @ "]:
if sep in first_line:
parts = first_line.split(sep, 1)
title, company = parts[0].strip(), parts[1].strip()
break
return {
"title": title,
"company": company,
"location": "",
"description": entry[:4000],
"salary_min": None, "salary_max": None,
"job_url": "", "date_posted": "",
"source": "Pasted Description",
}
def runjobrating(userid: str, entries: list, tmp: Path, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue): old_stdout = sys.stdout writer = QueueWriter(queue, loop) sys.stdout = io.TextIOWrapper(writer, encoding="utf-8", linebuffering=True)
try:
loop.call_soon_threadsafe(queue.put_nowait, f"── Step 1 / 2: Parsing {len(entries)} job entries...")
jobs = [_parse_entry(e) for e in entries]
valid = [j for j in jobs if j.get("description") and "Could not scrape" not in j.get("description", "")]
if not valid:
valid = jobs # rate all even if scraping failed — at least title/company are there
jobs_raw_path = tmp / "jobs_raw.json"
jobs_raw_path.write_text(json.dumps(valid, indent=2, ensure_ascii=False), encoding="utf-8")
loop.call_soon_threadsafe(queue.put_nowait, f"── Step 2 / 2: Rating {len(valid)} jobs with Claude...")
_rate_module.rate_jobs(
jobs_path=str(jobs_raw_path),
analysis_path=str(tmp / "cv_analysis.json"),
output_path=str(tmp / "jobs_rated.json"),
)
loop.call_soon_threadsafe(queue.put_nowait, "✓ Done! Loading results...")
loop.call_soon_threadsafe(queue.put_nowait, "DONE")
except SystemExit:
loop.call_soon_threadsafe(queue.put_nowait, "ERROR: A required step failed — check API keys.")
loop.call_soon_threadsafe(queue.put_nowait, "DONE")
except Exception as e:
loop.call_soon_threadsafe(queue.put_nowait, f"ERROR: {e}")
loop.call_soon_threadsafe(queue.put_nowait, "DONE")
finally:
try:
sys.stdout.flush()
except Exception:
pass
sys.stdout = old_stdout
if user_id in _search_state:
_search_state[user_id]["running"] = False
_search_state[user_id]["done"] = True
── Routes ────────────────────────────────────────────────────────────────────
@app.apiroute("/", methods=["GET", "HEAD"], responseclass=HTMLResponse) async def root(): return Path("index.html").read_text(encoding="utf-8")
── Profile ───────────────────────────────────────────────────────────────────
@app.get("/api/profile") async def getprofile(currentuser: User = Depends(getpaiduser), db: Session = Depends(getdb)): path = usertmp(currentuser) / "cvanalysis.json" if not path.exists(): return {} return json.loads(path.read_text(encoding="utf-8"))
@app.post("/api/profile") async def saveprofile(request: Request, currentuser: User = Depends(getpaiduser)): data = await request.json() path = usertmp(currentuser) / "cvanalysis.json" path.writetext(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") return {"status": "saved"}
@app.post("/api/profile/enhance") async def enhanceprofile(request: Request, currentuser: User = Depends(getpaiduser)): data = await request.json() result = callclaudeenhance(data) return result or {}
@app.post("/api/profile/enhance-experience") async def enhanceexperience(request: Request, currentuser: User = Depends(getpaiduser)): """ Rewrite raw experience entry responsibilities into strong, outcome-based bullets.
Request body:
{
"experience_entries": [...], // from cv_analysis.json
"apply": true // optional — write improved bullets back to cv_analysis.json
}
Response:
{
"enhancements": [
{
"index": 0,
"title": "...",
"company": "...",
"improved_bullets": [...],
"missing_data": [...],
"follow_up_questions": [...]
},
...
]
}
"""
data = await request.json()
entries = data.get("experience_entries", [])
if not entries:
raise HTTPException(status_code=400, detail="experience_entries is required and must not be empty")
try:
enhancements = await asyncio.get_event_loop().run_in_executor(
None, enhance_entries, entries
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Enhancement failed: {_safe_error(e)}")
# Optionally write improved bullets back into the user's cv_analysis.json
if data.get("apply"):
tmp = user_tmp(current_user)
analysis_path = tmp / "cv_analysis.json"
if analysis_path.exists():
analysis = json.loads(analysis_path.read_text(encoding="utf-8"))
updated = apply_enhancements(analysis, enhancements)
analysis_path.write_text(json.dumps(updated, indent=2, ensure_ascii=False), encoding="utf-8")
return {"enhancements": enhancements}
── CV upload ────────────────────────────────────────────────────────────────
@app.post("/api/upload/cv") async def uploadcv(file: UploadFile = File(...), currentuser: User = Depends(getpaiduser)): """ Upload a CV (PDF or DOCX). Extracts text, structures it with Claude, and saves to cvanalysis.json. Returns the structured profile + weakbullets list.
Response:
{
"profile": { ...cv_analysis.json fields... },
"weak_bullets": [{"entry_index", "bullet", "reason"}, ...],
"filename": "cv.pdf"
}
"""
suffix = Path(file.filename or "").suffix.lower()
if suffix not in {".pdf", ".docx"}:
raise HTTPException(400, "Only PDF and DOCX files are supported.")
contents = await file.read()
if len(contents) > MAX_CV_SIZE_BYTES:
raise HTTPException(413, "CV file is too large. Maximum size is 10 MB.")
tmp = user_tmp(current_user)
dest = tmp / f"cv_upload{suffix}"
dest.write_bytes(contents)
# Extract raw text
try:
if suffix == ".pdf":
raw_text = extract_pdf(dest)
else:
raw_text = extract_docx(dest)
except Exception as e:
raise HTTPException(422, f"Could not read file: {_safe_error(e)}")
if not raw_text or len(raw_text.strip()) < 50:
raise HTTPException(422, "CV text extraction returned almost nothing. "
"If this is a scanned PDF, convert it to text first.")
# Structure with Claude (run in thread — blocking I/O)
try:
structured = await asyncio.get_event_loop().run_in_executor(
None, structure_cv_text, raw_text
)
except Exception as e:
raise HTTPException(500, f"CV structuring failed: {_safe_error(e)}")
# Separate weak_bullets from the profile before saving
weak_bullets = structured.pop("_weak_bullets", [])
# Save as cv_analysis.json (overwrites any existing profile)
analysis_path = tmp / "cv_analysis.json"
analysis_path.write_text(json.dumps(structured, indent=2, ensure_ascii=False), encoding="utf-8")
# Save original sections snapshot for before/after comparison
original_sections = {
"summary": structured.get("professional_summary", ""),
"experience": [
{
"company": e.get("company", ""),
"location": e.get("location", ""),
"title": e.get("title", ""),
"dates": e.get("duration", ""),
"bullets": e.get("responsibilities", []),
}
for e in structured.get("experience_entries", [])
],
"skills": structured.get("core_skills", []),
"education": [
f"{e.get('degree','')} — {e.get('institution','')} ({e.get('year','')})"
for e in structured.get("education", [])
],
}
(tmp / "original_sections.json").write_text(
json.dumps(original_sections, indent=2, ensure_ascii=False), encoding="utf-8"
)
return {
"profile": structured,
"weak_bullets": weak_bullets,
"filename": file.filename,
}
── Photo upload ──────────────────────────────────────────────────────────────
@app.post("/api/upload/photo") async def uploadphoto(file: UploadFile = File(...), currentuser: User = Depends(getpaiduser)): suffix = Path(file.filename or "").suffix.lower() if suffix not in ALLOWEDPHOTOEXTENSIONS: raise HTTPException(400, f"Only image files are supported: {', '.join(sorted(ALLOWEDPHOTOEXTENSIONS))}")
contents = await file.read()
if len(contents) > MAX_PHOTO_SIZE_BYTES:
raise HTTPException(413, "Photo is too large. Maximum size is 5 MB.")
if not _is_valid_image(contents):
raise HTTPException(400, "File does not appear to be a valid image.")
tmp = user_tmp(current_user)
dest = tmp / f"photo_upload{suffix}"
dest.write_bytes(contents)
session_path = tmp / "session.json"
session = json.loads(session_path.read_text(encoding="utf-8")) if session_path.exists() else {}
session["photo_path"] = str(dest)
session_path.write_text(json.dumps(session, indent=2), encoding="utf-8")
return {"photo_path": str(dest), "filename": Path(file.filename or "photo").name}
── Search ────────────────────────────────────────────────────────────────────
@app.post("/api/search/start") async def startsearch(request: Request, currentuser: User = Depends(getpaiduser), db: Session = Depends(getdb)): uid = currentuser.id
# Block if another search is already running for this user
state = _search_state.get(uid, {})
if state.get("running"):
raise HTTPException(status_code=409, detail="Search already running")
# Check plan limit before starting
check_and_reset_usage(current_user, db)
config = await request.json()
tmp = user_tmp(current_user)
output = user_output(current_user)
(tmp / "session.json").write_text(json.dumps(config, indent=2), encoding="utf-8")
loop = asyncio.get_running_loop()
queue = asyncio.Queue()
_search_state[uid] = {"running": True, "done": False, "cancelled": False, "queue": queue, "loop": loop}
# Open a fresh DB session for the background thread
from db import SessionLocal
thread_db = SessionLocal()
thread = threading.Thread(
target=_run_search,
args=(uid, config, tmp, output, loop, queue, thread_db),
daemon=True,
)
thread.start()
return {"status": "started"}
@app.post("/api/search/cancel") async def cancelsearch(currentuser: User = Depends(getpaiduser)): uid = current_user.id if uid in searchstate: searchstate[uid]["cancelled"] = True return {"status": "cancelling"}
@app.get("/api/search/progress") async def searchprogress(request: Request, token: str = None): # EventSource can't set headers, so we accept the token as a query param from auth import JWTSECRET, JWTALGORITHM from jose import jwt as jwt, JWTError from db import SessionLocal uid = None if token: try: payload = jwt.decode(token, JWTSECRET, algorithms=[JWTALGORITHM]) uid = payload.get("sub") except (JWTError, Exception): pass if not uid: async def denied(): yield "data: Unauthorized\n\n" return StreamingResponse(denied(), mediatype="text/event-stream")
state = _search_state.get(uid)
async def event_stream() -> AsyncGenerator[str, None]:
if state is None:
yield "data: No search running\n\n"
return
queue = state["queue"]
while True:
try:
msg = await asyncio.wait_for(queue.get(), timeout=30)
yield f"data: {msg}\n\n"
if msg == "DONE":
break
except asyncio.TimeoutError:
yield "data: .\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.post("/api/quick-apply") async def quickapply(request: Request, currentuser: User = Depends(getpaiduser)): """ One-shot pipeline for a single job URL: scrape → rate → generate CV + cover letter → compile PDF + DOCX → score ATS → auto-mark Applied
Uses the same SSE progress stream as the full search.
Final message: QUICK_DONE:{json} with {folder, files, job_index, score}
"""
data = await request.json()
url = data.get("url", "").strip()
if not url:
raise HTTPException(400, "url is required")
if not (url.startswith("http://") or url.startswith("https://") or url.startswith("www.")):
raise HTTPException(400, "Must be a full URL (starting with https://)")
uid = current_user.id
tmp = user_tmp(current_user)
output = user_output(current_user)
analysis_path = tmp / "cv_analysis.json"
if not analysis_path.exists():
raise HTTPException(404, "Complete your profile first.")
if _search_state.get(uid, {}).get("running"):
raise HTTPException(409, "Another operation is running — wait for it to finish.")
session_path = tmp / "session.json"
session = json.loads(session_path.read_text(encoding="utf-8")) if session_path.exists() else {}
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
_search_state[uid] = {"running": True, "done": False, "cancelled": False, "queue": queue, "loop": loop}
threading.Thread(
target=_run_quick_apply,
args=(uid, url, tmp, output, session, loop, queue),
daemon=True,
).start()
return {"status": "started"}
def runquickapply(userid: str, url: str, tmp: Path, output: Path, session: dict, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue): """Background thread: full pipeline for a single job URL.""" old_stdout = sys.stdout writer = QueueWriter(queue, loop) sys.stdout = io.TextIOWrapper(writer, encoding="utf-8", linebuffering=True)
def emit(msg: str):
loop.call_soon_threadsafe(queue.put_nowait, msg)
try:
from urllib.parse import urlparse
domain = urlparse(url).netloc.replace("www.", "")
# ── Step 1: Scrape ──────────────────────────────────────────────────
emit(f"STEP:1:Extracting job from {domain}…")
job = _parse_entry(url)
if not job.get("description") or "Could not scrape" in job.get("description", ""):
emit("ERROR: Could not extract job description from that URL. Try pasting the description instead.")
emit("DONE")
return
emit(f"✓ Found: {job.get('title', '')} at {job.get('company', '')}")
# ── Step 2: Rate ───────────────────────────────────────────────────
emit("STEP:2:Scoring your fit with Claude AI…")
jobs_raw_path = tmp / "jobs_raw_quick.json"
jobs_raw_path.write_text(json.dumps([job], indent=2, ensure_ascii=False), encoding="utf-8")
_rate_module.rate_jobs(
jobs_path=str(jobs_raw_path),
analysis_path=str(tmp / "cv_analysis.json"),
output_path=str(tmp / "jobs_quick_rated.json"),
)
rated_jobs = json.loads((tmp / "jobs_quick_rated.json").read_text(encoding="utf-8"))
rated_job = rated_jobs[0] if rated_jobs else job
emit(f"✓ Fit score: {rated_job.get('rating', '?')}/100 — {rated_job.get('fit_level', '')}")
# Append to main jobs_rated.json (or create it)
jobs_rated_path = tmp / "jobs_rated.json"
all_jobs = json.loads(jobs_rated_path.read_text(encoding="utf-8")) if jobs_rated_path.exists() else []
all_jobs.append(rated_job)
jobs_rated_path.write_text(json.dumps(all_jobs, indent=2, ensure_ascii=False), encoding="utf-8")
job_index = len(all_jobs) - 1
# ── Step 3: Generate CV ────────────────────────────────────────────
emit("STEP:3:Crafting your tailored CV…")
analysis = json.loads((tmp / "cv_analysis.json").read_text(encoding="utf-8"))
# Template is always 1.tex — the only implemented template
template_path = str(TEMPLATES / "1.tex")
photo_path = session.get("photo_path")
company = re.sub(r"[^\w\s-]", "", rated_job.get("company", "Company")).replace(" ", "_")
title = re.sub(r"[^\w\s-]", "", rated_job.get("title", "Role")).replace(" ", "_")
folder_name = f"{company}_{title}"
out_dir = output / folder_name
generate_latex_cv(rated_job, analysis, template_path, str(out_dir),
photo_path=photo_path, template_filename="1.tex")
emit("✓ CV generated")
# ── Step 4: Cover letter ───────────────────────────────────────────
emit("STEP:4:Writing your cover letter…")
cover_template = str(TEMPLATES / "cover_letter_template.tex")
if Path(cover_template).exists():
generate_cover_letter(rated_job, analysis, cover_template, str(out_dir))
emit("✓ Cover letter written")
# ── Step 5: Compile + fallback chain ──────────────────────────────
emit("STEP:5:Compiling documents…")
cv_tex_path = out_dir / "cv.tex"
for tex in [out_dir / "cv.tex", out_dir / "cover_letter.tex"]:
if tex.exists():
try:
result = compile_latex(str(tex))
if result.get("fallback_used"):
fmt = result.get("format")
if fmt == "tex":
emit(f" ⚠ {tex.name}: PDF/DOCX compile failed — .tex available for Overleaf")
else:
emit(f" ℹ {tex.name}: generated as {fmt.upper()} (PDF unavailable on this server)")
except Exception as e:
emit(f" (compile warning: {e})")
# Always generate DOCX from cv.tex (ATS-safe, independent of PDF)
if cv_tex_path.exists():
try:
secs = extract_sections(cv_tex_path.read_text(encoding="utf-8"))
secs.pop("raw_tex", None)
generate_docx_cv(secs, analysis, str(out_dir / "cv.docx"))
except Exception as e:
emit(f" (DOCX warning: {e})")
emit("✓ Documents ready")
# ── Step 6: ATS Score ──────────────────────────────────────────────
emit("STEP:6:Scoring ATS compatibility…")
ats_score = None
if cv_tex_path.exists():
try:
ats_score = score_cv(cv_tex_path.read_text(encoding="utf-8"),
rated_job, analysis.get("candidate_name", ""))
s = ats_score.get("overall_score", 0)
verdict = ats_score.get("verdict", "")
emit(f"✓ ATS score: {s}/100 — {verdict}")
score_cache = {
"overall_score": ats_score.get("overall_score"),
"keyword_coverage_pct": ats_score.get("keyword_coverage_pct"),
}
(out_dir / "ats_score.json").write_text(
json.dumps(score_cache, indent=2), encoding="utf-8"
)
except Exception as e:
emit(f" (ATS scoring warning: {e})")
# ── Auto-mark Applied ──────────────────────────────────────────────
tracking = _load_tracking(tmp)
entry = tracking.get(str(job_index), {})
if entry.get("status", "Not Applied") == "Not Applied":
now = datetime.utcnow().isoformat(timespec="seconds") + "Z"
entry.update({"status": "Applied", "applied_date": now, "last_update": now})
tracking[str(job_index)] = entry
_save_tracking(tmp, tracking)
# ── Done ───────────────────────────────────────────────────────────
files = sorted(f.name for f in out_dir.iterdir() if f.suffix in (".pdf", ".tex", ".docx"))
# folder key = user_id/folder_name — matches /api/download/{user_id}/{folder}/{filename}
folder_key = f"{user_id}/{folder_name}"
result = {
"folder": folder_key,
"files": files,
"job_index": job_index,
"job": rated_job,
"score": ats_score,
}
emit(f"QUICK_DONE:{json.dumps(result)}")
except Exception as e:
emit(f"ERROR: {e}")
emit("DONE")
finally:
sys.stdout = old_stdout
if user_id in _search_state:
_search_state[user_id]["running"] = False
_search_state[user_id]["done"] = True
@app.post("/api/rate-jobs") async def ratejobsmanual(request: Request, currentuser: User = Depends(getpaid_user)): data = await request.json() entries = [e.strip() for e in data.get("entries", []) if e.strip()][:10] if not entries: raise HTTPException(400, "No job entries provided.")
uid = current_user.id
tmp = user_tmp(current_user)
analysis_path = tmp / "cv_analysis.json"
if not analysis_path.exists():
raise HTTPException(404, "No profile found. Complete and save your profile first.")
if _search_state.get(uid, {}).get("running"):
raise HTTPException(409, "A search is already running.")
loop = asyncio.get_event_loop()
queue: asyncio.Queue = asyncio.Queue()
_search_state[uid] = {"running": True, "done": False, "cancelled": False, "queue": queue}
threading.Thread(
target=_run_job_rating,
args=(uid, entries, tmp, loop, queue),
daemon=True,
).start()
return {"status": "started", "count": len(entries)}
@app.get("/api/jobs") async def getjobs(currentuser: User = Depends(getpaiduser)): path = usertmp(currentuser) / "jobsrated.json" if not path.exists(): return [] return json.loads(path.readtext(encoding="utf-8"))
── Job Application Tracker ───────────────────────────────────────────────────
VALID_STATUSES = {"Not Applied", "Applied", "Ghosted", "Screening", "Interview", "Offer", "Rejected"}
def loadtracking(tmp: Path) -> dict: """Load tracking.json. Returns dict keyed by jobindex string.""" path = tmp / "tracking.json" if path.exists(): return json.loads(path.readtext(encoding="utf-8")) return {}
def savetracking(tmp: Path, tracking: dict): path = tmp / "tracking.json" path.writetext(json.dumps(tracking, indent=2, ensureascii=False), encoding="utf-8")
@app.get("/api/tracker") async def gettracker(currentuser: User = Depends(getpaiduser)): """ Returns all tracking entries merged with job metadata. Response: list of {jobindex, company, title, location, rating, status, applieddate, lastupdate, notes} """ tmp = usertmp(currentuser) jobspath = tmp / "jobsrated.json" jobs = json.loads(jobspath.readtext(encoding="utf-8")) if jobspath.exists() else [] tracking = loadtracking(tmp)
result = []
for i, job in enumerate(jobs):
t = tracking.get(str(i), {})
result.append({
"job_index": i,
"company": job.get("company", ""),
"title": job.get("title", ""),
"location": job.get("location", ""),
"rating": job.get("rating", 0),
"status": t.get("status", "Not Applied"),
"applied_date": t.get("applied_date"),
"last_update": t.get("last_update"),
"notes": t.get("notes", ""),
})
return result
@app.patch("/api/tracker/{jobindex}") async def updatetracker(jobindex: int, request: Request, currentuser: User = Depends(getpaiduser)): """ Update status and/or notes for a job. Request body: { "status"?: str, "notes"?: str } """ body = await request.json() newstatus = body.get("status") newnotes = body.get("notes")
if new_status and new_status not in VALID_STATUSES:
raise HTTPException(400, f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}")
tmp = user_tmp(current_user)
jobs_path = tmp / "jobs_rated.json"
jobs = json.loads(jobs_path.read_text(encoding="utf-8")) if jobs_path.exists() else []
if job_index < 0 or job_index >= len(jobs):
raise HTTPException(404, f"Job index {job_index} out of range.")
tracking = _load_tracking(tmp)
entry = tracking.get(str(job_index), {})
now = datetime.utcnow().isoformat(timespec="seconds") + "Z"
if new_status is not None:
prev_status = entry.get("status", "Not Applied")
entry["status"] = new_status
entry["last_update"] = now
# Auto-set applied_date when first marking as Applied
if new_status == "Applied" and prev_status == "Not Applied" and not entry.get("applied_date"):
entry["applied_date"] = now
if new_notes is not None:
entry["notes"] = new_notes
entry["last_update"] = now
tracking[str(job_index)] = entry
_save_tracking(tmp, tracking)
return entry
@app.get("/api/tracker/summary") async def gettrackersummary(currentuser: User = Depends(getpaiduser)): """ Aggregate insights: counts per status, response rate, interview rate, ghost rate. """ tmp = usertmp(currentuser) jobspath = tmp / "jobsrated.json" totaljobs = 0 if jobspath.exists(): totaljobs = len(json.loads(jobspath.readtext(encoding="utf-8")))
tracking = _load_tracking(tmp)
counts = {s: 0 for s in VALID_STATUSES}
for entry in tracking.values():
s = entry.get("status", "Not Applied")
if s in counts:
counts[s] += 1
applied = counts["Applied"] + counts["Ghosted"] + counts["Screening"] + \
counts["Interview"] + counts["Offer"] + counts["Rejected"]
responded = counts["Screening"] + counts["Interview"] + counts["Offer"] + counts["Rejected"]
interviews = counts["Interview"] + counts["Offer"]
ghosts = counts["Ghosted"]
return {
"total_jobs": total_jobs,
"applied": applied,
"not_applied": counts["Not Applied"],
"counts": counts,
"response_rate": round(responded / applied * 100) if applied else 0,
"interview_rate": round(interviews / applied * 100) if applied else 0,
"ghost_rate": round(ghosts / applied * 100) if applied else 0,
}
@app.get("/api/jobs/{jobindex}/keyword-gap") async def getkeywordgap(jobindex: int, currentuser: User = Depends(getpaid_user)): """ Deep ATS keyword gap analysis for a specific job vs. the candidate's profile.
Returns:
{
"coverage_score": int,
"present": [{"keyword", "required", "matched_as", "is_synonym"}, ...],
"claimable": [{"keyword", "required", "suggestion"}, ...],
"hard_gaps": [{"keyword", "required"}, ...],
"synonyms": [{"jd_term", "profile_term", "recommendation"}, ...],
"top_10_must_use": [str, ...]
}
"""
tmp = user_tmp(current_user)
jobs_path = tmp / "jobs_rated.json"
analysis_path = tmp / "cv_analysis.json"
if not jobs_path.exists():
raise HTTPException(404, "No jobs found. Run a search first.")
if not analysis_path.exists():
raise HTTPException(404, "No profile found. Complete your profile first.")
jobs = json.loads(jobs_path.read_text(encoding="utf-8"))
if job_index < 0 or job_index >= len(jobs):
raise HTTPException(404, f"Job index {job_index} out of range.")
job = jobs[job_index]
analysis = json.loads(analysis_path.read_text(encoding="utf-8"))
try:
result = await asyncio.get_event_loop().run_in_executor(
None, analyze_keyword_gap, job, analysis
)
except Exception as e:
raise HTTPException(500, f"Keyword gap analysis failed: {_safe_error(e)}")
return result
@app.get("/api/jobs/{jobindex}/score-cv") async def getcvscore(jobindex: int, currentuser: User = Depends(getpaiduser)): """ Evaluate the generated cv.tex for this job against the job description. Returns a structured quality score with weak points and fix suggestions. The cv.tex must already exist (user must have clicked Generate CV first). """ tmp = usertmp(currentuser) output = useroutput(currentuser) jobspath = tmp / "jobsrated.json" analysispath = tmp / "cv_analysis.json"
if not jobs_path.exists():
raise HTTPException(404, "No jobs found. Run a search first.")
jobs = json.loads(jobs_path.read_text(encoding="utf-8"))
if job_index < 0 or job_index >= len(jobs):
raise HTTPException(404, f"Job index {job_index} out of range.")
job = jobs[job_index]
company = re.sub(r"[^\w\s-]", "", job.get("company", "Company")).replace(" ", "_")
title = re.sub(r"[^\w\s-]", "", job.get("title", "Role")).replace(" ", "_")
folder = output / f"{company}_{title}"
cv_tex = folder / "cv.tex"
if not cv_tex.exists():
raise HTTPException(404, "CV not generated yet. Click 'Generate CV' first.")
candidate_name = ""
if analysis_path.exists():
analysis = json.loads(analysis_path.read_text(encoding="utf-8"))
candidate_name = analysis.get("candidate_name", "")
cv_content = cv_tex.read_text(encoding="utf-8")
try:
result = await asyncio.get_event_loop().run_in_executor(
None, score_cv, cv_content, job, candidate_name
)
except Exception as e:
raise HTTPException(500, f"CV scoring failed: {_safe_error(e)}")
return result
@app.get("/api/jobs/{jobindex}/comparison") async def getcvcomparison(jobindex: int, currentuser = Depends(getpaid_user)): """ Before vs After CV comparison for a specific job. Requires: CV was uploaded (or profile was saved) AND CV was generated.
Response:
{
"before": { "sections": {...}, "score": {...} },
"after": { "sections": {...}, "score": {...} },
"delta": { "overall": +N, "keyword_match": +N, "impact": +N, "ats": +N, "clarity": +N }
}
"""
tmp = user_tmp(current_user)
output = user_output(current_user)
jobs_path = tmp / "jobs_rated.json"
analysis_path = tmp / "cv_analysis.json"
original_path = tmp / "original_sections.json"
if not jobs_path.exists():
raise HTTPException(404, "No jobs found.")
jobs = json.loads(jobs_path.read_text(encoding="utf-8"))
if job_index < 0 or job_index >= len(jobs):
raise HTTPException(404, f"Job index {job_index} out of range.")
job = jobs[job_index]
company = re.sub(r"[^\w\s-]", "", job.get("company", "Company")).replace(" ", "_")
title = re.sub(r"[^\w\s-]", "", job.get("title", "Role")).replace(" ", "_")
cv_tex = output / f"{company}_{title}" / "cv.tex"
if not cv_tex.exists():
raise HTTPException(404, "CV not generated yet. Click 'Generate CV' first.")
analysis = json.loads(analysis_path.read_text(encoding="utf-8")) if analysis_path.exists() else {}
candidate_name = analysis.get("candidate_name", "")
# ── Before sections ───────────────────────────────────────────────────────
if original_path.exists():
before_sections = json.loads(original_path.read_text(encoding="utf-8"))
else:
# Reconstruct from cv_analysis.json (manual profile entry — no upload)
before_sections = {
"summary": analysis.get("professional_summary", ""),
"experience": [
{
"company": e.get("company", ""),
"location": e.get("location", ""),
"title": e.get("title", ""),
"dates": e.get("duration", ""),
"bullets": e.get("original_responsibilities") or e.get("responsibilities", []),
}
for e in analysis.get("experience_entries", [])
],
"skills": analysis.get("core_skills", []),
"education": [
f"{e.get('degree','')} — {e.get('institution','')} ({e.get('year','')})"
for e in analysis.get("education", [])
],
}
# ── After sections ────────────────────────────────────────────────────────
after_tex = cv_tex.read_text(encoding="utf-8")
after_sections = extract_sections(after_tex)
after_sections.pop("raw_tex", None)
# ── Build plain-text "before CV" for scoring ──────────────────────────────
before_text_parts = []
if before_sections.get("summary"):
before_text_parts.append(f"SUMMARY\n{before_sections['summary']}")
for role in before_sections.get("experience", []):
header = f"{role.get('title','')} at {role.get('company','')} ({role.get('dates','')})"
bullets = "\n".join(f"- {b}" for b in role.get("bullets", []))
before_text_parts.append(f"{header}\n{bullets}")
if before_sections.get("skills"):
before_text_parts.append("SKILLS\n" + "\n".join(before_sections["skills"]))
before_text = "\n\n".join(before_text_parts)
# ── Before score: use the rating from Rate These Jobs ────────────────────
job_rating = job.get("rating", 50)
def _label(s):
if s >= 90: return "Excellent"
if s >= 70: return "Strong"
if s >= 50: return "Good"
if s >= 30: return "Weak"
return "Poor"
before_score = {
"overall_score": job_rating,
"grades": {
"keyword_match": {"score": job_rating, "label": _label(job_rating)},
"impact": {"score": job_rating, "label": _label(job_rating)},
"role_relevance": {"score": job_rating, "label": _label(job_rating)},
"ats_compatibility": {"score": job_rating, "label": _label(job_rating)},
"clarity": {"score": job_rating, "label": _label(job_rating)},
},
"verdict": job.get("fit_level", ""),
}
# ── After score ───────────────────────────────────────────────────────────
after_score = await asyncio.get_event_loop().run_in_executor(
None, score_cv, after_tex, job, candidate_name
)
# ── Delta ─────────────────────────────────────────────────────────────────
def _delta(key):
b = before_score.get("grades", {}).get(key, {}).get("score", 0)
a = after_score.get("grades", {}).get(key, {}).get("score", 0)
return a - b
delta = {
"overall": after_score.get("overall_score", 0) - before_score.get("overall_score", 0),
"keyword_match": _delta("keyword_match"),
"impact": _delta("impact"),
"role_relevance": _delta("role_relevance"),
"ats": _delta("ats_compatibility"),
"clarity": _delta("clarity"),
}
return {
"before": {"sections": before_sections, "score": before_score},
"after": {"sections": after_sections, "score": after_score},
"delta": delta,
}
── CV section preview + editing ─────────────────────────────────────────────
def getcvtex(jobindex: int, currentuser) -> tuple: """Helper: load job + cv.tex for a given job index. Returns (job, cvtexpath, texcontent).""" tmp = usertmp(currentuser) output = useroutput(currentuser) jobspath = tmp / "jobsrated.json" if not jobspath.exists(): raise HTTPException(404, "No jobs found. Run a search first.") jobs = json.loads(jobspath.readtext(encoding="utf-8")) if jobindex < 0 or jobindex >= len(jobs): raise HTTPException(404, f"Job index {jobindex} out of range.") job = jobs[jobindex] company = re.sub(r"[^\w\s-]", "", job.get("company", "Company")).replace(" ", "") title = re.sub(r"[^\w\s-]", "", job.get("title", "Role")).replace(" ", "") cvtex = output / f"{company}{title}" / "cv.tex" if not cvtex.exists(): raise HTTPException(404, "CV not generated yet. Click 'Generate CV' first.") return job, cvtex, cvtex.read_text(encoding="utf-8")
@app.get("/api/jobs/{jobindex}/cv-sections") async def getcvsections(jobindex: int, currentuser = Depends(getpaid_user)): """ Extract readable sections from the generated cv.tex. No API cost — pure regex extraction.
Response: { summary, experience: [{company, title, dates, bullets}], skills, education }
"""
_, _, tex = _get_cv_tex(job_index, current_user)
sections = extract_sections(tex)
sections.pop("raw_tex", None) # don't send the full tex to the frontend
return sections
@app.post("/api/jobs/{jobindex}/cv-sections/regenerate") async def regeneratecvsection(jobindex: int, request: Request, currentuser = Depends(getpaid_user)): """ Regenerate one section of the CV with Claude.
Request body: { "section": "summary"|"experience"|"skills", "role_index": 0 }
Response: { "sections": {...updated sections...} }
The updated cv.tex is saved automatically.
"""
body = await request.json()
section = body.get("section", "")
role_index = int(body.get("role_index", 0))
if section not in ("summary", "experience", "skills"):
raise HTTPException(400, "section must be: summary, experience, or skills")
job, cv_tex_path, tex = _get_cv_tex(job_index, current_user)
tmp = user_tmp(current_user)
analysis_path = tmp / "cv_analysis.json"
analysis = json.loads(analysis_path.read_text(encoding="utf-8")) if analysis_path.exists() else {}
try:
new_tex = await asyncio.get_event_loop().run_in_executor(
None, regenerate_section, section, tex, job, analysis, role_index
)
except Exception as e:
raise HTTPException(500, f"Regeneration failed: {_safe_error(e)}")
cv_tex_path.write_text(new_tex, encoding="utf-8")
sections = extract_sections(new_tex)
sections.pop("raw_tex", None)
return {"sections": sections}
@app.post("/api/jobs/{jobindex}/cv-sections/apply-edit") async def applycvsectionedit(jobindex: int, request: Request, currentuser = Depends(getpaiduser)): """ Apply a user's manual plain-text edit to one section, then recompile the PDF.
Request body: { "section": "summary"|"experience"|"skills", "edited_text": "..." }
Response: { "sections": {...updated sections...}, "files": [...] }
"""
body = await request.json()
section = body.get("section", "")
edited_text = body.get("edited_text", "").strip()
if section not in ("summary", "experience", "skills"):
raise HTTPException(400, "section must be: summary, experience, or skills")
if not edited_text:
raise HTTPException(400, "edited_text is required")
job, cv_tex_path, tex = _get_cv_tex(job_index, current_user)
try:
new_tex = await asyncio.get_event_loop().run_in_executor(
None, apply_manual_edit, section, edited_text, tex
)
except Exception as e:
raise HTTPException(500, f"Edit application failed: {_safe_error(e)}")
cv_tex_path.write_text(new_tex, encoding="utf-8")
sections = extract_sections(new_tex)
sections.pop("raw_tex", None)
# Recompile PDF
try:
compile_latex(str(cv_tex_path))
except Exception:
pass # compilation failure is non-fatal — tex is saved
# Regenerate DOCX to match updated content
tmp = user_tmp(current_user)
analysis_path = tmp / "cv_analysis.json"
edit_analysis = json.loads(analysis_path.read_text(encoding="utf-8")) if analysis_path.exists() else {}
try:
generate_docx_cv(sections, edit_analysis, str(cv_tex_path.parent / "cv.docx"))
except Exception as e:
print(f"[apply-edit] DOCX regeneration failed (non-fatal): {e}")
out_dir = cv_tex_path.parent
files = sorted(f.name for f in out_dir.iterdir() if f.suffix in (".pdf", ".tex", ".docx"))
folder = f"{current_user.id}/{out_dir.name}"
return {"sections": sections, "files": files, "folder": folder}
── Document generation ───────────────────────────────────────────────────────
@app.get("/api/gen-usage") async def getgenerationusage(currentuser: User = Depends(getpaiduser), db: Session = Depends(getdb)): """Return current CV/CL generation usage for the logged-in user.""" return getgenusage(current_user, db)
@app.post("/api/generate/{jobindex}") async def generatedocs(jobindex: int, currentuser: User = Depends(getpaiduser), db: Session = Depends(getdb)): tmp = usertmp(currentuser) output = useroutput(currentuser) jobspath = tmp / "jobsrated.json" analysispath = tmp / "cvanalysis.json" sessionpath = tmp / "session.json"
if not jobs_path.exists():
raise HTTPException(404, "No jobs found. Run a search first.")
if not analysis_path.exists():
raise HTTPException(404, "No profile found. Complete your profile first.")
# Enforce generation limits before doing any work
check_gen_limit(current_user, db, "cv")
check_gen_limit(current_user, db, "cl")
increment_gen_count(current_user, db, "cv")
increment_gen_count(current_user, db, "cl")
jobs = json.loads(jobs_path.read_text(encoding="utf-8"))
if job_index >= len(jobs):
raise HTTPException(404, f"Job index {job_index} out of range.")
analysis = json.loads(analysis_path.read_text(encoding="utf-8"))
session = json.loads(session_path.read_text(encoding="utf-8")) if session_path.exists() else {}
job = jobs[job_index]
company = re.sub(r"[^\w\s-]", "", job.get("company", "Company")).replace(" ", "_")
title = re.sub(r"[^\w\s-]", "", job.get("title", "Role")).replace(" ", "_")
folder_name = f"{company}_{title}"
out_dir = output / folder_name
# Template is always 1.tex — the only implemented template
template_path = str(TEMPLATES / "1.tex")
photo_path = session.get("photo_path")
generate_latex_cv(
job, analysis, template_path, str(out_dir),
photo_path=photo_path, template_filename="1.tex"
)
cover_template = str(TEMPLATES / "cover_letter_template.tex")
if Path(cover_template).exists():
generate_cover_letter(job, analysis, cover_template, str(out_dir))
# ── Compile + fallback chain — always produce at least one downloadable file ──
cv_tex_path = out_dir / "cv.tex"
compile_results = {} # {"cv": result_dict, "cover_letter": result_dict}
for tex in [out_dir / "cv.tex", out_dir / "cover_letter.tex"]:
if tex.exists():
try:
result = compile_latex(str(tex))
compile_results[tex.stem] = result
if result["fallback_used"]:
print(f"[generate] Fallback used for {tex.name}: {result.get('fallback_reason','')[:120]}")
except Exception as e:
compile_results[tex.stem] = {
"success": False, "output_path": None, "format": None,
"error": str(e), "fallback_used": False, "fallback_reason": None,
}
print(f"[generate] Compile exception for {tex.name}: {e}")
# ── Always generate DOCX from cv.tex (ATS-safe copy, independent of PDF) ─
if cv_tex_path.exists():
try:
sections = extract_sections(cv_tex_path.read_text(encoding="utf-8"))
sections.pop("raw_tex", None)
generate_docx_cv(sections, analysis, str(out_dir / "cv.docx"))
except Exception as e:
print(f"[generate] DOCX generation failed (non-fatal): {e}")
files = sorted(f.name for f in out_dir.iterdir() if f.suffix in (".pdf", ".tex", ".docx"))
# Auto-mark as Applied in tracker (only if currently Not Applied)
tracking = _load_tracking(tmp)
entry = tracking.get(str(job_index), {})
if entry.get("status", "Not Applied") == "Not Applied":
now = datetime.utcnow().isoformat(timespec="seconds") + "Z"
entry["status"] = "Applied"
entry["applied_date"] = now
entry["last_update"] = now
tracking[str(job_index)] = entry
_save_tracking(tmp, tracking)
# Score the CV immediately so the UI can show the result without a second request
ats_score = None
if cv_tex_path.exists():
try:
candidate_name = analysis.get("candidate_name", "")
ats_score = await asyncio.get_event_loop().run_in_executor(
None, score_cv, cv_tex_path.read_text(encoding="utf-8"), job, candidate_name
)
if ats_score:
score_cache = {
"overall_score": ats_score.get("overall_score"),
"keyword_coverage_pct": ats_score.get("keyword_coverage_pct"),
}
(out_dir / "ats_score.json").write_text(
json.dumps(score_cache, indent=2), encoding="utf-8"
)
except Exception as e:
print(f"[generate] ATS scoring failed (non-fatal): {e}")
# Build fallback banner info for the frontend
cv_compile = compile_results.get("cv", {})
fallback_info = None
if cv_compile.get("fallback_used"):
fmt = cv_compile.get("format")
if fmt == "docx":
fallback_info = {"type": "docx_fallback", "message": "PDF compilation unavailable — your CV has been generated as a DOCX instead."}
elif fmt == "tex":
fallback_info = {"type": "tex_fallback", "message": cv_compile.get("tex_message", "Download the .tex file and open it at overleaf.com for a free instant PDF.")}
# Load inferred metrics list (written by generate_latex_cv)
inferred_metrics = []
inferred_path = out_dir / "cv_inferred.json"
if inferred_path.exists():
try:
inferred_metrics = json.loads(inferred_path.read_text(encoding="utf-8")).get("inferred_metrics", [])
except Exception:
pass
return {
"folder": f"{current_user.id}/{folder_name}",
"files": files,
"score": ats_score,
"compile_results": compile_results,
"fallback_info": fallback_info,
"inferred_metrics": inferred_metrics,
"gen_usage": get_gen_usage(current_user, db),
}
@app.get("/api/download/{userid}/{folder}/{filename}") async def downloadfile(userid: str, folder: str, filename: str, currentuser: User = Depends(getpaiduser)): # Users can only download their own files if userid != currentuser.id: raise HTTPException(403, "Access denied.") path = OUTPUT / Path(user_id).name / Path(folder).name / Path(filename).name if not path.exists(): raise HTTPException(404, "File not found") return FileResponse(str(path), filename=Path(filename).name)
@app.get("/api/export") async def getexcel(currentuser: User = Depends(getpaiduser)): """ Regenerate jobsexport.xlsx on-demand with live tracking + ATS score data, then stream the file to the client. """ tmp = usertmp(currentuser) output = useroutput(currentuser) jobspath = tmp / "jobs_rated.json"
if not jobs_path.exists():
raise HTTPException(404, "Run a search first to generate the Excel export.")
tracking = _load_tracking(tmp)
out_path = output / "jobs_export.xlsx"
# Collect ATS scores from any generated cv.tex folders
ats_scores: dict = {}
jobs = json.loads(jobs_path.read_text(encoding="utf-8"))
for i, job in enumerate(jobs):
company = re.sub(r"[^\w\s-]", "", job.get("company", "Company")).replace(" ", "_")
title = re.sub(r"[^\w\s-]", "", job.get("title", "Role")).replace(" ", "_")
score_path = output / f"{company}_{title}" / "ats_score.json"
if score_path.exists():
try:
ats_scores[str(i)] = json.loads(score_path.read_text(encoding="utf-8"))
except Exception:
pass
def _regen():
export_excel(
jobs_path=str(jobs_path),
output_path=str(out_path),
tracking=tracking,
ats_scores=ats_scores,
)
await asyncio.get_event_loop().run_in_executor(None, _regen)
return FileResponse(str(out_path), filename="jobs_export.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@app.get("/api/session") async def getsession(currentuser: User = Depends(getpaiduser)): path = usertmp(currentuser) / "session.json" if not path.exists(): return {} return json.loads(path.read_text(encoding="utf-8"))
── SEO & discovery files ─────────────────────────────────────────────────────
@app.apiroute("/robots.txt", methods=["GET", "HEAD"], includeinschema=False) async def robotstxt(): return FileResponse("static/robots.txt", media_type="text/plain")
@app.apiroute("/sitemap.xml", methods=["GET", "HEAD"], includeinschema=False)
async def sitemapxml():
"""Dynamic sitemap: auto-discovers content/blog/*.md files."""
blogdir = Path("content/blog")
slugs = []
if blogdir.exists():
for f in sorted(blogdir.glob("*.md")):
slugs.append(f.stem)
today = datetime.utcnow().strftime("%Y-%m-%d")
urls = [
f"
@app.apiroute("/manifest.json", methods=["GET", "HEAD"], includeinschema=False) async def manifestjson(): return FileResponse("static/manifest.json", media_type="application/json")
@app.apiroute("/llms.txt", methods=["GET", "HEAD"], includeinschema=False) async def llmstxt(): return FileResponse("static/llms.txt", media_type="text/plain")
── Dedicated page routes ─────────────────────────────────────────────────────
@app.apiroute("/about", methods=["GET", "HEAD"], includeinschema=False) async def aboutpage(): return FileResponse("about.html")
@app.apiroute("/contact", methods=["GET", "HEAD"], includeinschema=False) async def contactpage(): return FileResponse("contact.html")
@app.apiroute("/privacy", methods=["GET", "HEAD"], includeinschema=False) async def privacypage(): return FileResponse("privacy.html")
@app.apiroute("/terms", methods=["GET", "HEAD"], includeinschema=False) async def termspage(): return FileResponse("terms.html")
@app.apiroute("/disclaimer", methods=["GET", "HEAD"], includeinschema=False) async def disclaimerpage(): return FileResponse("disclaimer.html")
── Blog pages ────────────────────────────────────────────────────────────────
TAGGRADIENTS = { "ATS": "linear-gradient(90deg,#3B82F6,#6366F1)", "ATS & Screening": "linear-gradient(90deg,#3B82F6,#6366F1)", "Cover Letters": "linear-gradient(90deg,#10B981,#3B82F6)", "CV Writing": "linear-gradient(90deg,#F59E0B,#EF4444)", "Job Search": "linear-gradient(90deg,#8B5CF6,#EC4899)", "Strategy": "linear-gradient(90deg,#EF4444,#F97316)", "Career Strategy": "linear-gradient(90deg,#8B5CF6,#EC4899)", "Interview": "linear-gradient(90deg,#06B6D4,#3B82F6)", } GRADIENTCYCLE = [ "linear-gradient(90deg,#3B82F6,#6366F1)", "linear-gradient(90deg,#10B981,#3B82F6)", "linear-gradient(90deg,#F59E0B,#EF4444)", "linear-gradient(90deg,#8B5CF6,#EC4899)", "linear-gradient(90deg,#EF4444,#F97316)", "linear-gradient(90deg,#06B6D4,#3B82F6)", ]
@app.apiroute("/blog", methods=["GET", "HEAD"], responseclass=HTMLResponse)
async def blogpage():
template = Path("blog.html").readtext(encoding="utf-8")
blogdir = Path("content/blog")
articles = []
if blogdir.exists():
for mdfile in blogdir.glob("*.md"):
if mdfile.stem.startswith(""):
continue
try:
meta, _ = parsefrontmatter(mdfile.readtext(encoding="utf-8"))
if not meta.get("title"):
continue
articles.append({
"slug": mdfile.stem,
"title": meta["title"].strip('"'),
"description": meta.get("description", "").strip('"'),
"date": str(meta.get("date", "2026-01-01")),
"tag": meta.get("tag", "CV Writing"),
"readtime": meta.get("readtime", "5"),
})
except Exception:
pass
articles.sort(key=lambda a: a["date"], reverse=True)
cardshtml = ""
for i, art in enumerate(articles):
gradient = TAGGRADIENTS.get(art["tag"], GRADIENTCYCLE[i % len(GRADIENTCYCLE)])
try:
from datetime import date as date
d = date.fromisoformat(art["date"])
datestr = d.strftime("%B %Y")
except Exception:
datestr = art["date"]
cardshtml += (
f'\n '
f'\n '
f'\n {art["description"]}{art["title"]}
'
f'\n
def parsefrontmatter(text: str): """Parse YAML front-matter block from a markdown file. Returns (metadict, body_str).""" if not text.startswith("---"): return {}, text end = text.find("\n---", 3) if end == -1: return {}, text front = text[3:end].strip() body = text[end + 4:].strip() meta = {} for line in front.splitlines(): if ":" in line: key, _, val = line.partition(":") meta[key.strip()] = val.strip() return meta, body
@app.apiroute("/blog/{slug}", methods=["GET", "HEAD"], includeinschema=False) async def blogpostpage(slug: str): mdpath = Path("content/blog") / f"{slug}.md" if not mdpath.exists(): from fastapi.responses import Response return Response(content="Not Found", statuscode=404) text = mdpath.readtext(encoding="utf-8") meta, body = parsefrontmatter(text) htmlbody = markdown2.markdown(body, extras=["fenced-code-blocks", "tables", "header-ids", "smartypants"]) templatepath = Path("templates/blogpost.html") if not template_path.exists(): return HTMLResponse(content=f"
{meta.get('title','')}
{html_body}") template = templatepath.readtext(encoding="utf-8") page = (template .replace("{{title}}", meta.get("title", "Blog | Resumegpt")) .replace("{{description}}", meta.get("description", "")) .replace("{{date}}", meta.get("date", "")) .replace("{{tag}}", meta.get("tag", "")) .replace("{{readtime}}", meta.get("readtime", "5")) .replace("{{content}}", html_body) .replace("__api_fix__", slug)) return HTMLResponse(content=page)── SPA catch-all — serve index.html for all frontend routes ─────────────────
@app.apiroute("/{fullpath:path}", methods=["GET", "HEAD"], responseclass=HTMLResponse) async def spafallback(fullpath: str): return Path("index.html").readtext(encoding="utf-8")