""" api_server.py — FastAPI JSON API + Streamlit reverse proxy. This is the NEW entrypoint for the HF Space, replacing `exec streamlit run ui.py` in start.sh. It: - Serves /api/health and /api/generate directly (Chrome extension targets these) - Launches Streamlit as a subprocess on port 8501 - Reverse-proxies all other HTTP traffic to Streamlit via httpx - Proxies the Streamlit WebSocket (/_stcore/stream) via the websockets library No ATS or LLM logic is implemented here — only wiring calls into existing src/ modules. """ import asyncio import base64 import hashlib import hmac import io import json import os import re import shutil import subprocess import sys import tempfile import time import httpx import uvicorn from fastapi import FastAPI, Header, HTTPException, UploadFile, Form, Request, WebSocket, WebSocketDisconnect, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import threading from fastapi.responses import JSONResponse, StreamingResponse from starlette.responses import Response as StarletteResponse # ── App init ────────────────────────────────────────────────────────────────── app = FastAPI(title="ATS Resume API", version="1.0") # ── CORS middleware ─────────────────────────────────────────────────────────── # Dev: allow all origins (*). Override CORS_ORIGINS with the published # chrome-extension:// value after publishing to the Chrome Web Store. ALLOWED_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",") app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_methods=["POST", "GET", "OPTIONS"], allow_headers=["X-Api-Token", "Content-Type"], expose_headers=["Content-Disposition"], allow_credentials=False, # credentials=True requires specific origin, not * ) # ── Auth token guard (constant-time comparison) ─────────────────────────────── _SECRET = os.getenv("API_SECRET_TOKEN", "") def _check_token(token: str | None) -> None: """Raise HTTP 401 if API_SECRET_TOKEN is set and the header doesn't match.""" if not _SECRET: return # No token configured → open (development mode only) if not token or not hmac.compare_digest(token.strip(), _SECRET.strip()): raise HTTPException(status_code=401, detail="auth_failed") def _truthy(v: str | None) -> bool: """Parse a multipart form flag into a bool ('1'/'true'/'yes'/'on').""" return str(v or "").strip().lower() in ("1", "true", "yes", "on") def _term_list(v: str | None) -> list: """Split a comma/newline/semicolon term list into a clean list.""" return [t.strip() for t in re.split(r"[\n,;]+", v or "") if t.strip()] # ── In-memory resume parse cache (single-user, per-process) ────────────────── # sha256_hex -> parsed Resume model (avoids redundant pdfplumber parsing) _RESUME_CACHE: dict[str, object] = {} # ── Core generation helper ──────────────────────────────────────────────────── def generate_resume_for_api( pdf_bytes: bytes, jd_text: str, job_title: str, company: str, maximum_ats_mode: bool = False, confirmed_terms: list | None = None, ) -> tuple[str, dict]: """ Run the full ATS pipeline for an uploaded resume PDF + JD text. Returns (docx_filepath, v2_report_dict). Raises on hard failure. The caller is responsible for cleaning up the returned filepath's parent temp directory after base64-encoding the file. """ from src.resume_parser_v2 import parse_resume_pdf from src.resume_customizer import ResumeCustomizer from src.llm_client import LLMClient from src.providers import build_provider_chain import config # 1. Parse the uploaded PDF — use in-memory cache keyed by SHA-256 to # avoid redundant pdfplumber parsing across repeated requests. pdf_hash = hashlib.sha256(pdf_bytes).hexdigest() if pdf_hash in _RESUME_CACHE: base_resume = _RESUME_CACHE[pdf_hash] else: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_pdf: tmp_pdf.write(pdf_bytes) tmp_pdf_path = tmp_pdf.name try: base_resume = parse_resume_pdf(tmp_pdf_path) finally: try: os.unlink(tmp_pdf_path) except OSError: pass _RESUME_CACHE[pdf_hash] = base_resume # 2. Build ResumeCustomizer pointed at a fresh temp output dir. tmp_out = tempfile.mkdtemp() llm = LLMClient() provider_chain = build_provider_chain(llm) # Use the first tailor-capable model config for the customizer constructor # (same logic as the batch pipeline). primary_cfg = next( (m for m in config.ASSESSMENT_MODELS if m.get("tailor") and m.get("api_key")), None, ) customizer = ResumeCustomizer(llm, base_resume.to_flat_text(), tmp_out, fast_model_cfg=primary_cfg) # 3. Build a minimal job dict (same shape the batch pipeline uses). job: dict = { "title": job_title, "company": company, "description": jd_text[:16000], # truncate to ~4000 words to avoid LLM token overflow "ats_keywords": "", "_raw_assessment": {}, # Maximum ATS Mode flows into _generate_resume_v4 via the job dict so # normal PM/AI craft terms are treated as user-confirmed and woven in. "_maximum_ats_mode": bool(maximum_ats_mode), "_confirmed_terms": confirmed_terms or [], } # 4. Call _run_provider_chain (full provider fallback + READY gate + best-attempt # selection). Always pass base_resume_override to skip the hardcoded disk path. filepath = tempfile.mktemp(suffix=".docx", dir=tmp_out) result_path = customizer._run_provider_chain( job, filepath, provider_chain, base_resume_override=base_resume ) # Always render a PDF sidecar so pdf_b64 is populated even on Linux/HF where # docx2pdf (Windows+Word only) is unavailable — docx_to_pdf falls back to # reportlab. Never fail generation because the PDF render failed. try: if result_path: from src.pdf_writer import docx_to_pdf docx_to_pdf(result_path) except Exception as exc: print(f"[api/generate] pdf sidecar render failed: {exc}") v2_report = job.get("_v2_report", {}) or {} return result_path, v2_report # ── LaTeX resume flow ───────────────────────────────────────────────────────── def _latex_status(pct: int, maximum_ats_mode: bool, has_unresolved_high: bool) -> str: """Map external-style coverage % onto the project's readiness statuses.""" from src.fit_gate import ( READY_MAX_ATS_95_PLUS, READY_90_PLUS_EXTERNAL_ALIGNED, READY_95_EXTERNAL_ALIGNED, NEEDS_USER_CONFIRMATION, BELOW_TARGET_REPAIRABLE, ) import config target = config.MAXIMUM_ATS.get("target_external_score", 95) minimum = config.MAXIMUM_ATS.get("min_external_score", 90) if pct >= target: return READY_MAX_ATS_95_PLUS if maximum_ats_mode else READY_95_EXTERNAL_ALIGNED if pct >= minimum: return READY_90_PLUS_EXTERNAL_ALIGNED if has_unresolved_high and maximum_ats_mode: return NEEDS_USER_CONFIRMATION return BELOW_TARGET_REPAIRABLE def latex_flow_for_api( latex_src: str, jd_text: str, job_title: str, company: str, maximum_ats_mode: bool = False, confirmed_terms: list | None = None, ) -> tuple[dict, str]: """LaTeX-first flow: extract text → gate JD keywords → inject → compile PDF. Returns (report_dict, out_dir). The caller cleans up out_dir after encoding. """ from src.ats_safe import generate_alignment_safe, to_legacy_report from src.nim_fallback import build_llm out_dir = tempfile.mkdtemp(prefix="latex_resume_") try: llm, health = build_llm() if os.getenv("V1_ENABLE_LLM") else (None, []) except Exception: llm, health = None, [] sel = getattr(llm, "model", None) # Evidence-gated safe path: JD cleaned server-side, extraction validated + # JD-traceable, supported terms rewritten in (verified), PDF re-parsed. Uses a # health-checked NIM model; if none is healthy, llm=None → preserve + report. safe = generate_alignment_safe( latex_src, jd_text, company=company or "", job_title=company or job_title or "resume", llm_client=llm, selected_model=sel, model_health=health, run_audit=True, out_dir=out_dir, compile_pdf=True, ) return to_legacy_report(safe), out_dir async def _generate_from_latex( latex_src: str, jd_text: str, job_title: str, company: str, max_ats: bool, conf_terms: list, ) -> JSONResponse: """Run the LaTeX-first generation flow and build the JSON response.""" out_dir: str | None = None try: loop = asyncio.get_event_loop() report, out_dir = await loop.run_in_executor( None, latex_flow_for_api, latex_src, jd_text, job_title, company, max_ats, conf_terms, ) pct = int(report.get("pct", 0) or 0) gated = report.get("gated", {}) or {} unresolved_high = [t for t, r in gated.items() if str(r).startswith("ask_user")] status = _latex_status(pct, max_ats, bool(unresolved_high)) # Encode the compiled PDF (if an engine produced one) and the .tex. pdf_b64 = None pdf_path = report.get("pdf_path") if pdf_path and os.path.exists(pdf_path): with open(pdf_path, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode("ascii") tex_src = report.get("tex", "") tex_b64 = base64.b64encode(tex_src.encode("utf-8")).decode("ascii") if tex_src else None # Surface WHY no PDF was produced so the panel can tell the user (engine # missing vs a LaTeX compile error). The .tex is always returned. pdf_error = None if not pdf_b64: engine = report.get("engine") pdf_error = "engine_missing" if not engine else "latex_error" # GUARANTEE a downloadable PDF even if the LaTeX engine crashed/missing: # render a plain reportlab PDF from the resume text. The .tex (Overleaf) # stays the full-design output; this is a clearly-labelled fallback. pdf_fallback = False if not pdf_b64: try: from src.latex_resume import latex_to_text, render_text_to_pdf fb_path = os.path.join(out_dir, "resume_fallback.pdf") if (render_text_to_pdf(latex_to_text(tex_src or ""), fb_path) and os.path.exists(fb_path)): with open(fb_path, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode("ascii") pdf_fallback = True except Exception as exc: print(f"[api/generate:latex] fallback pdf render failed: {exc}") from src.fit_gate import MAX_ATS_READY_STATUSES, DOWNLOADABLE_STATUSES download_allowed = status in DOWNLOADABLE_STATUSES payload = { "status": status, "source": "latex", "download_allowed": bool(download_allowed), "scores": { "jd_match": pct, # external-style coverage is the real signal "ats_readability": 100, # LaTeX → clean, parse-safe PDF "independent_jd_match": pct, }, "maximum_ats_mode": max_ats, "external_coverage": { "expected": report.get("expected"), "found": report.get("found"), "pct": pct, "missing": report.get("missing", []), }, "external_coverage_pct": pct, "coverage_report": { "keywords": report.get("keywords", []), "coverage_count": report.get("coverage_count", ""), "added": report.get("injected", []), "gated": gated, "needs_confirmation": unresolved_high, }, "injected_terms": report.get("injected", []), "needs_confirmation_terms": unresolved_high, "latex_engine": report.get("engine"), "latex_compiled": bool(report.get("compiled")), "compile_log": (report.get("compile_log", "") or "")[-1500:], "pdf_error": pdf_error, "pdf_fallback": pdf_fallback, "tex_b64": tex_b64, "pdf_b64": pdf_b64, "docx_b64": None, } print(f"[api/generate:latex] max_ats={max_ats} status={status} " f"external_cov={pct}% compiled={payload['latex_compiled']} " f"engine={payload['latex_engine']} injected={len(payload['injected_terms'])}") # Persist generated resume to Supabase so it survives HF Space restarts try: from src.supabase_client import get_service_client, is_configured, get_owner_user_id if is_configured() and tex_src: uid = get_owner_user_id() if uid: get_service_client().table("generated_resumes").insert({ "job_title": job_title, "company": company, "tex_source": tex_src, "ats_score": pct, "user_id": uid, }).execute() except Exception as _sb_exc: print(f"[api/generate:latex] supabase save failed (non-fatal): {_sb_exc}") return JSONResponse(payload) except Exception as exc: return JSONResponse( {"error": "latex_generation_failed", "detail": str(exc)}, status_code=500, ) finally: if out_dir: shutil.rmtree(out_dir, ignore_errors=True) async def _generate_from_latex_v2( latex_src: str, jd_text: str, job_title: str, company: str, max_ats: bool, conf_terms: list, ) -> JSONResponse: """V2: sentence-based keyword integration via LLM, same waterfall as V1.""" out_dir: str | None = None try: from src.resume_v2_natural import generate_v2 loop = asyncio.get_event_loop() out_dir = tempfile.mkdtemp(prefix="latex_v2_") report = await loop.run_in_executor( None, lambda: generate_v2( latex_src, jd_text, job_title=job_title, company=company, out_dir=out_dir, compile_pdf=True, ), ) pct = int(report.get("pct") or 0) pdf_path = report.get("pdf_path") tex = report.get("tex") or latex_src compiled = report.get("compiled", False) tex_b64 = base64.b64encode(tex.encode("utf-8")).decode("ascii") pdf_b64 = None if pdf_path: try: with open(pdf_path, "rb") as _f: pdf_b64 = base64.b64encode(_f.read()).decode("ascii") except Exception: pass pdf_error = None if not pdf_b64: engine = report.get("engine") pdf_error = "engine_missing" if not engine else "latex_error" pdf_fallback = False if not pdf_b64: try: from src.latex_resume import latex_to_text, render_text_to_pdf fb_path = os.path.join(out_dir, "resume_v2_fallback.pdf") if (render_text_to_pdf(latex_to_text(tex or ""), fb_path) and os.path.exists(fb_path)): with open(fb_path, "rb") as _f: pdf_b64 = base64.b64encode(_f.read()).decode("ascii") pdf_fallback = True except Exception as exc: print(f"[api/generate:v2] fallback pdf render failed: {exc}") from src.fit_gate import MAX_ATS_READY_STATUSES, DOWNLOADABLE_STATUSES status = _latex_status(pct, max_ats, False) download_allowed = status in DOWNLOADABLE_STATUSES payload = { "status": status, "source": "latex_v2", "version": "v2", "download_allowed": bool(download_allowed), "maximum_ats_mode": max_ats, "scores": { "jd_match": pct, "ats_readability": 100, "independent_jd_match": pct, }, "external_coverage": { "expected": report.get("expected", 0), "found": report.get("found", 0), "pct": pct, "missing": report.get("missing", []), }, "external_coverage_pct": pct, "coverage_report": { "keywords": report.get("keywords", []), "coverage_count": f"{report.get('found', 0)}/{report.get('expected', 0)}", }, "latex_engine": report.get("engine"), "latex_compiled": compiled, "compile_log": "", "pdf_error": pdf_error, "pdf_fallback": pdf_fallback, "tex_b64": tex_b64, "pdf_b64": pdf_b64, "docx_b64": None, "v2_models_used": report.get("v2_models_used", []), "v2_winner": report.get("v2_winner", ""), "judge_note": report.get("judge_note", ""), "keywords": report.get("keywords", []), } print(f"[api/generate:v2] max_ats={max_ats} status={status} " f"external_cov={pct}% compiled={compiled} " f"winner={report.get('v2_winner')} judge={report.get('judge_note')}") try: from src.supabase_client import get_service_client, is_configured, get_owner_user_id if is_configured() and tex: uid = get_owner_user_id() if uid: get_service_client().table("generated_resumes").insert({ "job_title": job_title, "company": company, "tex_source": tex, "ats_score": pct, "user_id": uid, }).execute() except Exception as _sb_exc: print(f"[api/generate:v2] supabase save failed (non-fatal): {_sb_exc}") return JSONResponse(payload) except Exception as exc: return JSONResponse( {"error": "v2_engine_error", "detail": str(exc)[:400]}, status_code=200 ) finally: if out_dir: shutil.rmtree(out_dir, ignore_errors=True) @app.post("/api/generate-stream") async def generate_stream_endpoint( jd_text: str = Form(""), job_title: str = Form(""), company: str = Form(""), maximum_ats_mode: str = Form(""), confirmed_terms: str = Form(""), resume_latex: str = Form(""), version: str = Form(""), resume: UploadFile = None, x_api_token: str = Header(None), ): """SSE endpoint — same as /api/generate but streams progress events. Events: `data: {"stage": "...", "pct": N}\\n\\n` Final: `data: {"done": true, ...full result fields...}\\n\\n` Error: `data: {"error": "...", "detail": "..."}\\n\\n` """ _check_token(x_api_token) max_ats = _truthy(maximum_ats_mode) conf = _term_list(confirmed_terms) _version = (version or "").strip().lower() or os.getenv("GEN_VERSION_DEFAULT", "v2") if not (resume_latex or "").strip() and resume is None: try: from src.default_resume import get_default_resume_latex resume_latex = get_default_resume_latex() except Exception: pass pdf_bytes = None if resume is not None: pdf_bytes = await resume.read() if not (resume_latex or "").strip() and not pdf_bytes: async def _err(): yield f'data: {json.dumps({"error": "no_resume", "detail": "No resume provided."})}\n\n' return StreamingResponse(_err(), media_type="text/event-stream") loop = asyncio.get_event_loop() queue: asyncio.Queue = asyncio.Queue() def _progress(stage: str, pct: int): loop.call_soon_threadsafe(queue.put_nowait, {"stage": stage, "pct": pct}) def _run(): out_dir = None try: latex_src = (resume_latex or "").strip() if _version == "v2": from src.resume_v2_natural import generate_v2 out_dir = tempfile.mkdtemp(prefix="stream_v2_") report = generate_v2( latex_src, jd_text, job_title=job_title, company=company, out_dir=out_dir, compile_pdf=True, progress_callback=_progress, ) else: # V1 = evidence-gated safe path (JD cleaned server-side, extraction # validated + traceable, résumé preserved, PDF re-parsed). This is # the PRIMARY extension path — previously it ran the raw run-gram # extractor with no LLM and no cleaning (the contamination bug). from src.ats_safe import generate_alignment_safe, to_legacy_report from src.nim_fallback import build_llm out_dir = tempfile.mkdtemp(prefix="stream_v1_") try: _llm, _health = build_llm() if os.getenv("V1_ENABLE_LLM") else (None, []) except Exception: _llm, _health = None, [] _safe = generate_alignment_safe( latex_src, jd_text, company=company or "", job_title=company or job_title or "resume", llm_client=_llm, selected_model=getattr(_llm, "model", None), model_health=_health, out_dir=out_dir, compile_pdf=True, progress_callback=_progress, ) report = to_legacy_report(_safe) # Build the same response payload as the blocking endpoints. pct = int(report.get("pct", 0) or 0) from src.fit_gate import MAX_ATS_READY_STATUSES, DOWNLOADABLE_STATUSES # V1 safe path carries its own status; the preserved résumé is always # downloadable. V2 keeps the coverage-derived status. status = report.get("status") or _latex_status(pct, max_ats, False) download_allowed = (True if report.get("status") else status in DOWNLOADABLE_STATUSES) tex = report.get("tex") or latex_src tex_b64 = base64.b64encode((tex or "").encode()).decode() if tex else None pdf_b64 = None pdf_path = report.get("pdf_path") if pdf_path and os.path.exists(pdf_path): with open(pdf_path, "rb") as _f: pdf_b64 = base64.b64encode(_f.read()).decode() # Reportlab fallback PDF (same as the blocking path) so the user # always gets a downloadable file even when tectonic is absent/crashed. pdf_fallback = False if not pdf_b64: try: from src.latex_resume import latex_to_text, render_text_to_pdf import tempfile as _tmp fb_dir = _tmp.mkdtemp(prefix="stream_fb_") fb_path = os.path.join(fb_dir, "resume_fallback.pdf") if (render_text_to_pdf(latex_to_text(tex or ""), fb_path) and os.path.exists(fb_path)): with open(fb_path, "rb") as _f: pdf_b64 = base64.b64encode(_f.read()).decode() pdf_fallback = True except Exception as _fb_exc: print(f"[api/stream] fallback pdf render failed: {_fb_exc}") gated = report.get("gated", {}) or {} unresolved_high = [t for t, r in gated.items() if str(r).startswith("ask_user")] pdf_error = None if not pdf_b64: engine = report.get("engine") pdf_error = "engine_missing" if not engine else "latex_error" payload = { "done": True, "status": status, "source": "latex_v2" if _version == "v2" else "latex", "version": _version, "download_allowed": bool(download_allowed), "maximum_ats_mode": max_ats, "scores": { "jd_match": pct, "ats_readability": 100, "independent_jd_match": pct, }, "external_coverage_pct": pct, "external_coverage": { "expected": report.get("expected"), "found": report.get("found"), "pct": pct, "missing": report.get("missing", []), }, "coverage_report": { "keywords": report.get("keywords", []), "coverage_count": report.get("coverage_count", ""), "added": report.get("injected", []), "gated": gated, "needs_confirmation": unresolved_high, }, "injected_terms": report.get("injected", []), "needs_confirmation_terms": unresolved_high, "latex_engine": report.get("engine"), "latex_compiled": bool(report.get("compiled")), "compile_log": (report.get("compile_log", "") or "")[-1500:], "pdf_error": pdf_error, "pdf_fallback": pdf_fallback, "judge_note": report.get("judge_note", ""), "tex_b64": tex_b64, "pdf_b64": pdf_b64, "docx_b64": None, } loop.call_soon_threadsafe(queue.put_nowait, payload) except Exception as exc: loop.call_soon_threadsafe( queue.put_nowait, {"error": "generation_failed", "detail": str(exc)[:300]}) finally: if out_dir: shutil.rmtree(out_dir, ignore_errors=True) t = threading.Thread(target=_run, daemon=True) t.start() async def _events(): while True: evt = await queue.get() yield f"data: {json.dumps(evt)}\n\n" if evt.get("done") or evt.get("error"): break return StreamingResponse( _events(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) # ── /api/generate-application-stream (unified resume + cover letter) ───────── @app.post("/api/generate-application-stream") async def generate_application_stream( job_description: str = Form(""), job_title: str = Form(""), company_name: str = Form(""), location: str = Form(""), source_url: str = Form(""), extension_version: str = Form(""), resume_latex: str = Form(""), cover_letter_latex: str = Form(""), user_confirmed_skill_expansion: str = Form(""), x_api_token: str = Header(None), ): """Unified endpoint: tailors resume + generates cover letter + compiles both. Streams SSE progress events, then a final 'complete' event with the full payload. """ _check_token(x_api_token) confirm_skills = _truthy(user_confirmed_skill_expansion) jd_text = (job_description or "").strip() if not jd_text: async def _err(): yield f'data: {json.dumps({"error": "No job description provided.", "detail": "Open a job posting or paste the JD.", "failed_at": "job_received"})}\n\n' return StreamingResponse(_err(), media_type="text/event-stream") latex_src = (resume_latex or "").strip() if not latex_src: try: from src.default_resume import get_default_resume_latex latex_src = get_default_resume_latex() except Exception: pass if not latex_src: async def _err2(): yield f'data: {json.dumps({"error": "No resume template configured.", "detail": "Upload your resume LaTeX in Settings.", "failed_at": "job_received"})}\n\n' return StreamingResponse(_err2(), media_type="text/event-stream") cl_template = (cover_letter_latex or "").strip() loop = asyncio.get_event_loop() queue: asyncio.Queue = asyncio.Queue() def _emit(stage: str, **extra): loop.call_soon_threadsafe(queue.put_nowait, {"stage": stage, **extra}) def _run_app(): out_dir = None try: _emit("job_received") # 1. Run V1 resume pipeline from src.ats_safe import generate_alignment_safe, to_legacy_report from src.nim_fallback import build_llm out_dir = tempfile.mkdtemp(prefix="app_gen_") try: _llm, _health = build_llm() if os.getenv("V1_ENABLE_LLM") else (None, []) except Exception: _llm, _health = None, [] _emit("jd_cleaned") safe = generate_alignment_safe( latex_src, jd_text, company=company_name or "", job_title=job_title or "resume", llm_client=_llm, selected_model=getattr(_llm, "model", None), model_health=_health, out_dir=out_dir, compile_pdf=False, confirm_gap_keywords=confirm_skills, ) report = to_legacy_report(safe) _emit("criteria_extracted") _emit("resume_tailored") # 2. Generate cover letter from src.cover_letter import generate_cover_letter cl_result = generate_cover_letter( clean_jd=jd_text, job_title=job_title or "", company=company_name or "", location=location or "", template_latex=cl_template or None, ) _emit("cover_letter_generated") # 3. Compile resume PDF resume_tex = report.get("tex") or latex_src resume_pdf_b64 = None resume_compiled = False resume_engine = None from src.latex_resume import ( compile_latex_to_pdf, latex_body_for_plain_pdf, latex_engines_available, render_text_to_pdf, ) r_dir = os.path.join(out_dir, "resume") r_compile = compile_latex_to_pdf(resume_tex, r_dir, jobname="resume", timeout=420) resume_compiled = r_compile.get("compiled", False) resume_engine = r_compile.get("engine") resume_compile_log = (r_compile.get("log") or "")[-800:] resume_pdf_fallback = False if r_compile.get("pdf_path") and os.path.exists(r_compile["pdf_path"]): with open(r_compile["pdf_path"], "rb") as f: resume_pdf_b64 = base64.b64encode(f.read()).decode() if not resume_pdf_b64 and not latex_engines_available(): try: fb_path = os.path.join(r_dir, "resume_fb.pdf") if (render_text_to_pdf(latex_body_for_plain_pdf(resume_tex), fb_path) and os.path.exists(fb_path)): with open(fb_path, "rb") as f: resume_pdf_b64 = base64.b64encode(f.read()).decode() resume_engine = "reportlab" resume_pdf_fallback = True except Exception: pass print( f"[app-gen] resume compiled={resume_compiled} engine={resume_engine} " f"fallback={resume_pdf_fallback} " f"log={(resume_compile_log or '')[-400:]}" ) _emit("resume_compiled") # 4. Compile cover letter PDF cl_tex = cl_result.get("latex", "") cl_pdf_b64 = None cl_compiled = False cl_engine = None c_dir = os.path.join(out_dir, "cover_letter") c_compile = compile_latex_to_pdf(cl_tex, c_dir, jobname="cover_letter", timeout=420) cl_compiled = c_compile.get("compiled", False) cl_engine = c_compile.get("engine") cl_compile_log = (c_compile.get("log") or "")[-800:] cl_pdf_fallback = False if c_compile.get("pdf_path") and os.path.exists(c_compile["pdf_path"]): with open(c_compile["pdf_path"], "rb") as f: cl_pdf_b64 = base64.b64encode(f.read()).decode() if not cl_pdf_b64 and not latex_engines_available(): try: fb_path = os.path.join(c_dir, "cl_fb.pdf") if (render_text_to_pdf(latex_body_for_plain_pdf(cl_tex), fb_path) and os.path.exists(fb_path)): with open(fb_path, "rb") as f: cl_pdf_b64 = base64.b64encode(f.read()).decode() cl_engine = "reportlab" cl_pdf_fallback = True except Exception: pass print( f"[app-gen] cover_letter compiled={cl_compiled} engine={cl_engine} " f"fallback={cl_pdf_fallback} " f"log={(cl_compile_log or '')[-400:]}" ) _emit("cover_letter_compiled") # 5. Validate _emit("documents_validated") # 6. Build scores _est = safe.get("internal_alignment_estimate") or {} pct_before = _est.get("before", 0) pct_after = _est.get("after", 0) execution_mode = safe.get("rewrite_mode", "deterministic") supported = [r["exact_jd_phrase"] for r in (safe.get("rewrites") or []) if r.get("applied")] placed_kw = safe.get("gap_disclosures") or [] supported = supported + [k for k in placed_kw if k not in supported] _ev = safe.get("evidence") or {} gaps = [g["keyword"] for g in _ev.get("gaps", [])] # Keyword accounting for the bulk card: how many of the JD's criteria # we could honestly use vs. had to leave out, and the truthful ceiling. _cov = _est.get("coverage") or {} keyword_stats = { "jd_total": len(_ev.get("covered", [])) + len(_ev.get("partial", [])) + len(_ev.get("gaps", [])), "included": len(_ev.get("covered", [])), "ignored": len(_ev.get("gaps", [])), "partial": len(_ev.get("partial", [])), "ceiling": round(_est.get("max_evidence_supported") or 0), "mandatory_total": _cov.get("mandatory_total") or 0, "mandatory_covered": _cov.get("supported_mandatory_in_final") or 0, } payload = { "status": "complete", "job": { "title": job_title or "", "company": company_name or "", "location": location or "", "source_url": source_url or "", }, "resume": { "latex": resume_tex, "pdf_base64": resume_pdf_b64, "compiled": resume_compiled, "pdf_fallback": resume_pdf_fallback, "engine": resume_engine, "compile_log": resume_compile_log, "score_before": round(pct_before), "score_after": round(pct_after), "execution_mode": execution_mode, "supported_phrases_added": supported, "unsupported_gaps": gaps, "keyword_stats": keyword_stats, }, "cover_letter": { "latex": cl_tex, "pdf_base64": cl_pdf_b64, "compiled": cl_compiled, "pdf_fallback": cl_pdf_fallback, "engine": cl_engine, "compile_log": cl_compile_log, "tailored_fields": cl_result.get("tailored_fields", {}), "emphasis": cl_result.get("emphasis", "general"), "unresolved_placeholders": cl_result.get("unresolved_placeholders", []), }, "extension_version": extension_version or "", } loop.call_soon_threadsafe(queue.put_nowait, payload) except Exception as exc: loop.call_soon_threadsafe(queue.put_nowait, { "error": str(exc)[:300], "detail": "Generation pipeline failed.", "failed_at": "resume_tailored", }) finally: if out_dir: shutil.rmtree(out_dir, ignore_errors=True) t = threading.Thread(target=_run_app, daemon=True) t.start() async def _events(): while True: evt = await queue.get() yield f"data: {json.dumps(evt)}\n\n" if evt.get("status") == "complete" or evt.get("error"): break return StreamingResponse( _events(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) # ── /api/compile-resume ────────────────────────────────────────────────────── @app.post("/api/compile-resume") async def compile_resume_endpoint( latex: str = Form(""), job_title: str = Form(""), company: str = Form(""), x_api_token: str = Header(None), ): """Compile edited resume LaTeX to PDF. Returns {status, pdf_base64, engine}.""" _check_token(x_api_token) latex_src = (latex or "").strip() if not latex_src: return JSONResponse({"status": "error", "message": "Empty LaTeX source."}, status_code=422) out_dir = tempfile.mkdtemp(prefix="compile_resume_") try: from src.latex_resume import compile_latex_to_pdf, latex_to_text, render_text_to_pdf loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, compile_latex_to_pdf, latex_src, out_dir, "resume", 120, ) pdf_b64 = None if result.get("pdf_path") and os.path.exists(result["pdf_path"]): with open(result["pdf_path"], "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode() if not pdf_b64: fb_path = os.path.join(out_dir, "resume_fb.pdf") if render_text_to_pdf(latex_to_text(latex_src), fb_path) and os.path.exists(fb_path): with open(fb_path, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode() result["engine"] = "reportlab" result["compiled"] = True # Sanitize compile log (never expose server paths) log = (result.get("log", "") or "")[-1000:] log = re.sub(r"(/tmp|/home|/var|C:\\)[^\s]*", "", log) return JSONResponse({ "status": "complete" if pdf_b64 else "error", "pdf_base64": pdf_b64, "engine": result.get("engine"), "compiled": result.get("compiled", False), "message": "" if pdf_b64 else "Compilation failed. Check LaTeX syntax.", "compile_log": log, }) except Exception as exc: return JSONResponse({"status": "error", "message": str(exc)[:200]}, status_code=500) finally: shutil.rmtree(out_dir, ignore_errors=True) # ── /api/compile-cover-letter ──────────────────────────────────────────────── @app.post("/api/compile-cover-letter") async def compile_cover_letter_endpoint( latex: str = Form(""), job_title: str = Form(""), company: str = Form(""), x_api_token: str = Header(None), ): """Compile edited cover letter LaTeX to PDF. Returns {status, pdf_base64, engine}.""" _check_token(x_api_token) latex_src = (latex or "").strip() if not latex_src: return JSONResponse({"status": "error", "message": "Empty LaTeX source."}, status_code=422) out_dir = tempfile.mkdtemp(prefix="compile_cl_") try: from src.latex_resume import compile_latex_to_pdf, latex_to_text, render_text_to_pdf loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, compile_latex_to_pdf, latex_src, out_dir, "cover_letter", 120, ) pdf_b64 = None if result.get("pdf_path") and os.path.exists(result["pdf_path"]): with open(result["pdf_path"], "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode() if not pdf_b64: fb_path = os.path.join(out_dir, "cl_fb.pdf") if render_text_to_pdf(latex_to_text(latex_src), fb_path) and os.path.exists(fb_path): with open(fb_path, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode() result["engine"] = "reportlab" result["compiled"] = True log = (result.get("log", "") or "")[-1000:] log = re.sub(r"(/tmp|/home|/var|C:\\)[^\s]*", "", log) return JSONResponse({ "status": "complete" if pdf_b64 else "error", "pdf_base64": pdf_b64, "engine": result.get("engine"), "compiled": result.get("compiled", False), "message": "" if pdf_b64 else "Compilation failed. Check LaTeX syntax.", "compile_log": log, }) except Exception as exc: return JSONResponse({"status": "error", "message": str(exc)[:200]}, status_code=500) finally: shutil.rmtree(out_dir, ignore_errors=True) async def _repair_from_latex( latex_src: str, jd_text: str, job_title: str, company: str, max_ats: bool, conf_terms: list, pasted_terms: list, ) -> JSONResponse: """Evidence-gated re-analysis + recompile. NOTE: this no longer blindly injects externally-reported "missing" keywords — doing so fabricated experience. It routes through the safe, evidence-gated orchestrator: the JD is cleaned, criteria are validated, and only résumé- supported terms are reported as covered; gaps are disclosed, never inserted. """ out_dir: str | None = None try: from src.ats_safe import generate_alignment_safe, to_legacy_report from src.nim_fallback import build_llm try: _llm, _health = build_llm() if os.getenv("V1_ENABLE_LLM") else (None, []) except Exception: _llm, _health = None, [] out_dir = tempfile.mkdtemp(prefix="latex_repair_") loop = asyncio.get_event_loop() report = await loop.run_in_executor( None, lambda: to_legacy_report(generate_alignment_safe( latex_src, jd_text, company=company or "", job_title=company or job_title or "resume", llm_client=_llm, selected_model=getattr(_llm, "model", None), model_health=_health, out_dir=out_dir, compile_pdf=True, )), ) pct = int(report.get("pct", 0) or 0) gated = report.get("gated", {}) or {} unresolved_high = [t for t, r in gated.items() if str(r).startswith("ask_user")] status = _latex_status(pct, max_ats, bool(unresolved_high)) pdf_b64 = None pdf_path = report.get("pdf_path") if pdf_path and os.path.exists(pdf_path): with open(pdf_path, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode("ascii") tex_src = report.get("tex", "") tex_b64 = base64.b64encode(tex_src.encode("utf-8")).decode("ascii") if tex_src else None pdf_error = None if not pdf_b64: engine = report.get("engine") pdf_error = "engine_missing" if not engine else "latex_error" # Guarantee a downloadable PDF even if the engine crashed/missing. pdf_fallback = False if not pdf_b64: try: from src.latex_resume import latex_to_text, render_text_to_pdf fb_path = os.path.join(out_dir, "resume_repair_fallback.pdf") if (render_text_to_pdf(latex_to_text(tex_src or ""), fb_path) and os.path.exists(fb_path)): with open(fb_path, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode("ascii") pdf_fallback = True except Exception as exc: print(f"[api/repair:latex] fallback pdf render failed: {exc}") injected = report.get("injected", []) try: if max_ats and injected: confirm_expansion_terms(injected) except Exception: pass from src.fit_gate import MAX_ATS_READY_STATUSES, DOWNLOADABLE_STATUSES print(f"[api/repair:latex] max_ats={max_ats} status={status} " f"external_cov={pct}% injected={len(injected)} " f"compiled={report.get('compiled')}") return JSONResponse({ "status": status, "source": "latex", "download_allowed": status in DOWNLOADABLE_STATUSES, "scores": {"jd_match": pct, "ats_readability": 100, "independent_jd_match": pct}, "external_score": pct, "maximum_ats_mode": max_ats, "after_coverage": {"expected": report.get("expected"), "found": report.get("found"), "pct": pct, "missing": report.get("missing", [])}, "added_terms": injected, "blocked_terms": [t for t, r in gated.items() if "blocked" in str(r)], "unresolved_high_risk_terms": unresolved_high, "coverage_report": { "keywords": report.get("keywords", []), "coverage_count": report.get("coverage_count", ""), "added": injected, "gated": gated, "needs_confirmation": unresolved_high, }, "vault_added": injected if max_ats else [], "latex_engine": report.get("engine"), "latex_compiled": bool(report.get("compiled")), "compile_log": (report.get("compile_log", "") or "")[-1500:], "pdf_error": pdf_error, "pdf_fallback": pdf_fallback, "tex_b64": tex_b64, "pdf_b64": pdf_b64, "docx_b64": None, }) except Exception as exc: return JSONResponse( {"error": "latex_repair_failed", "detail": str(exc)}, status_code=500 ) finally: if out_dir: shutil.rmtree(out_dir, ignore_errors=True) # ── /api/health ─────────────────────────────────────────────────────────────── @app.get("/api/health") async def health(): """Health check. Returns 200 with {status: ok} when the server is up.""" return { "status": "ok", "version": "1.0", "streamlit_pid": _streamlit_proc.pid if _streamlit_proc else None, } # ── /telegram/diag ──────────────────────────────────────────────────────────── @app.get("/telegram/diag") async def telegram_diag(): """Report whether the Space can reach api.telegram.org (egress check). Returns reachability/status only — never the bot token.""" try: from src.telegram_bot import diagnose return diagnose() except Exception as exc: # noqa: BLE001 return JSONResponse({"error": str(exc)[:200]}, status_code=500) # ── /telegram/webhook ───────────────────────────────────────────────────────── @app.post("/telegram/webhook") async def telegram_webhook(request: Request, background_tasks: BackgroundTasks): """Telegram pushes job-link/JD messages here. We ACK 200 instantly and run the (slow) generation in a background task, replying with the PDF when ready.""" # Validate the shared secret Telegram echoes back (set via setWebhook). secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "") if secret and request.headers.get("x-telegram-bot-api-secret-token") != secret: return JSONResponse({"ok": False, "error": "bad_secret"}, status_code=403) try: update = await request.json() except Exception: return JSONResponse({"ok": False, "error": "bad_json"}, status_code=400) try: from src.telegram_bot import process_update, is_configured if is_configured(): background_tasks.add_task(process_update, update) except Exception as exc: # noqa: BLE001 - never fail the webhook ACK print(f"[telegram] dispatch error: {exc}") return {"ok": True} # ── /api/form-assist ────────────────────────────────────────────────────────── @app.post("/api/form-assist") async def form_assist( fields_json: str = Form("[]"), profile_json: str = Form("{}"), resume_latex: str = Form(""), jd_text: str = Form(""), job_title: str = Form(""), company: str = Form(""), x_api_token: str = Header(None), ): """Answer job-application form fields from the candidate profile, resume LaTeX, and optional job-description context. Used by the browser extension's autofill flow for the fields that can't be filled deterministically.""" _check_token(x_api_token) try: fields = json.loads(fields_json or "[]") if not isinstance(fields, list): fields = [] except Exception: fields = [] if not fields: return JSONResponse({"answers": [], "answered_count": 0, "field_count": 0}) if not (resume_latex or "").strip(): try: from src.default_resume import get_default_resume_latex resume_latex = get_default_resume_latex() except Exception: # noqa: BLE001 resume_latex = "" try: from src.form_autofill import answer_application_fields loop = asyncio.get_event_loop() answers = await loop.run_in_executor( None, lambda: answer_application_fields( resume_latex=resume_latex, jd_text=jd_text, job_title=job_title, company=company, profile_json=profile_json, fields=fields, ), ) return JSONResponse({ "answers": answers, "answered_count": len([a for a in answers if (a.get("value") or "").strip()]), "field_count": len(fields), }) except Exception as exc: return JSONResponse( {"error": "form_assist_failed", "detail": str(exc)[:240]}, status_code=500, ) # ── /api/generate ───────────────────────────────────────────────────────────── @app.post("/api/generate") async def generate( jd_text: str = Form(""), jd_url: str = Form(""), # Telegram relay: fetch JD from a link job_title: str = Form(""), company: str = Form(""), maximum_ats_mode: str = Form(""), # "1"/"true" enables Maximum ATS Mode user_confirmed_expansion: str = Form(""), # alias for maximum_ats_mode confirmed_terms: str = Form(""), # optional comma/newline list resume_latex: str = Form(""), # LaTeX source (PRIORITISED over PDF) version: str = Form(""), # "v1" or "v2"; empty → GEN_VERSION_DEFAULT env resume: UploadFile = None, x_api_token: str = Header(None), ): """ Generate a tailored resume DOCX (and optional PDF) for the supplied JD. Request: multipart/form-data - jd_text — full job description text (required unless jd_url given) - jd_url — a job link; server fetches + extracts the JD (Telegram relay) - job_title (optional) — for recruiter pitch header - company (optional) — for recruiter pitch header - version — "v1" or "v2"; empty → GEN_VERSION_DEFAULT env (default v2) - resume — PDF bytes; OPTIONAL — falls back to the bundled default resume Header: X-Api-Token — must match API_SECRET_TOKEN env var (when set) Response: JSON with docx_b64, pdf_b64, status, scores, quality_flag, etc. """ _check_token(x_api_token) max_ats = _truthy(maximum_ats_mode) or _truthy(user_confirmed_expansion) conf_terms = _term_list(confirmed_terms) # ── jd_url → fetch + extract the JD server-side (used by the Telegram relay) ── if not (jd_text or "").strip() and (jd_url or "").strip(): try: from src.jd_from_url import fetch_jd_from_url res = fetch_jd_from_url(jd_url.strip()) if res.get("ok"): jd_text = res["jd_text"] job_title = job_title or res.get("job_title", "") else: return JSONResponse( {"error": "jd_unreadable", "detail": "Couldn't read that link server-side. Paste the job " "description text instead."}, status_code=200, ) except Exception as exc: # noqa: BLE001 return JSONResponse({"error": "jd_fetch_failed", "detail": str(exc)[:200]}, status_code=200) if not (jd_text or "").strip(): return JSONResponse({"error": "jd_required", "detail": "Provide jd_text or jd_url."}, status_code=200) # ── Resume fallback: no LaTeX and no PDF → use the bundled default resume. ──── if not (resume_latex or "").strip() and resume is None: try: from src.default_resume import get_default_resume_latex resume_latex = get_default_resume_latex() except Exception: # noqa: BLE001 pass # ── LaTeX-first: if the user supplied LaTeX source, use it (priority over the # uploaded PDF) for keyword matching + scoring, then compile to PDF. ────── if (resume_latex or "").strip(): _version = (version or "").strip().lower() or os.getenv("GEN_VERSION_DEFAULT", "v2") if _version == "v2": return await _generate_from_latex_v2( resume_latex, jd_text, job_title, company, max_ats, conf_terms ) return await _generate_from_latex( resume_latex, jd_text, job_title, company, max_ats, conf_terms ) if resume is None: raise HTTPException(status_code=422, detail="resume_required") pdf_bytes = await resume.read() if not pdf_bytes: raise HTTPException(status_code=422, detail="resume_required") tmp_out: str | None = None result_path: str | None = None try: # Run the blocking pipeline in a thread pool to keep the event loop free. loop = asyncio.get_event_loop() result_path, v2_report = await loop.run_in_executor( None, generate_resume_for_api, pdf_bytes, jd_text, job_title, company, max_ats, conf_terms, ) if not result_path: return JSONResponse( {"error": "generation_failed", "detail": "pipeline returned no output"}, status_code=500, ) # Encode DOCX as base64. with open(result_path, "rb") as f: docx_bytes = f.read() docx_b64 = base64.b64encode(docx_bytes).decode("ascii") # Look for an optional PDF sidecar (same stem, .pdf) from pdf_writer. pdf_b64: str | None = None pdf_sidecar = os.path.splitext(result_path)[0] + ".pdf" if os.path.exists(pdf_sidecar): with open(pdf_sidecar, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode("ascii") # Map report keys to the response contract (see 06-RESEARCH.md Section 2). est_scores = v2_report.get("estimated_scores", {}) or {} response_payload = { "status": v2_report.get("status", "NEEDS_USER_INPUT"), "download_allowed": bool(v2_report.get("download_allowed", False)), "scores": { "jd_match": est_scores.get("jd_match"), "ats_readability": est_scores.get("ats_readability"), "independent_jd_match": v2_report.get("independent_jd_match"), }, "quality_flag": v2_report.get("quality_flag", ""), "review_terms": v2_report.get("review_terms", []), "high_risk_terms": v2_report.get("high_risk_terms", v2_report.get("high_risk_terms_for_confirmation", [])), "maximum_ats_mode": max_ats, "external_coverage": v2_report.get("external_coverage", {}), "external_coverage_pct": v2_report.get("external_coverage_pct"), "coverage_report": v2_report.get("coverage_report", {}), "keyword_coverage": v2_report.get("keyword_coverage", {}), "docx_b64": docx_b64, "pdf_b64": pdf_b64, } print(f"[api/generate] maximum_ats_mode={max_ats} status={response_payload['status']} " f"internal={response_payload['scores'].get('jd_match')} " f"external_cov={response_payload.get('external_coverage_pct')}") return JSONResponse(response_payload) except HTTPException: raise except Exception as exc: return JSONResponse( {"error": "generation_failed", "detail": str(exc)}, status_code=500, ) finally: # Clean up the temp directory that generate_resume_for_api created. if result_path: parent = os.path.dirname(result_path) shutil.rmtree(parent, ignore_errors=True) # ── External ATS Feedback Repair helper ────────────────────────────────────── def repair_resume_for_api( pdf_bytes: bytes, jd_text: str, job_title: str, company: str, feedback: str, missing_keywords: list, external_score, maximum_ats_mode: bool = False, confirmed_terms: list | None = None, target_external_score=None, ) -> dict: """Re-tailor using pasted external-checker feedback. Honest: every keyword is risk-classified; BLOCKED + unconfirmed HIGH terms are never added. Returns the enriched repair result dict (incl. resume_path + coverage_report).""" from src.resume_parser_v2 import parse_resume_pdf from src.llm_client import LLMClient from src.jobalytics_repair import repair_with_external_feedback pdf_hash = hashlib.sha256(pdf_bytes).hexdigest() if pdf_hash in _RESUME_CACHE: base_resume = _RESUME_CACHE[pdf_hash] else: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_pdf: tmp_pdf.write(pdf_bytes) tmp_pdf_path = tmp_pdf.name try: base_resume = parse_resume_pdf(tmp_pdf_path) finally: try: os.unlink(tmp_pdf_path) except OSError: pass _RESUME_CACHE[pdf_hash] = base_resume tmp_out = tempfile.mkdtemp() job = { "title": job_title, "company": company, "description": (jd_text or "")[:16000], "ats_keywords": "", "_raw_assessment": {}, } result = repair_with_external_feedback( job, feedback_text=feedback or None, missing_keywords=missing_keywords or None, external_score=external_score, llm=LLMClient(), base_resume=base_resume, output_dir=tmp_out, maximum_ats_mode=maximum_ats_mode, confirmed_terms=confirmed_terms, target_external_score=target_external_score, ) # Render a PDF sidecar for the repaired DOCX (reportlab fallback off-Windows) # so the repair PDF button enables too. Non-fatal on failure. try: rp = result.get("resume_path") if rp: from src.pdf_writer import docx_to_pdf docx_to_pdf(rp) except Exception: pass # Persist user-confirmed expansion terms to the vault so future resumes treat # them as safe (only the terms actually added under confirmation). try: if maximum_ats_mode and not result.get("error"): from src.candidate_vault import confirm_expansion_terms confirm_expansion_terms(result.get("added_terms", [])) if confirmed_terms: confirm_expansion_terms(confirmed_terms) result["vault_added"] = result.get("added_terms", []) except Exception: pass return result # ── /api/repair-with-feedback ───────────────────────────────────────────────── @app.post("/api/repair-with-feedback") async def repair_with_feedback( jd_text: str = Form(...), job_title: str = Form(""), company: str = Form(""), feedback: str = Form(""), missing_keywords: str = Form(""), # optional comma/newline list external_score: str = Form(""), maximum_ats_mode: str = Form(""), user_confirmed_expansion: str = Form(""), confirmed_terms: str = Form(""), target_external_score: str = Form(""), resume_latex: str = Form(""), # LaTeX source (PRIORITISED over PDF) resume: UploadFile = None, x_api_token: str = Header(None), ): """External ATS Feedback Repair Mode. Paste Jobalytics/Simplify feedback (or an explicit missing-keyword list); re-tailor honestly and re-score from the re-parsed export. BLOCKED + unconfirmed HIGH terms are never fabricated.""" _check_token(x_api_token) if not feedback.strip() and not missing_keywords.strip(): raise HTTPException(status_code=422, detail="feedback_or_keywords_required") max_ats = _truthy(maximum_ats_mode) or _truthy(user_confirmed_expansion) conf_terms = _term_list(confirmed_terms) or None # ── LaTeX-first repair: inject the externally-reported missing keywords into # the user's LaTeX and recompile. Pasted terms come from an explicit list # AND/OR the parsed Jobalytics/Simplify feedback text. ─────────────────── if (resume_latex or "").strip(): pasted = _term_list(missing_keywords) if feedback.strip(): try: from src.jobalytics_repair import parse_external_feedback parsed = parse_external_feedback(feedback) pasted = list(dict.fromkeys(pasted + parsed.get("missing_keywords", []))) except Exception: pass return await _repair_from_latex( resume_latex, jd_text, job_title, company, max_ats, conf_terms or [], pasted, ) if resume is None: raise HTTPException(status_code=422, detail="resume_required") pdf_bytes = await resume.read() if not pdf_bytes: raise HTTPException(status_code=422, detail="resume_required") mk = _term_list(missing_keywords) or None ext = None try: ext = int(re.sub(r"[^\d]", "", external_score)) if external_score.strip() else None except ValueError: ext = None tgt = None try: tgt = int(re.sub(r"[^\d]", "", target_external_score)) if target_external_score.strip() else None except ValueError: tgt = None max_ats = _truthy(maximum_ats_mode) or _truthy(user_confirmed_expansion) conf_terms = _term_list(confirmed_terms) or None result_path = None try: loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, repair_resume_for_api, pdf_bytes, jd_text, job_title, company, feedback, mk, ext, max_ats, conf_terms, tgt, ) print(f"[api/repair] maximum_ats_mode={max_ats} status={result.get('status')} " f"after_cov={result.get('after_coverage', {}).get('pct')} " f"added={len(result.get('added_terms', []))}") if result.get("error"): return JSONResponse( {"error": result["error"], "detail": result.get("detail", "")}, status_code=400 if result["error"] == "no_missing_keywords" else 500, ) result_path = result.get("resume_path") docx_b64 = pdf_b64 = None if result_path and os.path.exists(result_path): with open(result_path, "rb") as f: docx_b64 = base64.b64encode(f.read()).decode("ascii") pdf_sidecar = os.path.splitext(result_path)[0] + ".pdf" if os.path.exists(pdf_sidecar): with open(pdf_sidecar, "rb") as f: pdf_b64 = base64.b64encode(f.read()).decode("ascii") sc = result.get("scores", {}) return JSONResponse({ "status": result.get("status", "NEEDS_USER_INPUT"), "download_allowed": bool(result.get("download_allowed", False)), "scores": { "jd_match": sc.get("internal_jd_match"), "ats_readability": sc.get("ats_readability"), "independent_jd_match": sc.get("independent_jd_match"), }, "external_score": result.get("external_score"), "target_external_score": result.get("target_external_score"), "maximum_ats_mode": result.get("maximum_ats_mode", max_ats), "before_coverage": result.get("before_coverage"), "after_coverage": result.get("after_coverage"), "added_terms": result.get("added_terms", []), "review_flag_terms": result.get("review_flag_terms", []), "unresolved_high_risk_terms": result.get("unresolved_high_risk_terms", []), "blocked_terms": result.get("blocked_terms", []), "already_present_terms": result.get("already_present_terms", []), "still_missing_repairable": result.get("still_missing_repairable", []), "coverage_report": result.get("coverage_report", {}), "below_target_explanation": result.get("below_target_explanation", ""), "vault_added": result.get("vault_added", []), "docx_b64": docx_b64, "pdf_b64": pdf_b64, }) except HTTPException: raise except Exception as exc: return JSONResponse({"error": "repair_failed", "detail": str(exc)}, status_code=500) finally: if result_path: shutil.rmtree(os.path.dirname(result_path), ignore_errors=True) # ── Streamlit subprocess ────────────────────────────────────────────────────── _streamlit_proc: subprocess.Popen | None = None _STREAMLIT_PORT = 8501 _DEBUG_LOG_PATH = "debug-03623a.log" def _debug_log(run_id: str, hypothesis_id: str, location: str, message: str, data: dict) -> None: """Append one NDJSON runtime debug event for this session.""" payload = { "sessionId": "03623a", "runId": run_id, "hypothesisId": hypothesis_id, "location": location, "message": message, "data": data, "timestamp": int(time.time() * 1000), } try: with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as f: f.write(json.dumps(payload, separators=(",", ":")) + "\n") except Exception: pass def start_streamlit() -> None: """Launch Streamlit on an internal port so the proxy can reach it.""" global _streamlit_proc _streamlit_proc = subprocess.Popen( [ sys.executable, "-m", "streamlit", "run", "ui.py", "--server.port", str(_STREAMLIT_PORT), "--server.address", "127.0.0.1", "--server.headless", "true", # Required for reverse-proxy operation: without these, Streamlit's # CORS/XSRF middleware rejects requests whose Origin header comes # from the public HF Space domain rather than 127.0.0.1:8501. "--server.enableCORS", "false", "--server.enableXsrfProtection", "false", ] ) # #region agent log _debug_log( run_id="open-check-1", hypothesis_id="H4", location="api_server.py:start_streamlit", message="streamlit_process_started", data={"pid": _streamlit_proc.pid, "port": _STREAMLIT_PORT}, ) # #endregion # ── WebSocket proxy for /_stcore/stream (Streamlit live updates) ────────────── @app.websocket("/_stcore/stream") async def ws_proxy(websocket: WebSocket): """ Bidirectional WebSocket proxy to Streamlit's internal port. httpx does NOT handle WebSocket upgrades — this route handles that separately. """ import websockets as _ws import websockets.exceptions as _ws_exc # Streamlit 1.45+ requires the 'streamlit' WebSocket subprotocol to be # negotiated during the handshake. Without it the upstream closes immediately. # Extract whatever subprotocols the browser offered and mirror them back. subprotocols_raw = websocket.headers.get("sec-websocket-protocol", "") subprotocols = [p.strip() for p in subprotocols_raw.split(",") if p.strip()] await websocket.accept(subprotocol=subprotocols[0] if subprotocols else None) # #region agent log _debug_log( run_id="open-check-1", hypothesis_id="H3", location="api_server.py:ws_proxy", message="ws_client_connected", data={"client": str(websocket.client), "subprotocols": subprotocols}, ) # #endregion upstream_url = ( f"ws://127.0.0.1:{_STREAMLIT_PORT}/_stcore/stream" f"?{websocket.scope.get('query_string', b'').decode()}" ) try: # Pass the same subprotocol(s) to the upstream Streamlit WebSocket. connect_kwargs: dict = {} if subprotocols: connect_kwargs["subprotocols"] = subprotocols async with _ws.connect(upstream_url, **connect_kwargs) as upstream: async def client_to_upstream(): # Use receive() directly to avoid consuming a frame on type # mismatch (receive_bytes/receive_text each consume the frame # before raising, so the double-receive pattern loses messages). try: while True: msg = await websocket.receive() if msg["type"] == "websocket.disconnect": break data = msg.get("bytes") text = msg.get("text") if data is not None: await upstream.send(data) elif text is not None: await upstream.send(text) except Exception: pass async def upstream_to_client(): try: async for message in upstream: if isinstance(message, bytes): await websocket.send_bytes(message) else: await websocket.send_text(message) except _ws_exc.ConnectionClosed: pass await asyncio.gather(client_to_upstream(), upstream_to_client()) except Exception as exc: # #region agent log _debug_log( run_id="open-check-1", hypothesis_id="H3", location="api_server.py:ws_proxy", message="ws_upstream_connect_or_proxy_failed", data={"error": str(exc)[:200]}, ) # #endregion finally: try: await websocket.close() except Exception: pass # ── HTTP reverse proxy catch-all → Streamlit ───────────────────────────────── # FastAPI routes (/api/*) take priority; everything else is proxied to Streamlit. @app.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"], ) async def proxy(request: Request, path: str): """Proxy all non-/api/* requests to the internal Streamlit server.""" # Never proxy /api/* to Streamlit — if it reaches here it's an unknown API # route; return a clean JSON 404 (prevents a confusing blank Streamlit page). if path == "api" or path.startswith("api/"): return JSONResponse({"error": "not_found", "path": "/" + path}, status_code=404) # #region agent log _debug_log( run_id="open-check-1", hypothesis_id="H2", location="api_server.py:proxy_entry", message="proxy_request_received", data={"method": request.method, "path": path}, ) # #endregion url = f"http://127.0.0.1:{_STREAMLIT_PORT}/{path}" params = dict(request.query_params) headers = { k: v for k, v in request.headers.items() if k.lower() != "host" } body = await request.body() async with httpx.AsyncClient(timeout=60.0) as client: try: resp = await client.request( method=request.method, url=url, headers=headers, content=body, params=params, ) # httpx already DECOMPRESSED resp.content, so we must drop the # upstream content-encoding/length (and hop-by-hop headers) — copying # them makes the browser try to gunzip plain bytes → blank page. _DROP = {"content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"} clean_headers = {k: v for k, v in resp.headers.items() if k.lower() not in _DROP} # #region agent log _debug_log( run_id="open-check-1", hypothesis_id="H2", location="api_server.py:proxy_response", message="proxy_upstream_response", data={ "path": path, "status_code": resp.status_code, "content_type": resp.headers.get("content-type", ""), }, ) # #endregion return StarletteResponse( content=resp.content, status_code=resp.status_code, headers=clean_headers, media_type=resp.headers.get("content-type"), ) except httpx.ConnectError as exc: # #region agent log _debug_log( run_id="open-check-1", hypothesis_id="H4", location="api_server.py:proxy_connect_error", message="streamlit_connect_error", data={"path": path, "error": str(exc)[:200]}, ) # #endregion # Streamlit may still be starting up — return a friendly retry message. return StarletteResponse( content=b"Streamlit is starting, please wait...", status_code=503, headers={"Retry-After": "5"}, ) # ── Entrypoint ──────────────────────────────────────────────────────────────── if __name__ == "__main__": start_streamlit() uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")