Spaces:
Running
Running
Commit ·
f057ca2
1
Parent(s): 431d531
feat(phase-10): V2 natural sentence resume mode — Kimi LLM generates sentences, same V1 waterfall
Browse filesV2 engine (src/resume_v2_natural.py) uses Kimi-K2.6 to generate natural sentences
from keywords, placed in the same waterfall locations as V1. Falls back to V1 comma
placement if LLM fails. All touchpoints wired: API dispatch, HF bulk, extension
popup/options, Cloudflare relay, Telegram bot. 17 V2 tests + 11 V1 regression pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .planning/phases/10-v1-v2-resume-modes/10-01-SUMMARY.md +41 -0
- HISTORY.md +31 -0
- README.md +39 -0
- api_server.py +183 -0
- extension/background.js +201 -4
- extension/content.js +207 -0
- extension/manifest.json +1 -1
- extension/options/options.html +13 -0
- extension/options/options.js +21 -1
- extension/popup/popup.html +14 -0
- extension/popup/popup.js +85 -1
- relay/cloudflare-worker.js +24 -1
- src/resume_v2_natural.py +563 -0
- src/telegram_bot.py +30 -6
- tests/test_resume_v2.py +237 -0
- ui.py +54 -10
.planning/phases/10-v1-v2-resume-modes/10-01-SUMMARY.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
phase: 10-v1-v2-resume-modes
|
| 3 |
+
plan: 01
|
| 4 |
+
type: summary
|
| 5 |
+
status: DONE
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# 10-01 Summary: V2 Engine + API Dispatch
|
| 9 |
+
|
| 10 |
+
## What was built
|
| 11 |
+
|
| 12 |
+
### src/resume_v2_natural.py (new, ~350 lines)
|
| 13 |
+
- `generate_v2()` — main entry point: extract keywords, allocate (same V1 waterfall),
|
| 14 |
+
call LLM to generate natural sentences, place in resume, compile
|
| 15 |
+
- `_allocate_keywords()` — exact V1 waterfall: Summary 15-20, PSM 25-30, PS 25-30,
|
| 16 |
+
ML Edutech 8-12, Skills Other 15-20, Projects, NxtWave
|
| 17 |
+
- `_build_user_prompt()` / `_SYSTEM_PROMPT` — LLM prompt with profile context
|
| 18 |
+
- `_place_sentences_structured()` — inject sentences into same anchors as V1
|
| 19 |
+
- `_v2_honesty_check()` — regulated credential check on generated text
|
| 20 |
+
- `_validate_candidate()` — identity marker + structure validation
|
| 21 |
+
- `_fan_out()` / `_judge_candidates()` — optional multi-model fan-out
|
| 22 |
+
- Falls back to V1 comma placement if LLM fails
|
| 23 |
+
|
| 24 |
+
### api_server.py changes
|
| 25 |
+
- `version: str = Form("")` added to `/api/generate`
|
| 26 |
+
- Dispatch: `_version == "v2"` routes to `_generate_from_latex_v2()`
|
| 27 |
+
- `_generate_from_latex_v2()` wraps `generate_v2()` with identical payload shape to V1
|
| 28 |
+
|
| 29 |
+
## Key decisions (modified from original plan)
|
| 30 |
+
- **Kimi-K2.6** as default V2 model (env `V2_JUDGE_MODEL`), not MiniMax
|
| 31 |
+
- **Sentence-based** placement in same waterfall locations as V1 (not whole-resume rewrite)
|
| 32 |
+
- Single-model fast path by default (~5s per job); multi-model fan-out optional
|
| 33 |
+
- V1 remains the safe default (`GEN_VERSION_DEFAULT=v1`)
|
| 34 |
+
|
| 35 |
+
## Verification
|
| 36 |
+
- `from src.resume_v2_natural import generate_v2` — imports OK
|
| 37 |
+
- `_fan_out_cfgs()` returns Kimi/Qwen/GPT-OSS configs
|
| 38 |
+
- `_v2_model_cfg()` returns Kimi-K2.6
|
| 39 |
+
- `_validate_candidate` passes real resume, rejects short/non-LaTeX
|
| 40 |
+
- api_server.py parses with `_generate_from_latex_v2` present
|
| 41 |
+
- Version field + dispatch present in `/api/generate`
|
HISTORY.md
CHANGED
|
@@ -4,6 +4,37 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## 2026-06-24 (AM5) — Clean server-side JD extraction (JD only, all platforms)
|
| 8 |
|
| 9 |
First successful Telegram run worked but the resume was polluted: the server-side
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## 2026-06-24 — Phase 10: V1/V2 Resume Generation Modes
|
| 8 |
+
|
| 9 |
+
Labeled the existing pipeline as **V1** (structured keyword placement) and added
|
| 10 |
+
**V2** (natural AI sentence integration). Both modes are live across all three
|
| 11 |
+
touchpoints: HF Streamlit, Chrome extension, and Telegram bot.
|
| 12 |
+
|
| 13 |
+
**V2 engine** (`src/resume_v2_natural.py`): Same keyword extraction + waterfall
|
| 14 |
+
allocation as V1 (Summary 15-20, BYJU's PSM 25-30, PS 25-30, ML Edutech 8-12,
|
| 15 |
+
Skills Other 15-20, Projects/NxtWave), but an LLM (Kimi-K2.6 by default, ~5s)
|
| 16 |
+
generates natural sentences that weave the keywords into genuine experience
|
| 17 |
+
bullets. Falls back to V1 comma placement if the LLM call fails. Honesty
|
| 18 |
+
boundaries preserved (regulated credentials + specialty terms never injected).
|
| 19 |
+
|
| 20 |
+
**API dispatch**: `/api/generate` accepts `version=v1|v2`; default from
|
| 21 |
+
`GEN_VERSION_DEFAULT` env (v1). V2 response payload mirrors V1 (tex_b64, pdf_b64,
|
| 22 |
+
status, external_coverage_pct) plus `v2_models_used`, `v2_winner`, `judge_note`.
|
| 23 |
+
|
| 24 |
+
**Touchpoints**:
|
| 25 |
+
- HF Streamlit: V1/V2 radio on the home page; bulk pipeline honors V2 selection
|
| 26 |
+
- Chrome extension: Options default (`gen_version_default`) + popup per-run toggle;
|
| 27 |
+
background forwards `version` to `/api/generate`; popup renders `latex_v2` source
|
| 28 |
+
- Telegram: `/v1`, `/v2`, `/mode` commands (relay via Workers KV, bot via in-memory)
|
| 29 |
+
- Cloudflare relay: forwards `version` + per-user KV state
|
| 30 |
+
|
| 31 |
+
**Key decisions**:
|
| 32 |
+
- Kimi-K2.6 as default V2 model (fastest at ~5s, configurable via `V2_JUDGE_MODEL`)
|
| 33 |
+
- V2 works in bulk pipeline (one LLM call per job ≈ 5-10s; acceptable for 30 jobs)
|
| 34 |
+
- V1 remains the safe default (GEN_VERSION_DEFAULT=v1)
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
## 2026-06-24 (AM5) — Clean server-side JD extraction (JD only, all platforms)
|
| 39 |
|
| 40 |
First successful Telegram run worked but the resume was polluted: the server-side
|
README.md
CHANGED
|
@@ -404,6 +404,45 @@ Verify (deterministic, no keys):
|
|
| 404 |
|
| 405 |
---
|
| 406 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
## Google Sheet Columns
|
| 408 |
|
| 409 |
| Column | Description |
|
|
|
|
| 404 |
|
| 405 |
---
|
| 406 |
|
| 407 |
+
## Generation Modes (V1 / V2)
|
| 408 |
+
|
| 409 |
+
Two resume tailoring modes, selectable per surface:
|
| 410 |
+
|
| 411 |
+
### V1 — Structured Keyword Placement (default)
|
| 412 |
+
|
| 413 |
+
Uncapped JD keyword extraction + ordered placement into the hardcoded resume
|
| 414 |
+
(Phase 9). Keywords are appended as comma-separated `\resumeItem` lines in a
|
| 415 |
+
waterfall order: Summary 15-20, BYJU's PSM 25-30, PS 25-30, ML Edutech 8-12,
|
| 416 |
+
Skills Other 15-20, Projects, NxtWave. Fast (no LLM call for placement);
|
| 417 |
+
keywords appear verbatim.
|
| 418 |
+
|
| 419 |
+
### V2 — Natural AI Sentence Integration
|
| 420 |
+
|
| 421 |
+
Same keyword extraction + waterfall allocation as V1, but an LLM (Kimi-K2.6 by
|
| 422 |
+
default, ~5s) generates **natural sentences** that weave the keywords into
|
| 423 |
+
genuine-sounding experience bullets. Reads as natural prose rather than keyword
|
| 424 |
+
lists; honesty boundaries preserved; slightly slower (one LLM call + compile).
|
| 425 |
+
Falls back to V1 comma placement if the LLM call fails.
|
| 426 |
+
|
| 427 |
+
### Selecting a version
|
| 428 |
+
|
| 429 |
+
| Surface | How to choose |
|
| 430 |
+
|---------|---------------|
|
| 431 |
+
| `/api/generate` | `version=v1` or `version=v2` form field; default from `GEN_VERSION_DEFAULT` env (v1) |
|
| 432 |
+
| HF Streamlit | V1/V2 radio on the home page; bulk pipeline honors the selection |
|
| 433 |
+
| Chrome extension | Default in Options (`gen_version_default`) + per-run toggle in the popup |
|
| 434 |
+
| Telegram bot | `/v1`, `/v2` commands set per-user default; `/mode` shows current |
|
| 435 |
+
|
| 436 |
+
### Env knobs
|
| 437 |
+
|
| 438 |
+
| Variable | Default | Description |
|
| 439 |
+
|----------|---------|-------------|
|
| 440 |
+
| `GEN_VERSION_DEFAULT` | `v1` | Default version when not specified |
|
| 441 |
+
| `V2_JUDGE_MODEL` | `Kimi-K2.6` | Model used for V2 sentence generation |
|
| 442 |
+
| `V2_FAN_OUT_MODELS` | `Kimi-K2.6,Qwen3.5-397b,GPT-OSS-120b` | Models for multi-model fan-out (when explicitly configured) |
|
| 443 |
+
|
| 444 |
+
---
|
| 445 |
+
|
| 446 |
## Google Sheet Columns
|
| 447 |
|
| 448 |
| Column | Description |
|
api_server.py
CHANGED
|
@@ -336,6 +336,123 @@ async def _generate_from_latex(
|
|
| 336 |
shutil.rmtree(out_dir, ignore_errors=True)
|
| 337 |
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
async def _repair_from_latex(
|
| 340 |
latex_src: str, jd_text: str, job_title: str, company: str,
|
| 341 |
max_ats: bool, conf_terms: list, pasted_terms: list,
|
|
@@ -491,6 +608,65 @@ async def telegram_webhook(request: Request, background_tasks: BackgroundTasks):
|
|
| 491 |
return {"ok": True}
|
| 492 |
|
| 493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
# ── /api/generate ─────────────────────────────────────────────────────────────
|
| 495 |
@app.post("/api/generate")
|
| 496 |
async def generate(
|
|
@@ -502,6 +678,7 @@ async def generate(
|
|
| 502 |
user_confirmed_expansion: str = Form(""), # alias for maximum_ats_mode
|
| 503 |
confirmed_terms: str = Form(""), # optional comma/newline list
|
| 504 |
resume_latex: str = Form(""), # LaTeX source (PRIORITISED over PDF)
|
|
|
|
| 505 |
resume: UploadFile = None,
|
| 506 |
x_api_token: str = Header(None),
|
| 507 |
):
|
|
@@ -513,6 +690,7 @@ async def generate(
|
|
| 513 |
- jd_url — a job link; server fetches + extracts the JD (Telegram relay)
|
| 514 |
- job_title (optional) — for recruiter pitch header
|
| 515 |
- company (optional) — for recruiter pitch header
|
|
|
|
| 516 |
- resume — PDF bytes; OPTIONAL — falls back to the bundled default resume
|
| 517 |
Header: X-Api-Token — must match API_SECRET_TOKEN env var (when set)
|
| 518 |
|
|
@@ -557,6 +735,11 @@ async def generate(
|
|
| 557 |
# ── LaTeX-first: if the user supplied LaTeX source, use it (priority over the
|
| 558 |
# uploaded PDF) for keyword matching + scoring, then compile to PDF. ──────
|
| 559 |
if (resume_latex or "").strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
return await _generate_from_latex(
|
| 561 |
resume_latex, jd_text, job_title, company, max_ats, conf_terms
|
| 562 |
)
|
|
|
|
| 336 |
shutil.rmtree(out_dir, ignore_errors=True)
|
| 337 |
|
| 338 |
|
| 339 |
+
async def _generate_from_latex_v2(
|
| 340 |
+
latex_src: str, jd_text: str, job_title: str, company: str,
|
| 341 |
+
max_ats: bool, conf_terms: list,
|
| 342 |
+
) -> JSONResponse:
|
| 343 |
+
"""V2: sentence-based keyword integration via LLM, same waterfall as V1."""
|
| 344 |
+
out_dir: str | None = None
|
| 345 |
+
try:
|
| 346 |
+
from src.resume_v2_natural import generate_v2
|
| 347 |
+
loop = asyncio.get_event_loop()
|
| 348 |
+
out_dir = tempfile.mkdtemp(prefix="latex_v2_")
|
| 349 |
+
report = await loop.run_in_executor(
|
| 350 |
+
None,
|
| 351 |
+
lambda: generate_v2(
|
| 352 |
+
latex_src, jd_text, job_title=job_title, company=company,
|
| 353 |
+
out_dir=out_dir, compile_pdf=True,
|
| 354 |
+
),
|
| 355 |
+
)
|
| 356 |
+
pct = int(report.get("pct") or 0)
|
| 357 |
+
pdf_path = report.get("pdf_path")
|
| 358 |
+
tex = report.get("tex") or latex_src
|
| 359 |
+
compiled = report.get("compiled", False)
|
| 360 |
+
|
| 361 |
+
tex_b64 = base64.b64encode(tex.encode("utf-8")).decode("ascii")
|
| 362 |
+
pdf_b64 = None
|
| 363 |
+
if pdf_path:
|
| 364 |
+
try:
|
| 365 |
+
with open(pdf_path, "rb") as _f:
|
| 366 |
+
pdf_b64 = base64.b64encode(_f.read()).decode("ascii")
|
| 367 |
+
except Exception:
|
| 368 |
+
pass
|
| 369 |
+
|
| 370 |
+
pdf_error = None
|
| 371 |
+
if not pdf_b64:
|
| 372 |
+
engine = report.get("engine")
|
| 373 |
+
pdf_error = "engine_missing" if not engine else "latex_error"
|
| 374 |
+
|
| 375 |
+
pdf_fallback = False
|
| 376 |
+
if not pdf_b64:
|
| 377 |
+
try:
|
| 378 |
+
from src.latex_resume import latex_to_text, render_text_to_pdf
|
| 379 |
+
fb_path = os.path.join(out_dir, "resume_v2_fallback.pdf")
|
| 380 |
+
if (render_text_to_pdf(latex_to_text(tex or ""), fb_path)
|
| 381 |
+
and os.path.exists(fb_path)):
|
| 382 |
+
with open(fb_path, "rb") as _f:
|
| 383 |
+
pdf_b64 = base64.b64encode(_f.read()).decode("ascii")
|
| 384 |
+
pdf_fallback = True
|
| 385 |
+
except Exception as exc:
|
| 386 |
+
print(f"[api/generate:v2] fallback pdf render failed: {exc}")
|
| 387 |
+
|
| 388 |
+
from src.fit_gate import MAX_ATS_READY_STATUSES
|
| 389 |
+
status = _latex_status(pct, max_ats, False)
|
| 390 |
+
download_allowed = status in MAX_ATS_READY_STATUSES
|
| 391 |
+
|
| 392 |
+
payload = {
|
| 393 |
+
"status": status,
|
| 394 |
+
"source": "latex_v2",
|
| 395 |
+
"version": "v2",
|
| 396 |
+
"download_allowed": bool(download_allowed),
|
| 397 |
+
"maximum_ats_mode": max_ats,
|
| 398 |
+
"scores": {
|
| 399 |
+
"jd_match": pct,
|
| 400 |
+
"ats_readability": 100,
|
| 401 |
+
"independent_jd_match": pct,
|
| 402 |
+
},
|
| 403 |
+
"external_coverage": {
|
| 404 |
+
"expected": report.get("expected", 0),
|
| 405 |
+
"found": report.get("found", 0),
|
| 406 |
+
"pct": pct,
|
| 407 |
+
"missing": report.get("missing", []),
|
| 408 |
+
},
|
| 409 |
+
"external_coverage_pct": pct,
|
| 410 |
+
"coverage_report": {
|
| 411 |
+
"keywords": report.get("keywords", []),
|
| 412 |
+
"coverage_count": f"{report.get('found', 0)}/{report.get('expected', 0)}",
|
| 413 |
+
},
|
| 414 |
+
"latex_engine": report.get("engine"),
|
| 415 |
+
"latex_compiled": compiled,
|
| 416 |
+
"compile_log": "",
|
| 417 |
+
"pdf_error": pdf_error,
|
| 418 |
+
"pdf_fallback": pdf_fallback,
|
| 419 |
+
"tex_b64": tex_b64,
|
| 420 |
+
"pdf_b64": pdf_b64,
|
| 421 |
+
"docx_b64": None,
|
| 422 |
+
"v2_models_used": report.get("v2_models_used", []),
|
| 423 |
+
"v2_winner": report.get("v2_winner", ""),
|
| 424 |
+
"judge_note": report.get("judge_note", ""),
|
| 425 |
+
"keywords": report.get("keywords", []),
|
| 426 |
+
}
|
| 427 |
+
print(f"[api/generate:v2] max_ats={max_ats} status={status} "
|
| 428 |
+
f"external_cov={pct}% compiled={compiled} "
|
| 429 |
+
f"winner={report.get('v2_winner')} judge={report.get('judge_note')}")
|
| 430 |
+
|
| 431 |
+
try:
|
| 432 |
+
from src.supabase_client import get_service_client, is_configured, get_owner_user_id
|
| 433 |
+
if is_configured() and tex:
|
| 434 |
+
uid = get_owner_user_id()
|
| 435 |
+
if uid:
|
| 436 |
+
get_service_client().table("generated_resumes").insert({
|
| 437 |
+
"job_title": job_title,
|
| 438 |
+
"company": company,
|
| 439 |
+
"tex_source": tex,
|
| 440 |
+
"ats_score": pct,
|
| 441 |
+
"user_id": uid,
|
| 442 |
+
}).execute()
|
| 443 |
+
except Exception as _sb_exc:
|
| 444 |
+
print(f"[api/generate:v2] supabase save failed (non-fatal): {_sb_exc}")
|
| 445 |
+
|
| 446 |
+
return JSONResponse(payload)
|
| 447 |
+
except Exception as exc:
|
| 448 |
+
return JSONResponse(
|
| 449 |
+
{"error": "v2_engine_error", "detail": str(exc)[:400]}, status_code=200
|
| 450 |
+
)
|
| 451 |
+
finally:
|
| 452 |
+
if out_dir:
|
| 453 |
+
shutil.rmtree(out_dir, ignore_errors=True)
|
| 454 |
+
|
| 455 |
+
|
| 456 |
async def _repair_from_latex(
|
| 457 |
latex_src: str, jd_text: str, job_title: str, company: str,
|
| 458 |
max_ats: bool, conf_terms: list, pasted_terms: list,
|
|
|
|
| 608 |
return {"ok": True}
|
| 609 |
|
| 610 |
|
| 611 |
+
# ── /api/form-assist ──────────────────────────────────────────────────────────
|
| 612 |
+
@app.post("/api/form-assist")
|
| 613 |
+
async def form_assist(
|
| 614 |
+
fields_json: str = Form("[]"),
|
| 615 |
+
profile_json: str = Form("{}"),
|
| 616 |
+
resume_latex: str = Form(""),
|
| 617 |
+
jd_text: str = Form(""),
|
| 618 |
+
job_title: str = Form(""),
|
| 619 |
+
company: str = Form(""),
|
| 620 |
+
x_api_token: str = Header(None),
|
| 621 |
+
):
|
| 622 |
+
"""Answer job-application form fields from the candidate profile, resume
|
| 623 |
+
LaTeX, and optional job-description context. Used by the browser extension's
|
| 624 |
+
autofill flow for the fields that can't be filled deterministically."""
|
| 625 |
+
_check_token(x_api_token)
|
| 626 |
+
|
| 627 |
+
try:
|
| 628 |
+
fields = json.loads(fields_json or "[]")
|
| 629 |
+
if not isinstance(fields, list):
|
| 630 |
+
fields = []
|
| 631 |
+
except Exception:
|
| 632 |
+
fields = []
|
| 633 |
+
if not fields:
|
| 634 |
+
return JSONResponse({"answers": [], "answered_count": 0, "field_count": 0})
|
| 635 |
+
|
| 636 |
+
if not (resume_latex or "").strip():
|
| 637 |
+
try:
|
| 638 |
+
from src.default_resume import get_default_resume_latex
|
| 639 |
+
resume_latex = get_default_resume_latex()
|
| 640 |
+
except Exception: # noqa: BLE001
|
| 641 |
+
resume_latex = ""
|
| 642 |
+
|
| 643 |
+
try:
|
| 644 |
+
from src.form_autofill import answer_application_fields
|
| 645 |
+
|
| 646 |
+
loop = asyncio.get_event_loop()
|
| 647 |
+
answers = await loop.run_in_executor(
|
| 648 |
+
None,
|
| 649 |
+
lambda: answer_application_fields(
|
| 650 |
+
resume_latex=resume_latex,
|
| 651 |
+
jd_text=jd_text,
|
| 652 |
+
job_title=job_title,
|
| 653 |
+
company=company,
|
| 654 |
+
profile_json=profile_json,
|
| 655 |
+
fields=fields,
|
| 656 |
+
),
|
| 657 |
+
)
|
| 658 |
+
return JSONResponse({
|
| 659 |
+
"answers": answers,
|
| 660 |
+
"answered_count": len([a for a in answers if (a.get("value") or "").strip()]),
|
| 661 |
+
"field_count": len(fields),
|
| 662 |
+
})
|
| 663 |
+
except Exception as exc:
|
| 664 |
+
return JSONResponse(
|
| 665 |
+
{"error": "form_assist_failed", "detail": str(exc)[:240]},
|
| 666 |
+
status_code=500,
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
|
| 670 |
# ── /api/generate ─────────────────────────────────────────────────────────────
|
| 671 |
@app.post("/api/generate")
|
| 672 |
async def generate(
|
|
|
|
| 678 |
user_confirmed_expansion: str = Form(""), # alias for maximum_ats_mode
|
| 679 |
confirmed_terms: str = Form(""), # optional comma/newline list
|
| 680 |
resume_latex: str = Form(""), # LaTeX source (PRIORITISED over PDF)
|
| 681 |
+
version: str = Form(""), # "v1" or "v2"; empty → GEN_VERSION_DEFAULT env
|
| 682 |
resume: UploadFile = None,
|
| 683 |
x_api_token: str = Header(None),
|
| 684 |
):
|
|
|
|
| 690 |
- jd_url — a job link; server fetches + extracts the JD (Telegram relay)
|
| 691 |
- job_title (optional) — for recruiter pitch header
|
| 692 |
- company (optional) — for recruiter pitch header
|
| 693 |
+
- version — "v1" or "v2"; empty → GEN_VERSION_DEFAULT env (default v1)
|
| 694 |
- resume — PDF bytes; OPTIONAL — falls back to the bundled default resume
|
| 695 |
Header: X-Api-Token — must match API_SECRET_TOKEN env var (when set)
|
| 696 |
|
|
|
|
| 735 |
# ── LaTeX-first: if the user supplied LaTeX source, use it (priority over the
|
| 736 |
# uploaded PDF) for keyword matching + scoring, then compile to PDF. ──────
|
| 737 |
if (resume_latex or "").strip():
|
| 738 |
+
_version = (version or "").strip().lower() or os.getenv("GEN_VERSION_DEFAULT", "v1")
|
| 739 |
+
if _version == "v2":
|
| 740 |
+
return await _generate_from_latex_v2(
|
| 741 |
+
resume_latex, jd_text, job_title, company, max_ats, conf_terms
|
| 742 |
+
)
|
| 743 |
return await _generate_from_latex(
|
| 744 |
resume_latex, jd_text, job_title, company, max_ats, conf_terms
|
| 745 |
)
|
extension/background.js
CHANGED
|
@@ -52,6 +52,195 @@ async function resolveActiveTabUrl() {
|
|
| 52 |
}
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
// Shallow-merge a patch into the entry for `urlKey`, preserving existing fields,
|
| 56 |
// stamping savedAt, and pruning to the most-recent MAX_SAVED entries.
|
| 57 |
async function writeEntry(urlKey, patch) {
|
|
@@ -125,11 +314,17 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
| 125 |
handleDownload(msg).catch(console.error);
|
| 126 |
return false;
|
| 127 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
});
|
| 129 |
|
| 130 |
// ─── REPAIR (External ATS Feedback Repair Mode) ───────────────────────────────
|
| 131 |
|
| 132 |
-
async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats_mode, confirmed_terms, url }) {
|
| 133 |
const data = await new Promise(res => chrome.storage.local.get(
|
| 134 |
['resume_b64', 'resume_latex', 'api_url', 'api_token'], res
|
| 135 |
));
|
|
@@ -153,6 +348,7 @@ async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats
|
|
| 153 |
formData.append('feedback', feedback || '');
|
| 154 |
formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0');
|
| 155 |
if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms);
|
|
|
|
| 156 |
|
| 157 |
// LaTeX takes priority; only attach the PDF when no LaTeX is saved.
|
| 158 |
if (hasLatex) {
|
|
@@ -195,7 +391,7 @@ async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats
|
|
| 195 |
|
| 196 |
// ─── GENERATE ────────────────────────────────────────────────────────────────
|
| 197 |
|
| 198 |
-
async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, confirmed_terms, url }) {
|
| 199 |
// 1. Read settings from storage
|
| 200 |
const data = await new Promise(res => chrome.storage.local.get(
|
| 201 |
['resume_b64', 'resume_latex', 'api_url', 'api_token'], res
|
|
@@ -229,7 +425,7 @@ async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, c
|
|
| 229 |
});
|
| 230 |
|
| 231 |
// 3. Perform the network round-trip (no popup dependency).
|
| 232 |
-
const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms });
|
| 233 |
|
| 234 |
// 4. Overwrite the marker with the terminal state — done OR error — so the
|
| 235 |
// popup never shows a false/forever spinner. Done regardless of popup state.
|
|
@@ -243,7 +439,7 @@ async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, c
|
|
| 243 |
}
|
| 244 |
|
| 245 |
// Network/parse layer for GENERATE. Returns a result object (success or {error}).
|
| 246 |
-
async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms }) {
|
| 247 |
// Build multipart/form-data. LaTeX takes priority over the PDF.
|
| 248 |
const formData = new FormData();
|
| 249 |
formData.append('jd_text', jd_text);
|
|
@@ -251,6 +447,7 @@ async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, co
|
|
| 251 |
formData.append('company', company || '');
|
| 252 |
formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0');
|
| 253 |
if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms);
|
|
|
|
| 254 |
|
| 255 |
if (hasLatex) {
|
| 256 |
formData.append('resume_latex', resumeLatex || data.resume_latex);
|
|
|
|
| 52 |
}
|
| 53 |
}
|
| 54 |
|
| 55 |
+
function normalizeText(s) {
|
| 56 |
+
return String(s || '')
|
| 57 |
+
.toLowerCase()
|
| 58 |
+
.replace(/[_-]+/g, ' ')
|
| 59 |
+
.replace(/[^a-z0-9\s]/g, ' ')
|
| 60 |
+
.replace(/\s+/g, ' ')
|
| 61 |
+
.trim();
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function parseProfileJson(raw) {
|
| 65 |
+
if (!raw || !String(raw).trim()) return {};
|
| 66 |
+
try {
|
| 67 |
+
const data = JSON.parse(raw);
|
| 68 |
+
return data && typeof data === 'object' ? data : {};
|
| 69 |
+
} catch (_) {
|
| 70 |
+
return {};
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function profileIndex(profile) {
|
| 75 |
+
const out = { ...(profile || {}) };
|
| 76 |
+
const full = String(out.full_name || '').trim();
|
| 77 |
+
if (full) {
|
| 78 |
+
const parts = full.split(/\s+/);
|
| 79 |
+
if (!out.first_name && parts.length) out.first_name = parts[0];
|
| 80 |
+
if (!out.last_name && parts.length > 1) out.last_name = parts.slice(1).join(' ');
|
| 81 |
+
}
|
| 82 |
+
return out;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function valueOf(profile, key) {
|
| 86 |
+
const v = profile ? profile[key] : '';
|
| 87 |
+
return typeof v === 'string' || typeof v === 'number' ? String(v).trim() : '';
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function fieldText(field) {
|
| 91 |
+
return normalizeText([
|
| 92 |
+
field.label, field.name, field.placeholder, field.help_text, field.type,
|
| 93 |
+
].filter(Boolean).join(' '));
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function isLongAnswerField(field) {
|
| 97 |
+
return field.tag === 'textarea'
|
| 98 |
+
|| field.type === 'textarea'
|
| 99 |
+
|| (field.type === 'text' && /cover letter|why|tell us|describe|summary|experience|motivation|fit/.test(fieldText(field)));
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function localAutofillValue(field, profile) {
|
| 103 |
+
const text = fieldText(field);
|
| 104 |
+
const fullName = valueOf(profile, 'full_name');
|
| 105 |
+
const firstName = valueOf(profile, 'first_name');
|
| 106 |
+
const lastName = valueOf(profile, 'last_name');
|
| 107 |
+
|
| 108 |
+
if (!text) return '';
|
| 109 |
+
if (/first name|given name/.test(text)) return firstName;
|
| 110 |
+
if (/last name|family name|surname/.test(text)) return lastName;
|
| 111 |
+
if (/full name|your name|applicant name|candidate name/.test(text)) return fullName;
|
| 112 |
+
if (/email|e mail/.test(text)) return valueOf(profile, 'email');
|
| 113 |
+
if (/phone|mobile|contact number|telephone/.test(text)) return valueOf(profile, 'phone');
|
| 114 |
+
if (/linkedin/.test(text)) return valueOf(profile, 'linkedin_url');
|
| 115 |
+
if (/github/.test(text)) return valueOf(profile, 'github_url');
|
| 116 |
+
if (/portfolio|website|personal site/.test(text)) return valueOf(profile, 'portfolio_url');
|
| 117 |
+
if (/current company|current employer|employer|organization/.test(text)) return valueOf(profile, 'current_company');
|
| 118 |
+
if (/current title|job title|designation|headline/.test(text)) return valueOf(profile, 'current_title');
|
| 119 |
+
if (/location|current city|city state|address/.test(text)) return valueOf(profile, 'location');
|
| 120 |
+
if (/years of experience|total experience|experience in years|how many years/.test(text)) {
|
| 121 |
+
return valueOf(profile, 'years_experience');
|
| 122 |
+
}
|
| 123 |
+
if (/notice period|joining period|available to join|when can you start|start date/.test(text)) {
|
| 124 |
+
return valueOf(profile, 'notice_period');
|
| 125 |
+
}
|
| 126 |
+
if (/authorized to work|work authorization/.test(text)) return valueOf(profile, 'work_authorization');
|
| 127 |
+
if (/require sponsorship|need sponsorship|visa sponsorship/.test(text)) {
|
| 128 |
+
return valueOf(profile, 'requires_sponsorship');
|
| 129 |
+
}
|
| 130 |
+
if (/visa status/.test(text)) return valueOf(profile, 'visa_status');
|
| 131 |
+
if (/current salary|current ctc/.test(text)) return valueOf(profile, 'current_salary');
|
| 132 |
+
if (/expected salary|salary expectation|expected ctc|compensation expectation/.test(text)) {
|
| 133 |
+
return valueOf(profile, 'expected_salary');
|
| 134 |
+
}
|
| 135 |
+
if (/notice|relocation|work mode|remote|hybrid/.test(text)) return valueOf(profile, 'additional_notes');
|
| 136 |
+
return '';
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function pickFieldsForApi(fields, localAnswers) {
|
| 140 |
+
const answered = new Set((localAnswers || []).map((a) => a.id));
|
| 141 |
+
return (fields || []).filter((field) => {
|
| 142 |
+
if (!field || !field.id || answered.has(field.id)) return false;
|
| 143 |
+
const text = fieldText(field);
|
| 144 |
+
if (!text) return false;
|
| 145 |
+
if (/search|filter|sort/.test(text)) return false;
|
| 146 |
+
return isLongAnswerField(field)
|
| 147 |
+
|| (field.tag === 'select' || field.type === 'radio')
|
| 148 |
+
|| /why|motivation|about you|introduce yourself|cover letter|summary|fit|salary|authorization|sponsorship|relocate/.test(text);
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
async function runFormAssist({ data, resumeLatex, profile, fields, jd_text, job_title, company }) {
|
| 153 |
+
const formData = new FormData();
|
| 154 |
+
formData.append('resume_latex', resumeLatex || '');
|
| 155 |
+
formData.append('profile_json', JSON.stringify(profile || {}));
|
| 156 |
+
formData.append('fields_json', JSON.stringify(fields || []));
|
| 157 |
+
formData.append('jd_text', jd_text || '');
|
| 158 |
+
formData.append('job_title', job_title || '');
|
| 159 |
+
formData.append('company', company || '');
|
| 160 |
+
|
| 161 |
+
let resp;
|
| 162 |
+
try {
|
| 163 |
+
resp = await fetch(`${data.api_url.replace(/\/$/, '')}/api/form-assist`, {
|
| 164 |
+
method: 'POST',
|
| 165 |
+
headers: { 'X-Api-Token': data.api_token },
|
| 166 |
+
body: formData,
|
| 167 |
+
});
|
| 168 |
+
} catch (networkErr) {
|
| 169 |
+
return { error: 'network_error', detail: `Cannot reach API: ${networkErr.message}` };
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
let result;
|
| 173 |
+
try {
|
| 174 |
+
result = await resp.json();
|
| 175 |
+
} catch {
|
| 176 |
+
return { error: 'parse_error', detail: `API returned non-JSON (status ${resp.status})` };
|
| 177 |
+
}
|
| 178 |
+
if (!resp.ok) {
|
| 179 |
+
return { error: result.error || 'api_error', detail: result.detail || `HTTP ${resp.status}` };
|
| 180 |
+
}
|
| 181 |
+
return result;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
async function handleAutofillForm({ fields, jd_text, job_title, company }) {
|
| 185 |
+
const data = await new Promise(res => chrome.storage.local.get(
|
| 186 |
+
['api_url', 'api_token', 'resume_latex', 'autofill_profile_json'], res
|
| 187 |
+
));
|
| 188 |
+
|
| 189 |
+
let resumeLatex = data.resume_latex;
|
| 190 |
+
if ((!resumeLatex || !resumeLatex.trim()) && self.DEFAULT_RESUME_LATEX) {
|
| 191 |
+
resumeLatex = self.DEFAULT_RESUME_LATEX;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
const profile = profileIndex(parseProfileJson(data.autofill_profile_json));
|
| 195 |
+
const localAnswers = [];
|
| 196 |
+
for (const field of (fields || [])) {
|
| 197 |
+
const value = localAutofillValue(field, profile);
|
| 198 |
+
if (value) {
|
| 199 |
+
localAnswers.push({
|
| 200 |
+
id: field.id,
|
| 201 |
+
value,
|
| 202 |
+
confidence: 'high',
|
| 203 |
+
source: 'profile',
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const needsApi = pickFieldsForApi(fields, localAnswers);
|
| 209 |
+
let apiAnswers = [];
|
| 210 |
+
let warning = '';
|
| 211 |
+
|
| 212 |
+
if (needsApi.length) {
|
| 213 |
+
if (!data.api_url || !data.api_token) {
|
| 214 |
+
warning = 'API not configured. Filled only the common profile fields.';
|
| 215 |
+
} else if (!resumeLatex || !resumeLatex.trim()) {
|
| 216 |
+
warning = 'Resume LaTeX is not configured. Filled only the common profile fields.';
|
| 217 |
+
} else {
|
| 218 |
+
const apiResult = await runFormAssist({
|
| 219 |
+
data, resumeLatex, profile, fields: needsApi, jd_text, job_title, company,
|
| 220 |
+
});
|
| 221 |
+
if (apiResult && apiResult.error) {
|
| 222 |
+
warning = apiResult.detail || apiResult.error;
|
| 223 |
+
} else {
|
| 224 |
+
apiAnswers = Array.isArray(apiResult?.answers) ? apiResult.answers : [];
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
const merged = new Map();
|
| 230 |
+
for (const answer of [...apiAnswers, ...localAnswers]) {
|
| 231 |
+
if (!answer || !answer.id || !String(answer.value || '').trim()) continue;
|
| 232 |
+
merged.set(answer.id, answer);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
return {
|
| 236 |
+
answers: Array.from(merged.values()),
|
| 237 |
+
local_count: localAnswers.length,
|
| 238 |
+
api_count: apiAnswers.filter((a) => String(a?.value || '').trim()).length,
|
| 239 |
+
field_count: (fields || []).length,
|
| 240 |
+
warning,
|
| 241 |
+
};
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
// Shallow-merge a patch into the entry for `urlKey`, preserving existing fields,
|
| 245 |
// stamping savedAt, and pruning to the most-recent MAX_SAVED entries.
|
| 246 |
async function writeEntry(urlKey, patch) {
|
|
|
|
| 314 |
handleDownload(msg).catch(console.error);
|
| 315 |
return false;
|
| 316 |
}
|
| 317 |
+
if (msg.type === 'AUTOFILL_FORM') {
|
| 318 |
+
handleAutofillForm(msg)
|
| 319 |
+
.then((data) => safeRespond(sendResponse, data))
|
| 320 |
+
.catch((err) => safeRespond(sendResponse, { error: 'runtime_error', detail: err.message }));
|
| 321 |
+
return true;
|
| 322 |
+
}
|
| 323 |
});
|
| 324 |
|
| 325 |
// ─── REPAIR (External ATS Feedback Repair Mode) ───────────────────────────────
|
| 326 |
|
| 327 |
+
async function handleRepair({ jd_text, job_title, company, feedback, maximum_ats_mode, confirmed_terms, url, version }) {
|
| 328 |
const data = await new Promise(res => chrome.storage.local.get(
|
| 329 |
['resume_b64', 'resume_latex', 'api_url', 'api_token'], res
|
| 330 |
));
|
|
|
|
| 348 |
formData.append('feedback', feedback || '');
|
| 349 |
formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0');
|
| 350 |
if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms);
|
| 351 |
+
formData.append('version', version || 'v1');
|
| 352 |
|
| 353 |
// LaTeX takes priority; only attach the PDF when no LaTeX is saved.
|
| 354 |
if (hasLatex) {
|
|
|
|
| 391 |
|
| 392 |
// ─── GENERATE ────────────────────────────────────────────────────────────────
|
| 393 |
|
| 394 |
+
async function handleGenerate({ jd_text, job_title, company, maximum_ats_mode, confirmed_terms, url, version }) {
|
| 395 |
// 1. Read settings from storage
|
| 396 |
const data = await new Promise(res => chrome.storage.local.get(
|
| 397 |
['resume_b64', 'resume_latex', 'api_url', 'api_token'], res
|
|
|
|
| 425 |
});
|
| 426 |
|
| 427 |
// 3. Perform the network round-trip (no popup dependency).
|
| 428 |
+
const result = await runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version });
|
| 429 |
|
| 430 |
// 4. Overwrite the marker with the terminal state — done OR error — so the
|
| 431 |
// popup never shows a false/forever spinner. Done regardless of popup state.
|
|
|
|
| 439 |
}
|
| 440 |
|
| 441 |
// Network/parse layer for GENERATE. Returns a result object (success or {error}).
|
| 442 |
+
async function runGenerate({ data, hasLatex, resumeLatex, jd_text, job_title, company, maximum_ats_mode, confirmed_terms, version }) {
|
| 443 |
// Build multipart/form-data. LaTeX takes priority over the PDF.
|
| 444 |
const formData = new FormData();
|
| 445 |
formData.append('jd_text', jd_text);
|
|
|
|
| 447 |
formData.append('company', company || '');
|
| 448 |
formData.append('maximum_ats_mode', maximum_ats_mode ? '1' : '0');
|
| 449 |
if (confirmed_terms) formData.append('confirmed_terms', confirmed_terms);
|
| 450 |
+
formData.append('version', version || 'v1');
|
| 451 |
|
| 452 |
if (hasLatex) {
|
| 453 |
formData.append('resume_latex', resumeLatex || data.resume_latex);
|
extension/content.js
CHANGED
|
@@ -545,6 +545,193 @@ function looksLikeListingJunk(result) {
|
|
| 545 |
return false;
|
| 546 |
}
|
| 547 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
// ─── Message listener ─────────────────────────────────────────────────────────
|
| 549 |
|
| 550 |
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
@@ -556,6 +743,26 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
| 556 |
return; // synchronous response
|
| 557 |
}
|
| 558 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
if (msg.type === 'EXTRACT_JD') {
|
| 560 |
// Extract, and if the page clearly hadn't loaded the posting yet (short text
|
| 561 |
// and no JD signal), retry ONCE after a short delay — covers SPA navigations
|
|
|
|
| 545 |
return false;
|
| 546 |
}
|
| 547 |
|
| 548 |
+
function isEligibleField(el) {
|
| 549 |
+
if (!el || !el.tagName || !el.isConnected) return false;
|
| 550 |
+
if (el.closest('#ats-side-panel-root')) return false;
|
| 551 |
+
if (el.disabled || el.readOnly) return false;
|
| 552 |
+
const tag = el.tagName.toLowerCase();
|
| 553 |
+
const type = (el.type || '').toLowerCase();
|
| 554 |
+
if (tag === 'input' && /^(hidden|password|file|submit|button|reset|image|range|color)$/.test(type)) {
|
| 555 |
+
return false;
|
| 556 |
+
}
|
| 557 |
+
if (type === 'search') return false;
|
| 558 |
+
const style = window.getComputedStyle(el);
|
| 559 |
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
| 560 |
+
const rect = el.getBoundingClientRect();
|
| 561 |
+
return rect.width > 0 && rect.height > 0;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
function labelText(el) {
|
| 565 |
+
const parts = [];
|
| 566 |
+
if (el.id) {
|
| 567 |
+
const byFor = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
| 568 |
+
if (byFor) parts.push(byFor.innerText || byFor.textContent || '');
|
| 569 |
+
}
|
| 570 |
+
const wrap = el.closest('label');
|
| 571 |
+
if (wrap) parts.push(wrap.innerText || wrap.textContent || '');
|
| 572 |
+
if (el.labels) {
|
| 573 |
+
for (const lab of el.labels) parts.push(lab.innerText || lab.textContent || '');
|
| 574 |
+
}
|
| 575 |
+
parts.push(el.getAttribute('aria-label') || '');
|
| 576 |
+
parts.push(el.getAttribute('placeholder') || '');
|
| 577 |
+
parts.push(el.name || '');
|
| 578 |
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
function helpText(el) {
|
| 582 |
+
const ids = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
|
| 583 |
+
const texts = [];
|
| 584 |
+
for (const id of ids) {
|
| 585 |
+
const node = document.getElementById(id);
|
| 586 |
+
if (node) texts.push(node.innerText || node.textContent || '');
|
| 587 |
+
}
|
| 588 |
+
return texts.join(' ').replace(/\s+/g, ' ').trim();
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
function ensureFieldId(el, fallback) {
|
| 592 |
+
if (!el.dataset.atsFieldId) {
|
| 593 |
+
el.dataset.atsFieldId = `atsf_${Date.now()}_${fallback}`;
|
| 594 |
+
}
|
| 595 |
+
return el.dataset.atsFieldId;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
function bestFormRoot() {
|
| 599 |
+
const forms = Array.from(document.forms || []);
|
| 600 |
+
if (!forms.length) return document;
|
| 601 |
+
let best = forms[0];
|
| 602 |
+
let bestCount = -1;
|
| 603 |
+
for (const form of forms) {
|
| 604 |
+
const count = Array.from(form.querySelectorAll('input, textarea, select'))
|
| 605 |
+
.filter(isEligibleField).length;
|
| 606 |
+
if (count > bestCount) {
|
| 607 |
+
best = form;
|
| 608 |
+
bestCount = count;
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
return best || document;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
function scanApplicationFields() {
|
| 615 |
+
const root = bestFormRoot();
|
| 616 |
+
const fields = [];
|
| 617 |
+
const radioGroups = new Map();
|
| 618 |
+
let idx = 0;
|
| 619 |
+
|
| 620 |
+
for (const el of root.querySelectorAll('input, textarea, select')) {
|
| 621 |
+
if (!isEligibleField(el)) continue;
|
| 622 |
+
const tag = el.tagName.toLowerCase();
|
| 623 |
+
const type = (el.type || '').toLowerCase();
|
| 624 |
+
|
| 625 |
+
if (type === 'radio') {
|
| 626 |
+
const groupName = el.name || ensureFieldId(el, idx++);
|
| 627 |
+
const groupKey = `radio:${groupName}`;
|
| 628 |
+
let group = radioGroups.get(groupKey);
|
| 629 |
+
if (!group) {
|
| 630 |
+
group = {
|
| 631 |
+
id: `ats_radio_${groupName}`,
|
| 632 |
+
tag: 'input',
|
| 633 |
+
type: 'radio',
|
| 634 |
+
name: groupName,
|
| 635 |
+
label: labelText(el),
|
| 636 |
+
placeholder: '',
|
| 637 |
+
help_text: helpText(el),
|
| 638 |
+
required: !!el.required,
|
| 639 |
+
options: [],
|
| 640 |
+
};
|
| 641 |
+
radioGroups.set(groupKey, group);
|
| 642 |
+
}
|
| 643 |
+
const optLabel = labelText(el) || el.value || 'Option';
|
| 644 |
+
group.options.push({ label: optLabel, value: el.value || optLabel });
|
| 645 |
+
continue;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
const fieldId = ensureFieldId(el, idx++);
|
| 649 |
+
const meta = {
|
| 650 |
+
id: fieldId,
|
| 651 |
+
tag,
|
| 652 |
+
type: type || tag,
|
| 653 |
+
name: el.name || '',
|
| 654 |
+
label: labelText(el),
|
| 655 |
+
placeholder: el.getAttribute('placeholder') || '',
|
| 656 |
+
help_text: helpText(el),
|
| 657 |
+
required: !!el.required,
|
| 658 |
+
options: [],
|
| 659 |
+
};
|
| 660 |
+
if (tag === 'select') {
|
| 661 |
+
meta.options = Array.from(el.options || [])
|
| 662 |
+
.map((opt) => ({
|
| 663 |
+
label: (opt.textContent || '').trim(),
|
| 664 |
+
value: (opt.value || '').trim(),
|
| 665 |
+
}))
|
| 666 |
+
.filter((opt) => opt.label || opt.value);
|
| 667 |
+
}
|
| 668 |
+
fields.push(meta);
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
fields.push(...radioGroups.values());
|
| 672 |
+
return fields;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
function setNativeValue(el, value) {
|
| 676 |
+
const proto = Object.getPrototypeOf(el);
|
| 677 |
+
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
| 678 |
+
if (desc && desc.set) desc.set.call(el, value);
|
| 679 |
+
else el.value = value;
|
| 680 |
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
| 681 |
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
function norm(s) {
|
| 685 |
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
function applyAnswer(answer) {
|
| 689 |
+
if (!answer || !answer.id || !String(answer.value || '').trim()) return false;
|
| 690 |
+
const value = String(answer.value).trim();
|
| 691 |
+
const node = document.querySelector(`[data-ats-field-id="${CSS.escape(answer.id)}"]`);
|
| 692 |
+
if (node) {
|
| 693 |
+
if ((node.value || '').trim()) return false;
|
| 694 |
+
if (node.tagName.toLowerCase() === 'select') {
|
| 695 |
+
const want = norm(value);
|
| 696 |
+
const option = Array.from(node.options || []).find((opt) => {
|
| 697 |
+
const label = norm(opt.textContent || '');
|
| 698 |
+
const val = norm(opt.value || '');
|
| 699 |
+
return want === label || want === val || label.includes(want) || val.includes(want);
|
| 700 |
+
});
|
| 701 |
+
if (!option) return false;
|
| 702 |
+
setNativeValue(node, option.value);
|
| 703 |
+
return true;
|
| 704 |
+
}
|
| 705 |
+
setNativeValue(node, value);
|
| 706 |
+
return true;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
if (String(answer.id).startsWith('ats_radio_')) {
|
| 710 |
+
const groupName = String(answer.id).replace(/^ats_radio_/, '');
|
| 711 |
+
const radios = Array.from(document.querySelectorAll(`input[type="radio"][name="${CSS.escape(groupName)}"]`));
|
| 712 |
+
const want = norm(value);
|
| 713 |
+
const target = radios.find((radio) => {
|
| 714 |
+
const text = norm(labelText(radio) || radio.value);
|
| 715 |
+
return text === want || text.includes(want) || want.includes(text);
|
| 716 |
+
});
|
| 717 |
+
if (!target) return false;
|
| 718 |
+
target.checked = true;
|
| 719 |
+
target.dispatchEvent(new Event('input', { bubbles: true }));
|
| 720 |
+
target.dispatchEvent(new Event('change', { bubbles: true }));
|
| 721 |
+
return true;
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
return false;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
function applyAutofillAnswers(answers) {
|
| 728 |
+
let applied = 0;
|
| 729 |
+
for (const answer of (answers || [])) {
|
| 730 |
+
if (applyAnswer(answer)) applied += 1;
|
| 731 |
+
}
|
| 732 |
+
return applied;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
// ─── Message listener ─────────────────────────────────────────────────────────
|
| 736 |
|
| 737 |
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
|
| 743 |
return; // synchronous response
|
| 744 |
}
|
| 745 |
|
| 746 |
+
if (msg.type === 'SCAN_FORM') {
|
| 747 |
+
try {
|
| 748 |
+
const fields = scanApplicationFields();
|
| 749 |
+
sendResponse({ fields, field_count: fields.length });
|
| 750 |
+
} catch (err) {
|
| 751 |
+
sendResponse({ fields: [], field_count: 0, error: err.message });
|
| 752 |
+
}
|
| 753 |
+
return;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
if (msg.type === 'APPLY_FORM_FILL') {
|
| 757 |
+
try {
|
| 758 |
+
const applied = applyAutofillAnswers(msg.answers || []);
|
| 759 |
+
sendResponse({ applied });
|
| 760 |
+
} catch (err) {
|
| 761 |
+
sendResponse({ applied: 0, error: err.message });
|
| 762 |
+
}
|
| 763 |
+
return;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
if (msg.type === 'EXTRACT_JD') {
|
| 767 |
// Extract, and if the page clearly hadn't loaded the posting yet (short text
|
| 768 |
// and no JD signal), retry ONCE after a short delay — covers SPA navigations
|
extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"manifest_version": 3,
|
| 3 |
"name": "ATS Resume Generator",
|
| 4 |
-
"version": "1.
|
| 5 |
"description": "Tailors your resume (PDF or LaTeX) to any job posting using the ATS pipeline.",
|
| 6 |
"permissions": [
|
| 7 |
"storage",
|
|
|
|
| 1 |
{
|
| 2 |
"manifest_version": 3,
|
| 3 |
"name": "ATS Resume Generator",
|
| 4 |
+
"version": "1.8.0",
|
| 5 |
"description": "Tailors your resume (PDF or LaTeX) to any job posting using the ATS pipeline.",
|
| 6 |
"permissions": [
|
| 7 |
"storage",
|
extension/options/options.html
CHANGED
|
@@ -198,6 +198,19 @@
|
|
| 198 |
<input id="resume_file" type="file" accept=".pdf">
|
| 199 |
<p id="resume_status">No resume uploaded.</p>
|
| 200 |
<p class="note">Upload once. The PDF is stored locally in the extension — it is never sent to any server until you click Run on a job page.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
</div>
|
| 202 |
|
| 203 |
<button id="save_btn">Save Settings</button>
|
|
|
|
| 198 |
<input id="resume_file" type="file" accept=".pdf">
|
| 199 |
<p id="resume_status">No resume uploaded.</p>
|
| 200 |
<p class="note">Upload once. The PDF is stored locally in the extension — it is never sent to any server until you click Run on a job page.</p>
|
| 201 |
+
|
| 202 |
+
<label for="gen_version_default" style="margin-top:16px;">Default Generation Mode</label>
|
| 203 |
+
<select id="gen_version_default" style="width:100%;padding:10px 14px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;margin-bottom:16px;">
|
| 204 |
+
<option value="v1">V1 — Structured (keyword placement)</option>
|
| 205 |
+
<option value="v2">V2 — Natural AI (sentence integration)</option>
|
| 206 |
+
</select>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div class="section">
|
| 210 |
+
<div class="section-title">Application Autofill Profile</div>
|
| 211 |
+
<label for="autofill_profile_json">Reusable profile JSON</label>
|
| 212 |
+
<textarea id="autofill_profile_json" placeholder='{"full_name":"", "email":"", "phone":"", "location":"", "linkedin_url":"", "portfolio_url":"", "current_title":"", "current_company":"", "years_experience":"", "notice_period":"", "work_authorization":"", "requires_sponsorship":"", "visa_status":"", "expected_salary":"", "current_salary":"", "additional_notes":""}'></textarea>
|
| 213 |
+
<p class="note">Used by the extension to fill common application fields instantly. Harder questions are answered from this profile plus your resume LaTeX and the current job description.</p>
|
| 214 |
</div>
|
| 215 |
|
| 216 |
<button id="save_btn">Save Settings</button>
|
extension/options/options.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
| 14 |
|
| 15 |
// ─── Load stored settings on page open ───────────────────────────────────────
|
| 16 |
chrome.storage.local.get(
|
| 17 |
-
['api_url', 'api_token', 'resume_filename', 'resume_sha256', 'resume_latex'],
|
| 18 |
(data) => {
|
| 19 |
if (data.api_url) {
|
| 20 |
document.getElementById('api_url').value = data.api_url;
|
|
@@ -38,6 +38,12 @@ chrome.storage.local.get(
|
|
| 38 |
document.getElementById('latex_status').textContent =
|
| 39 |
`Default resume loaded (${self.DEFAULT_RESUME_LATEX.length.toLocaleString()} chars). Edit to use your own.`;
|
| 40 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
);
|
| 43 |
|
|
@@ -77,6 +83,20 @@ document.getElementById('save_btn').addEventListener('click', async () => {
|
|
| 77 |
// ─── LaTeX source (priority input) ─────────────────────────────────────────
|
| 78 |
const latexSrc = document.getElementById('resume_latex').value.trim();
|
| 79 |
toStore.resume_latex = latexSrc; // store '' to allow clearing it
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
document.getElementById('latex_status').textContent = latexSrc
|
| 81 |
? `LaTeX saved (${latexSrc.length.toLocaleString()} chars). Takes priority over the PDF.`
|
| 82 |
: 'No LaTeX saved.';
|
|
|
|
| 14 |
|
| 15 |
// ─── Load stored settings on page open ───────────────────────────────────────
|
| 16 |
chrome.storage.local.get(
|
| 17 |
+
['api_url', 'api_token', 'resume_filename', 'resume_sha256', 'resume_latex', 'gen_version_default', 'autofill_profile_json'],
|
| 18 |
(data) => {
|
| 19 |
if (data.api_url) {
|
| 20 |
document.getElementById('api_url').value = data.api_url;
|
|
|
|
| 38 |
document.getElementById('latex_status').textContent =
|
| 39 |
`Default resume loaded (${self.DEFAULT_RESUME_LATEX.length.toLocaleString()} chars). Edit to use your own.`;
|
| 40 |
}
|
| 41 |
+
if (data.gen_version_default) {
|
| 42 |
+
document.getElementById('gen_version_default').value = data.gen_version_default;
|
| 43 |
+
}
|
| 44 |
+
if (data.autofill_profile_json) {
|
| 45 |
+
document.getElementById('autofill_profile_json').value = data.autofill_profile_json;
|
| 46 |
+
}
|
| 47 |
}
|
| 48 |
);
|
| 49 |
|
|
|
|
| 83 |
// ─── LaTeX source (priority input) ─────────────────────────────────────────
|
| 84 |
const latexSrc = document.getElementById('resume_latex').value.trim();
|
| 85 |
toStore.resume_latex = latexSrc; // store '' to allow clearing it
|
| 86 |
+
toStore.gen_version_default = document.getElementById('gen_version_default').value;
|
| 87 |
+
const autofillProfile = document.getElementById('autofill_profile_json').value.trim();
|
| 88 |
+
if (autofillProfile) {
|
| 89 |
+
try {
|
| 90 |
+
JSON.parse(autofillProfile);
|
| 91 |
+
toStore.autofill_profile_json = autofillProfile;
|
| 92 |
+
} catch (_) {
|
| 93 |
+
statusEl.textContent = 'Error: Autofill profile must be valid JSON.';
|
| 94 |
+
statusEl.style.color = '#dc2626';
|
| 95 |
+
return;
|
| 96 |
+
}
|
| 97 |
+
} else {
|
| 98 |
+
toStore.autofill_profile_json = '';
|
| 99 |
+
}
|
| 100 |
document.getElementById('latex_status').textContent = latexSrc
|
| 101 |
? `LaTeX saved (${latexSrc.length.toLocaleString()} chars). Takes priority over the PDF.`
|
| 102 |
: 'No LaTeX saved.';
|
extension/popup/popup.html
CHANGED
|
@@ -11,6 +11,9 @@
|
|
| 11 |
#run-btn { width: 100%; padding: 10px; background: #2563eb; color: white; border: none;
|
| 12 |
border-radius: 6px; font-size: 14px; cursor: pointer; }
|
| 13 |
#run-btn:disabled { background: #93c5fd; cursor: not-allowed; }
|
|
|
|
|
|
|
|
|
|
| 14 |
#status { margin-top: 10px; font-size: 12px; min-height: 20px; }
|
| 15 |
#scores { display: none; margin-top: 8px; font-size: 12px; }
|
| 16 |
#scores table { width: 100%; border-collapse: collapse; }
|
|
@@ -60,7 +63,18 @@
|
|
| 60 |
user-confirmed (interview-supportable) skills for this resume. Never fakes
|
| 61 |
degrees, certs, employers, titles, or seniority.</span>
|
| 62 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
<button id="run-btn">Run</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
<div id="status"></div>
|
| 65 |
<div id="scores">
|
| 66 |
<table>
|
|
|
|
| 11 |
#run-btn { width: 100%; padding: 10px; background: #2563eb; color: white; border: none;
|
| 12 |
border-radius: 6px; font-size: 14px; cursor: pointer; }
|
| 13 |
#run-btn:disabled { background: #93c5fd; cursor: not-allowed; }
|
| 14 |
+
#autofill-btn { width: 100%; padding: 9px; background: #0f766e; color: white; border: none;
|
| 15 |
+
border-radius: 6px; font-size: 13px; cursor: pointer; margin-top: 8px; }
|
| 16 |
+
#autofill-btn:disabled { background: #99f6e4; cursor: not-allowed; }
|
| 17 |
#status { margin-top: 10px; font-size: 12px; min-height: 20px; }
|
| 18 |
#scores { display: none; margin-top: 8px; font-size: 12px; }
|
| 19 |
#scores table { width: 100%; border-collapse: collapse; }
|
|
|
|
| 63 |
user-confirmed (interview-supportable) skills for this resume. Never fakes
|
| 64 |
degrees, certs, employers, titles, or seniority.</span>
|
| 65 |
</label>
|
| 66 |
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
| 67 |
+
<label style="font-size:11px;color:#555;margin:0;font-weight:normal;">Mode:</label>
|
| 68 |
+
<select id="version-toggle" style="padding:4px 8px;border:1px solid #d1d5db;border-radius:4px;font-size:11px;">
|
| 69 |
+
<option value="v1">V1 Structured</option>
|
| 70 |
+
<option value="v2">V2 Natural AI</option>
|
| 71 |
+
</select>
|
| 72 |
+
</div>
|
| 73 |
<button id="run-btn">Run</button>
|
| 74 |
+
<button id="autofill-btn">Autofill Application Form</button>
|
| 75 |
+
<div style="font-size:11px;color:#64748b;margin-top:6px;">
|
| 76 |
+
Fills common details from your saved profile, then uses your resume LaTeX + JD for harder questions.
|
| 77 |
+
</div>
|
| 78 |
<div id="status"></div>
|
| 79 |
<div id="scores">
|
| 80 |
<table>
|
extension/popup/popup.js
CHANGED
|
@@ -34,6 +34,7 @@ let restoredMeta = null; // {job_title, company} from a restored result
|
|
| 34 |
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
| 35 |
|
| 36 |
const runBtn = document.getElementById('run-btn');
|
|
|
|
| 37 |
const statusEl = document.getElementById('status');
|
| 38 |
const jobInfoEl = document.getElementById('job-info');
|
| 39 |
const scoresEl = document.getElementById('scores');
|
|
@@ -98,6 +99,27 @@ function sendBgMessage(msg) {
|
|
| 98 |
});
|
| 99 |
}
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
// ─── JD extraction (runs in the background; Run is usable immediately) ─────────
|
| 102 |
|
| 103 |
/**
|
|
@@ -382,6 +404,11 @@ restoreResultForTab();
|
|
| 382 |
renderHistory();
|
| 383 |
requestExtraction().then(handleExtraction);
|
| 384 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
// ─── Options link ─────────────────────────────────────────────────────────────
|
| 386 |
|
| 387 |
document.getElementById('open-options').addEventListener('click', (e) => {
|
|
@@ -389,6 +416,62 @@ document.getElementById('open-options').addEventListener('click', (e) => {
|
|
| 389 |
chrome.runtime.openOptionsPage();
|
| 390 |
});
|
| 391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
// ─── Step 2: Run button ───────────────────────────────────────────────────────
|
| 393 |
|
| 394 |
runBtn.addEventListener('click', async () => {
|
|
@@ -426,6 +509,7 @@ runBtn.addEventListener('click', async () => {
|
|
| 426 |
company: extractedData?.company || '',
|
| 427 |
maximum_ats_mode: isMaxAtsOn(),
|
| 428 |
url: currentUrlKey,
|
|
|
|
| 429 |
});
|
| 430 |
handleResult(result);
|
| 431 |
});
|
|
@@ -528,7 +612,7 @@ function applyResult(result) {
|
|
| 528 |
const dlPdf = document.getElementById('dl-pdf');
|
| 529 |
const dlTex = document.getElementById('dl-tex');
|
| 530 |
|
| 531 |
-
if (result.source === 'latex') {
|
| 532 |
// LaTeX flow: clean PDF + .tex, no DOCX.
|
| 533 |
downloadsEl.classList.add('visible');
|
| 534 |
dlDocx.style.display = 'none';
|
|
|
|
| 34 |
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
| 35 |
|
| 36 |
const runBtn = document.getElementById('run-btn');
|
| 37 |
+
const autofillBtn = document.getElementById('autofill-btn');
|
| 38 |
const statusEl = document.getElementById('status');
|
| 39 |
const jobInfoEl = document.getElementById('job-info');
|
| 40 |
const scoresEl = document.getElementById('scores');
|
|
|
|
| 99 |
});
|
| 100 |
}
|
| 101 |
|
| 102 |
+
async function activeTab() {
|
| 103 |
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
| 104 |
+
return tab || null;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function sendTabMessage(tabId, msg) {
|
| 108 |
+
return new Promise((resolve) => {
|
| 109 |
+
try {
|
| 110 |
+
chrome.tabs.sendMessage(tabId, msg, (response) => {
|
| 111 |
+
if (chrome.runtime.lastError) {
|
| 112 |
+
resolve({ error: chrome.runtime.lastError.message || 'Tab did not respond.' });
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
resolve(response || {});
|
| 116 |
+
});
|
| 117 |
+
} catch (err) {
|
| 118 |
+
resolve({ error: err.message });
|
| 119 |
+
}
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
// ─── JD extraction (runs in the background; Run is usable immediately) ─────────
|
| 124 |
|
| 125 |
/**
|
|
|
|
| 404 |
renderHistory();
|
| 405 |
requestExtraction().then(handleExtraction);
|
| 406 |
|
| 407 |
+
chrome.storage.local.get(['gen_version_default'], (d) => {
|
| 408 |
+
const sel = document.getElementById('version-toggle');
|
| 409 |
+
if (sel && d.gen_version_default) sel.value = d.gen_version_default;
|
| 410 |
+
});
|
| 411 |
+
|
| 412 |
// ─── Options link ─────────────────────────────────────────────────────────────
|
| 413 |
|
| 414 |
document.getElementById('open-options').addEventListener('click', (e) => {
|
|
|
|
| 416 |
chrome.runtime.openOptionsPage();
|
| 417 |
});
|
| 418 |
|
| 419 |
+
autofillBtn.addEventListener('click', async () => {
|
| 420 |
+
const tab = await activeTab();
|
| 421 |
+
if (!tab?.id || isRestrictedTabUrl(tab.url)) {
|
| 422 |
+
statusEl.textContent = 'Open an application page in a normal browser tab first.';
|
| 423 |
+
return;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
autofillBtn.disabled = true;
|
| 427 |
+
statusEl.innerHTML = '<span class="spinner"></span> Scanning form...';
|
| 428 |
+
|
| 429 |
+
const scan = await sendTabMessage(tab.id, { type: 'SCAN_FORM' });
|
| 430 |
+
if (scan.error) {
|
| 431 |
+
statusEl.textContent = `Could not read the form: ${scan.error}`;
|
| 432 |
+
autofillBtn.disabled = false;
|
| 433 |
+
return;
|
| 434 |
+
}
|
| 435 |
+
const fields = Array.isArray(scan.fields) ? scan.fields : [];
|
| 436 |
+
if (!fields.length) {
|
| 437 |
+
statusEl.textContent = 'No fillable form fields found on this page.';
|
| 438 |
+
autofillBtn.disabled = false;
|
| 439 |
+
return;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
if (!extractedData) {
|
| 443 |
+
const resp = await requestExtraction();
|
| 444 |
+
if (resp) handleExtraction(resp);
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
statusEl.innerHTML = '<span class="spinner"></span> Generating answers...';
|
| 448 |
+
const result = await sendBgMessage({
|
| 449 |
+
type: 'AUTOFILL_FORM',
|
| 450 |
+
fields,
|
| 451 |
+
jd_text: extractedData?.jd_text || document.getElementById('manual-jd-text').value.trim(),
|
| 452 |
+
job_title: extractedData?.job_title || '',
|
| 453 |
+
company: extractedData?.company || '',
|
| 454 |
+
});
|
| 455 |
+
|
| 456 |
+
if (!result || result.error) {
|
| 457 |
+
statusEl.textContent = `Autofill failed: ${result?.detail || result?.error || 'Unknown error'}`;
|
| 458 |
+
autofillBtn.disabled = false;
|
| 459 |
+
return;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
statusEl.innerHTML = '<span class="spinner"></span> Filling page...';
|
| 463 |
+
const apply = await sendTabMessage(tab.id, {
|
| 464 |
+
type: 'APPLY_FORM_FILL',
|
| 465 |
+
answers: result.answers || [],
|
| 466 |
+
});
|
| 467 |
+
autofillBtn.disabled = false;
|
| 468 |
+
|
| 469 |
+
const applied = apply?.applied || 0;
|
| 470 |
+
const warning = result.warning ? ` ${result.warning}` : '';
|
| 471 |
+
statusEl.textContent =
|
| 472 |
+
`Filled ${applied}/${result.field_count || fields.length} fields. Review everything before submitting.${warning}`;
|
| 473 |
+
});
|
| 474 |
+
|
| 475 |
// ─── Step 2: Run button ───────────────────────────────────────────────────────
|
| 476 |
|
| 477 |
runBtn.addEventListener('click', async () => {
|
|
|
|
| 509 |
company: extractedData?.company || '',
|
| 510 |
maximum_ats_mode: isMaxAtsOn(),
|
| 511 |
url: currentUrlKey,
|
| 512 |
+
version: document.getElementById('version-toggle').value,
|
| 513 |
});
|
| 514 |
handleResult(result);
|
| 515 |
});
|
|
|
|
| 612 |
const dlPdf = document.getElementById('dl-pdf');
|
| 613 |
const dlTex = document.getElementById('dl-tex');
|
| 614 |
|
| 615 |
+
if (result.source === 'latex' || result.source === 'latex_v2') {
|
| 616 |
// LaTeX flow: clean PDF + .tex, no DOCX.
|
| 617 |
downloadsEl.classList.add('visible');
|
| 618 |
dlDocx.style.display = 'none';
|
relay/cloudflare-worker.js
CHANGED
|
@@ -23,7 +23,7 @@ const HELP =
|
|
| 23 |
"your tailored, ATS-optimized resume PDF.\n\n" +
|
| 24 |
"• Company/ATS links (Greenhouse, Lever, Ashby, Naukri) usually work directly.\n" +
|
| 25 |
"• If I can't read a LinkedIn/Indeed link, copy the description text and send that.\n\n" +
|
| 26 |
-
"Commands: /start, /help";
|
| 27 |
|
| 28 |
export default {
|
| 29 |
async fetch(request, env, ctx) {
|
|
@@ -90,6 +90,28 @@ async function handle(update, env) {
|
|
| 90 |
return;
|
| 91 |
}
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
if (!text || /^\/(start|help)\b/i.test(text)) {
|
| 94 |
await tg(env, "sendMessage", { chat_id: chatId, text: HELP, parse_mode: "Markdown" });
|
| 95 |
return;
|
|
@@ -108,6 +130,7 @@ async function handle(update, env) {
|
|
| 108 |
return;
|
| 109 |
}
|
| 110 |
form.append("maximum_ats_mode", "1");
|
|
|
|
| 111 |
|
| 112 |
let res;
|
| 113 |
try {
|
|
|
|
| 23 |
"your tailored, ATS-optimized resume PDF.\n\n" +
|
| 24 |
"• Company/ATS links (Greenhouse, Lever, Ashby, Naukri) usually work directly.\n" +
|
| 25 |
"• If I can't read a LinkedIn/Indeed link, copy the description text and send that.\n\n" +
|
| 26 |
+
"Commands: /start, /help, /v1, /v2, /mode";
|
| 27 |
|
| 28 |
export default {
|
| 29 |
async fetch(request, env, ctx) {
|
|
|
|
| 90 |
return;
|
| 91 |
}
|
| 92 |
|
| 93 |
+
// ── Per-user version mode (Workers KV optional) ──────────────────────────
|
| 94 |
+
const verKey = `ver:${userId}`;
|
| 95 |
+
let userVer = 'v1';
|
| 96 |
+
if (env.USER_STATE) {
|
| 97 |
+
try { userVer = (await env.USER_STATE.get(verKey)) || 'v1'; } catch (_) {}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (/^\/v1\b/i.test(text)) {
|
| 101 |
+
if (env.USER_STATE) { try { await env.USER_STATE.put(verKey, 'v1'); } catch (_) {} }
|
| 102 |
+
await tg(env, "sendMessage", { chat_id: chatId, text: "✅ Mode set to V1 (Structured keyword placement)." });
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
if (/^\/v2\b/i.test(text)) {
|
| 106 |
+
if (env.USER_STATE) { try { await env.USER_STATE.put(verKey, 'v2'); } catch (_) {} }
|
| 107 |
+
await tg(env, "sendMessage", { chat_id: chatId, text: "✅ Mode set to V2 (Natural AI sentence integration)." });
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
if (/^\/mode\b/i.test(text)) {
|
| 111 |
+
await tg(env, "sendMessage", { chat_id: chatId, text: `Current mode: ${userVer.toUpperCase()}` });
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
if (!text || /^\/(start|help)\b/i.test(text)) {
|
| 116 |
await tg(env, "sendMessage", { chat_id: chatId, text: HELP, parse_mode: "Markdown" });
|
| 117 |
return;
|
|
|
|
| 130 |
return;
|
| 131 |
}
|
| 132 |
form.append("maximum_ats_mode", "1");
|
| 133 |
+
form.append("version", userVer);
|
| 134 |
|
| 135 |
let res;
|
| 136 |
try {
|
src/resume_v2_natural.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/resume_v2_natural.py — V2 Natural Sentence Integration engine.
|
| 3 |
+
|
| 4 |
+
Same keyword extraction + waterfall allocation as V1, but instead of
|
| 5 |
+
comma-separated keyword lists, an LLM (Kimi by default) generates natural
|
| 6 |
+
sentences that weave the keywords into genuine-sounding experience bullets.
|
| 7 |
+
|
| 8 |
+
Placement locations match V1 exactly:
|
| 9 |
+
Summary 15-20 | BYJU's PSM 25-30 | BYJU's PS 25-30 | ML Edutech 8-12
|
| 10 |
+
Skills Other 15-20 | Projects / NxtWave
|
| 11 |
+
|
| 12 |
+
Falls back to V1-style comma placement if the LLM call fails.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 21 |
+
|
| 22 |
+
import config
|
| 23 |
+
from src.llm_client import LLMClient
|
| 24 |
+
from src.latex_resume import (
|
| 25 |
+
compile_latex_to_pdf,
|
| 26 |
+
decide_includable_terms,
|
| 27 |
+
latex_escape,
|
| 28 |
+
latex_to_text,
|
| 29 |
+
_remove_injected_block,
|
| 30 |
+
_is_hardcoded_resume,
|
| 31 |
+
_specialty_hit,
|
| 32 |
+
)
|
| 33 |
+
from src.external_ats import external_coverage
|
| 34 |
+
from src.candidate_fit import _REGULATED_CRED
|
| 35 |
+
|
| 36 |
+
log = logging.getLogger("resume_v2")
|
| 37 |
+
|
| 38 |
+
# ── Inject markers (same as V1 so _remove_injected_block strips both) ────────
|
| 39 |
+
_INJECT_ITEM = "% ats-item"
|
| 40 |
+
_INJECT_SKILLS_OTHER = "% ats-skills-other"
|
| 41 |
+
_INJECT_MARKER = "% ats-injected-start"
|
| 42 |
+
_INJECT_END = "% ats-injected-end"
|
| 43 |
+
|
| 44 |
+
_SUMMARY_RE = re.compile(
|
| 45 |
+
r"(?:\\section\s*\{[^}]*(?:summary|profile|objective)[^}]*\}|"
|
| 46 |
+
r"\\(?:resumeSubheading|textbf|large)\s*\{[^}]*(?:summary|profile|objective)[^}]*\})",
|
| 47 |
+
re.I,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# ── Model config helpers ─────────────────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
def _cfg_by_name(name: str) -> dict | None:
|
| 53 |
+
return next((m for m in config.ASSESSMENT_MODELS if m.get("name") == name), None)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _v2_model_cfg() -> dict | None:
|
| 57 |
+
name = os.getenv("V2_JUDGE_MODEL", "Kimi-K2.6")
|
| 58 |
+
return _cfg_by_name(name.strip())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _fan_out_cfgs() -> list[dict]:
|
| 62 |
+
names = os.getenv("V2_FAN_OUT_MODELS", "Kimi-K2.6,Qwen3.5-397b,GPT-OSS-120b").split(",")
|
| 63 |
+
return [c for name in names if (c := _cfg_by_name(name.strip())) and c.get("api_key")]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ── LLM prompt for sentence generation ───────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
_SYSTEM_PROMPT = """\
|
| 69 |
+
You are a resume bullet-point writer for a Product Manager candidate.
|
| 70 |
+
Given keywords allocated to resume sections, write natural bullets that weave ALL keywords.
|
| 71 |
+
|
| 72 |
+
CANDIDATE PROFILE:
|
| 73 |
+
- Saiteja Tirunagari, Product Manager, 5+ years
|
| 74 |
+
- BYJU'S (Think & Learn Pvt Ltd):
|
| 75 |
+
* Asst. Product Success Manager: customer success, retention, onboarding analytics, \
|
| 76 |
+
stakeholder management, data-driven product decisions
|
| 77 |
+
* Product Specialist: product specs, feature launches, A/B testing, user research, \
|
| 78 |
+
cross-functional collaboration with engineering and design
|
| 79 |
+
- ML Edutech (Founder): AI/ML education product, generative AI, product strategy, MVP
|
| 80 |
+
- NxtWave (Internal Product Manager): ed-tech platform CCBP 4.0, learner engagement, \
|
| 81 |
+
curriculum product management
|
| 82 |
+
|
| 83 |
+
RULES:
|
| 84 |
+
1. Every keyword MUST appear verbatim in its section's output
|
| 85 |
+
2. Write as genuine accomplishments grounded in the profile above
|
| 86 |
+
3. Use active voice, quantify where natural
|
| 87 |
+
4. Each bullet: 80-200 characters
|
| 88 |
+
5. For summary: write a 1-2 sentence paragraph (no bullet marker)
|
| 89 |
+
6. For skills_other: just list keywords comma-separated
|
| 90 |
+
7. Return ONLY valid JSON — no markdown fences, no explanation text
|
| 91 |
+
8. Do NOT fabricate degrees, certifications, employers, or job titles
|
| 92 |
+
9. Do NOT add specialized engineering terms unless they appear in the keywords"""
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _build_user_prompt(allocations: dict, job_title: str, company: str) -> str:
|
| 96 |
+
parts = [f"TARGET ROLE: {job_title or 'Product Manager'} at {company or 'Company'}\n"]
|
| 97 |
+
parts.append("KEYWORDS PER SECTION (write enough bullets to cover ALL keywords):")
|
| 98 |
+
|
| 99 |
+
for section, terms in allocations.items():
|
| 100 |
+
if section == "projects":
|
| 101 |
+
for proj, proj_terms in terms.items():
|
| 102 |
+
if proj_terms:
|
| 103 |
+
n = len(proj_terms)
|
| 104 |
+
parts.append(f' projects_{proj} ({n} kw, 1 bullet): {json.dumps(proj_terms)}')
|
| 105 |
+
elif terms:
|
| 106 |
+
n = len(terms)
|
| 107 |
+
if section == "summary":
|
| 108 |
+
parts.append(f' summary ({n} kw, 1-2 sentence paragraph): {json.dumps(terms)}')
|
| 109 |
+
elif section == "skills_other":
|
| 110 |
+
parts.append(f' skills_other ({n} kw, comma list only): {json.dumps(terms)}')
|
| 111 |
+
elif n > 15:
|
| 112 |
+
parts.append(f' {section} ({n} kw, 2-3 bullets): {json.dumps(terms)}')
|
| 113 |
+
else:
|
| 114 |
+
parts.append(f' {section} ({n} kw, 1-2 bullets): {json.dumps(terms)}')
|
| 115 |
+
|
| 116 |
+
parts.append("""
|
| 117 |
+
Return JSON:
|
| 118 |
+
{
|
| 119 |
+
"summary": "paragraph text...",
|
| 120 |
+
"psm": ["bullet1...", "bullet2..."],
|
| 121 |
+
"ps": ["bullet1...", "bullet2..."],
|
| 122 |
+
"ml_edutech": ["bullet1..."],
|
| 123 |
+
"skills_other": "kw1, kw2, ...",
|
| 124 |
+
"nxtwave": ["bullet1..."],
|
| 125 |
+
"projects_FDP": ["bullet1..."],
|
| 126 |
+
"projects_Launchpad": ["bullet1..."],
|
| 127 |
+
...
|
| 128 |
+
}""")
|
| 129 |
+
return "\n".join(parts)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ── Keyword allocation (same waterfall as V1) ────────────────────────────────
|
| 133 |
+
|
| 134 |
+
def _allocate_keywords(jd_text: str, base_text: str, latex_src: str) -> tuple[dict, dict]:
|
| 135 |
+
"""Allocate JD keywords to sections using the exact V1 waterfall.
|
| 136 |
+
Returns (allocations_dict, decision_dict)."""
|
| 137 |
+
decision = decide_includable_terms(jd_text, base_text, maximum_ats_mode=True)
|
| 138 |
+
includable = decision["includable"]
|
| 139 |
+
|
| 140 |
+
src_low = (latex_src or "").lower()
|
| 141 |
+
seen: set[str] = set()
|
| 142 |
+
pool: list[str] = []
|
| 143 |
+
for t in includable:
|
| 144 |
+
tl = (t or "").strip().lower()
|
| 145 |
+
if not tl or tl in seen:
|
| 146 |
+
continue
|
| 147 |
+
seen.add(tl)
|
| 148 |
+
if tl in src_low:
|
| 149 |
+
continue
|
| 150 |
+
pool.append(t.strip())
|
| 151 |
+
|
| 152 |
+
def take(n: int) -> list[str]:
|
| 153 |
+
nonlocal pool
|
| 154 |
+
g = pool[:n]
|
| 155 |
+
pool = pool[n:]
|
| 156 |
+
return g
|
| 157 |
+
|
| 158 |
+
allocations: dict = {}
|
| 159 |
+
allocations["summary"] = take(20)
|
| 160 |
+
allocations["psm"] = take(30)
|
| 161 |
+
allocations["ps"] = take(30)
|
| 162 |
+
allocations["ml_edutech"] = take(12)
|
| 163 |
+
skills = take(20)
|
| 164 |
+
|
| 165 |
+
proj_order = ["FDP", "Launchpad", "OCR--OMR", "Offline NAT",
|
| 166 |
+
"AI Chatbot", "NIAT Application Portal"]
|
| 167 |
+
allocations["projects"] = {k: take(10) for k in proj_order}
|
| 168 |
+
allocations["nxtwave"] = take(20)
|
| 169 |
+
allocations["skills_other"] = skills + pool # overflow
|
| 170 |
+
return allocations, decision
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ── LLM response parsing ─────────────────────────────────────────────────────
|
| 174 |
+
|
| 175 |
+
def _parse_llm_response(raw: str) -> dict:
|
| 176 |
+
text = (raw or "").strip()
|
| 177 |
+
m = re.search(r"```(?:json)?\s*([\s\S]+?)```", text)
|
| 178 |
+
if m:
|
| 179 |
+
text = m.group(1).strip()
|
| 180 |
+
try:
|
| 181 |
+
return json.loads(text)
|
| 182 |
+
except Exception:
|
| 183 |
+
pass
|
| 184 |
+
idx = text.find("{")
|
| 185 |
+
if idx >= 0:
|
| 186 |
+
depth = 0
|
| 187 |
+
for i, ch in enumerate(text[idx:], idx):
|
| 188 |
+
if ch == "{":
|
| 189 |
+
depth += 1
|
| 190 |
+
elif ch == "}":
|
| 191 |
+
depth -= 1
|
| 192 |
+
if depth == 0:
|
| 193 |
+
try:
|
| 194 |
+
return json.loads(text[idx : i + 1])
|
| 195 |
+
except Exception:
|
| 196 |
+
break
|
| 197 |
+
return {}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _ensure_list(val) -> list[str]:
|
| 201 |
+
"""Normalize a sentence value to a list of strings."""
|
| 202 |
+
if isinstance(val, str):
|
| 203 |
+
return [val] if val.strip() else []
|
| 204 |
+
if isinstance(val, list):
|
| 205 |
+
return [s for s in val if isinstance(s, str) and s.strip()]
|
| 206 |
+
return []
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ── Sentence placement (same anchors as V1) ──────────────────────────────────
|
| 210 |
+
|
| 211 |
+
def _place_sentences_structured(
|
| 212 |
+
latex_src: str, sentences: dict, allocations: dict,
|
| 213 |
+
) -> tuple[str, list[str]]:
|
| 214 |
+
"""Inject V2 natural sentences into the same anchors as V1's
|
| 215 |
+
place_keywords_structured. Returns (new_source, placed_terms)."""
|
| 216 |
+
src = _remove_injected_block(latex_src or "")
|
| 217 |
+
|
| 218 |
+
exp_start = src.find("\\section{EXPERIENCE}")
|
| 219 |
+
proj_start = src.find("\\section{SELECTED")
|
| 220 |
+
if proj_start == -1:
|
| 221 |
+
proj_start = src.find("SELECTED 0")
|
| 222 |
+
skills_start = src.find("\\section{SKILLS}")
|
| 223 |
+
|
| 224 |
+
insertions: list[tuple[int, str]] = []
|
| 225 |
+
placed_terms: list[str] = []
|
| 226 |
+
|
| 227 |
+
def esc_join(ts: list[str]) -> str:
|
| 228 |
+
return ", ".join(latex_escape(t) for t in ts)
|
| 229 |
+
|
| 230 |
+
def add_items(anchor: str, bullets: list[str], fallback_terms: list[str],
|
| 231 |
+
search_start: int) -> None:
|
| 232 |
+
if search_start < 0:
|
| 233 |
+
return
|
| 234 |
+
a = src.find(anchor, search_start)
|
| 235 |
+
if a == -1:
|
| 236 |
+
return
|
| 237 |
+
e = src.find("\\resumeItemListEnd", a)
|
| 238 |
+
if e == -1:
|
| 239 |
+
return
|
| 240 |
+
p = src.rfind("\n", 0, e)
|
| 241 |
+
if p == -1:
|
| 242 |
+
return
|
| 243 |
+
if bullets:
|
| 244 |
+
block = ""
|
| 245 |
+
for b in bullets:
|
| 246 |
+
block += "\n \\resumeItem{" + latex_escape(b) + "} " + _INJECT_ITEM
|
| 247 |
+
insertions.append((p, block))
|
| 248 |
+
placed_terms.extend(fallback_terms)
|
| 249 |
+
elif fallback_terms:
|
| 250 |
+
block = "\n \\resumeItem{" + esc_join(fallback_terms) + "} " + _INJECT_ITEM
|
| 251 |
+
insertions.append((p, block))
|
| 252 |
+
placed_terms.extend(fallback_terms)
|
| 253 |
+
|
| 254 |
+
# Experience slots
|
| 255 |
+
add_items("Asst. Product Success Manager",
|
| 256 |
+
_ensure_list(sentences.get("psm")),
|
| 257 |
+
allocations.get("psm", []), exp_start)
|
| 258 |
+
add_items("Product Specialist",
|
| 259 |
+
_ensure_list(sentences.get("ps")),
|
| 260 |
+
allocations.get("ps", []), exp_start)
|
| 261 |
+
add_items("ML Edutech",
|
| 262 |
+
_ensure_list(sentences.get("ml_edutech")),
|
| 263 |
+
allocations.get("ml_edutech", []), exp_start)
|
| 264 |
+
add_items("Internal Product Manager",
|
| 265 |
+
_ensure_list(sentences.get("nxtwave")),
|
| 266 |
+
allocations.get("nxtwave", []), exp_start)
|
| 267 |
+
|
| 268 |
+
# Project slots
|
| 269 |
+
proj_order = ["FDP", "Launchpad", "OCR--OMR", "Offline NAT",
|
| 270 |
+
"AI Chatbot", "NIAT Application Portal"]
|
| 271 |
+
for k in proj_order:
|
| 272 |
+
proj_key = f"projects_{k}"
|
| 273 |
+
proj_terms = (allocations.get("projects") or {}).get(k, [])
|
| 274 |
+
add_items(k, _ensure_list(sentences.get(proj_key)), proj_terms, proj_start)
|
| 275 |
+
|
| 276 |
+
# Summary slot
|
| 277 |
+
summary_text = (sentences.get("summary") or "").strip() if isinstance(
|
| 278 |
+
sentences.get("summary"), str) else ""
|
| 279 |
+
summary_terms = allocations.get("summary", [])
|
| 280 |
+
if summary_text or summary_terms:
|
| 281 |
+
sm = _SUMMARY_RE.search(src)
|
| 282 |
+
if sm:
|
| 283 |
+
nxt = src.find("\\section", sm.end())
|
| 284 |
+
pos = nxt if nxt != -1 else sm.end()
|
| 285 |
+
content = latex_escape(summary_text) if summary_text else (
|
| 286 |
+
"Additional areas: " + esc_join(summary_terms) + ".")
|
| 287 |
+
block = (
|
| 288 |
+
"\n" + _INJECT_MARKER + "\n"
|
| 289 |
+
"\\par\\noindent " + content + "\n"
|
| 290 |
+
+ _INJECT_END + "\n"
|
| 291 |
+
)
|
| 292 |
+
insertions.append((pos, block))
|
| 293 |
+
placed_terms.extend(summary_terms)
|
| 294 |
+
|
| 295 |
+
# Skills "Other:" row
|
| 296 |
+
skills_terms = allocations.get("skills_other", [])
|
| 297 |
+
skills_text = sentences.get("skills_other", "")
|
| 298 |
+
if skills_terms and skills_start != -1:
|
| 299 |
+
se = src.find("\\end{itemize}", skills_start)
|
| 300 |
+
if se != -1:
|
| 301 |
+
close = src.rfind("}}", skills_start, se)
|
| 302 |
+
if close != -1:
|
| 303 |
+
content = (latex_escape(skills_text) if isinstance(skills_text, str)
|
| 304 |
+
and skills_text.strip() else esc_join(skills_terms))
|
| 305 |
+
row = (" \\\\\n \\textbf{Other}{: " + content
|
| 306 |
+
+ "} " + _INJECT_SKILLS_OTHER + "\n ")
|
| 307 |
+
insertions.append((close, row))
|
| 308 |
+
placed_terms.extend(skills_terms)
|
| 309 |
+
|
| 310 |
+
for pos, text in sorted(insertions, key=lambda x: x[0], reverse=True):
|
| 311 |
+
src = src[:pos] + text + src[pos:]
|
| 312 |
+
|
| 313 |
+
return src, placed_terms
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# ── Honesty + validation ─────────────────────────────────────────────────────
|
| 317 |
+
|
| 318 |
+
def _v2_honesty_check(winner_text: str, original_text: str) -> tuple[bool, list[str]]:
|
| 319 |
+
wt = (winner_text or "").lower()
|
| 320 |
+
ot = (original_text or "").lower()
|
| 321 |
+
violations: list[str] = []
|
| 322 |
+
for m in _REGULATED_CRED.finditer(wt):
|
| 323 |
+
term = m.group(0)
|
| 324 |
+
if term not in ot:
|
| 325 |
+
violations.append(f"regulated_cred:{term}")
|
| 326 |
+
return (len(violations) == 0, violations)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _validate_candidate(latex: str, original_latex: str) -> tuple[bool, str]:
|
| 330 |
+
if not latex or len(latex) < 500:
|
| 331 |
+
return (False, "too_short")
|
| 332 |
+
if not any(k in latex for k in (r"\documentclass", r"\begin{document}", r"\resumeSubheading")):
|
| 333 |
+
return (False, "not_latex")
|
| 334 |
+
markers = ["Saiteja Tirunagari", "NxtWave", "Think \\& Learn", "ML Edutech"]
|
| 335 |
+
for marker in markers:
|
| 336 |
+
if marker not in latex and marker.replace("\\\\", "\\") not in latex:
|
| 337 |
+
return (False, f"missing_marker:{marker}")
|
| 338 |
+
return (True, "ok")
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# ── Multi-model fan-out (optional, for extension/bot single-resume) ──────────
|
| 342 |
+
|
| 343 |
+
def _fan_out(latex_src: str, jd_text: str, job_title: str, company: str,
|
| 344 |
+
allocations: dict, model_cfgs: list[dict], llm: LLMClient) -> list[dict]:
|
| 345 |
+
"""Call multiple models in parallel, each generating sentences."""
|
| 346 |
+
user_prompt = _build_user_prompt(allocations, job_title, company)
|
| 347 |
+
|
| 348 |
+
def call_one(cfg: dict) -> dict:
|
| 349 |
+
try:
|
| 350 |
+
raw = llm._call_with_cfg(cfg, _SYSTEM_PROMPT, user_prompt, max_tokens=2000)
|
| 351 |
+
parsed = _parse_llm_response(raw)
|
| 352 |
+
return {"name": cfg["name"], "sentences": parsed, "error": None}
|
| 353 |
+
except Exception as exc:
|
| 354 |
+
return {"name": cfg["name"], "sentences": {}, "error": str(exc)}
|
| 355 |
+
|
| 356 |
+
results = []
|
| 357 |
+
with ThreadPoolExecutor(max_workers=len(model_cfgs)) as pool:
|
| 358 |
+
futures = {pool.submit(call_one, cfg): cfg for cfg in model_cfgs}
|
| 359 |
+
for fut in as_completed(futures):
|
| 360 |
+
results.append(fut.result())
|
| 361 |
+
return results
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _judge_candidates(candidates: list[dict], jd_text: str,
|
| 365 |
+
judge_cfg: dict, llm: LLMClient) -> int:
|
| 366 |
+
"""Ask the judge model to pick the best sentence set. Returns winner index."""
|
| 367 |
+
if len(candidates) <= 1:
|
| 368 |
+
return 0
|
| 369 |
+
snippets = []
|
| 370 |
+
for i, c in enumerate(candidates, 1):
|
| 371 |
+
s = c.get("sentences", {})
|
| 372 |
+
preview = json.dumps(s, indent=None)[:600]
|
| 373 |
+
snippets.append(f"CANDIDATE {i} ({c['name']}):\n{preview}")
|
| 374 |
+
|
| 375 |
+
prompt = (
|
| 376 |
+
"You are a resume quality judge. Below are sentence sets generated for a "
|
| 377 |
+
"Product Manager resume. Pick the one that reads most naturally and weaves "
|
| 378 |
+
"keywords best. Return ONLY the winning number (e.g. '1' or '2').\n\n"
|
| 379 |
+
+ "\n---\n".join(snippets)
|
| 380 |
+
)
|
| 381 |
+
try:
|
| 382 |
+
raw = llm._call_with_cfg(judge_cfg, "Pick the best resume sentences.", prompt, max_tokens=20)
|
| 383 |
+
digits = re.sub(r"\D", "", raw.strip())
|
| 384 |
+
n = int(digits) if digits else 1
|
| 385 |
+
if 1 <= n <= len(candidates):
|
| 386 |
+
return n - 1
|
| 387 |
+
except Exception:
|
| 388 |
+
pass
|
| 389 |
+
return 0
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# ── Main entry point ─────────────────────────────────────────────────────────
|
| 393 |
+
|
| 394 |
+
def generate_v2(
|
| 395 |
+
latex_src: str,
|
| 396 |
+
jd_text: str,
|
| 397 |
+
job_title: str = "",
|
| 398 |
+
company: str = "",
|
| 399 |
+
models: list[dict] | None = None,
|
| 400 |
+
judge: dict | None = None,
|
| 401 |
+
out_dir: str | None = None,
|
| 402 |
+
compile_pdf: bool = True,
|
| 403 |
+
) -> dict:
|
| 404 |
+
"""V2: extract keywords, allocate (same waterfall as V1), generate natural
|
| 405 |
+
sentences via LLM, place in resume, compile.
|
| 406 |
+
|
| 407 |
+
Falls back to V1-style comma placement if the LLM call fails."""
|
| 408 |
+
|
| 409 |
+
base_text = latex_to_text(latex_src or "")
|
| 410 |
+
|
| 411 |
+
# 1. Allocate keywords (exact V1 waterfall)
|
| 412 |
+
allocations, decision = _allocate_keywords(jd_text, base_text, latex_src)
|
| 413 |
+
|
| 414 |
+
has_keywords = any(
|
| 415 |
+
(isinstance(v, list) and v) or (isinstance(v, dict) and any(vv for vv in v.values()))
|
| 416 |
+
for v in allocations.values()
|
| 417 |
+
)
|
| 418 |
+
if not has_keywords:
|
| 419 |
+
return _build_report(latex_src, jd_text, decision,
|
| 420 |
+
compiled=False, engine=None, pdf_path=None,
|
| 421 |
+
v2_models_used=[], v2_winner="no_keywords",
|
| 422 |
+
judge_note="no_keywords_to_place")
|
| 423 |
+
|
| 424 |
+
# 2. Generate sentences via LLM
|
| 425 |
+
model_cfgs = models or []
|
| 426 |
+
primary_cfg = judge if judge is not None else _v2_model_cfg()
|
| 427 |
+
sentences: dict = {}
|
| 428 |
+
v2_models_used: list[str] = []
|
| 429 |
+
v2_winner = "v1_fallback"
|
| 430 |
+
judge_note = ""
|
| 431 |
+
|
| 432 |
+
# Single-model fast path (default for bulk speed)
|
| 433 |
+
if primary_cfg and primary_cfg.get("api_key"):
|
| 434 |
+
try:
|
| 435 |
+
llm = LLMClient()
|
| 436 |
+
user_prompt = _build_user_prompt(allocations, job_title, company)
|
| 437 |
+
raw = llm._call_with_cfg(primary_cfg, _SYSTEM_PROMPT, user_prompt,
|
| 438 |
+
max_tokens=2000)
|
| 439 |
+
sentences = _parse_llm_response(raw)
|
| 440 |
+
v2_models_used = [primary_cfg["name"]]
|
| 441 |
+
v2_winner = primary_cfg["name"]
|
| 442 |
+
judge_note = "single_model"
|
| 443 |
+
log.info("V2 sentences from %s: %d sections", primary_cfg["name"],
|
| 444 |
+
len(sentences))
|
| 445 |
+
except Exception as exc:
|
| 446 |
+
log.warning("V2 LLM failed (%s): %s", primary_cfg.get("name", "?"), exc)
|
| 447 |
+
judge_note = f"primary_failed:{str(exc)[:80]}"
|
| 448 |
+
|
| 449 |
+
# Multi-model fan-out path (when explicitly configured with models param)
|
| 450 |
+
if not sentences and model_cfgs:
|
| 451 |
+
try:
|
| 452 |
+
llm = LLMClient()
|
| 453 |
+
results = _fan_out(latex_src, jd_text, job_title, company,
|
| 454 |
+
allocations, model_cfgs, llm)
|
| 455 |
+
valid = [r for r in results if r["sentences"] and not r["error"]]
|
| 456 |
+
if valid:
|
| 457 |
+
if len(valid) > 1 and primary_cfg and primary_cfg.get("api_key"):
|
| 458 |
+
idx = _judge_candidates(valid, jd_text, primary_cfg, llm)
|
| 459 |
+
else:
|
| 460 |
+
idx = 0
|
| 461 |
+
sentences = valid[idx]["sentences"]
|
| 462 |
+
v2_models_used = [r["name"] for r in valid]
|
| 463 |
+
v2_winner = valid[idx]["name"]
|
| 464 |
+
judge_note = "fan_out"
|
| 465 |
+
except Exception as exc:
|
| 466 |
+
log.warning("V2 fan-out failed: %s", exc)
|
| 467 |
+
judge_note = f"fan_out_failed:{str(exc)[:80]}"
|
| 468 |
+
|
| 469 |
+
# Fallback: try each fan-out model individually
|
| 470 |
+
if not sentences:
|
| 471 |
+
for cfg in _fan_out_cfgs():
|
| 472 |
+
if cfg.get("api_key") and cfg.get("name") != (primary_cfg or {}).get("name"):
|
| 473 |
+
try:
|
| 474 |
+
llm = LLMClient()
|
| 475 |
+
raw = llm._call_with_cfg(
|
| 476 |
+
cfg, _SYSTEM_PROMPT,
|
| 477 |
+
_build_user_prompt(allocations, job_title, company),
|
| 478 |
+
max_tokens=2000)
|
| 479 |
+
sentences = _parse_llm_response(raw)
|
| 480 |
+
if sentences:
|
| 481 |
+
v2_models_used = [cfg["name"]]
|
| 482 |
+
v2_winner = cfg["name"]
|
| 483 |
+
judge_note = "fallback_model"
|
| 484 |
+
break
|
| 485 |
+
except Exception:
|
| 486 |
+
continue
|
| 487 |
+
|
| 488 |
+
# 3. Place sentences (or V1 comma fallback)
|
| 489 |
+
if _is_hardcoded_resume(latex_src):
|
| 490 |
+
new_src, placed = _place_sentences_structured(
|
| 491 |
+
latex_src, sentences, allocations)
|
| 492 |
+
else:
|
| 493 |
+
from src.latex_resume import inject_keywords
|
| 494 |
+
all_terms: list[str] = []
|
| 495 |
+
for v in allocations.values():
|
| 496 |
+
if isinstance(v, list):
|
| 497 |
+
all_terms.extend(v)
|
| 498 |
+
elif isinstance(v, dict):
|
| 499 |
+
for pts in v.values():
|
| 500 |
+
all_terms.extend(pts)
|
| 501 |
+
new_src, placed = inject_keywords(latex_src, all_terms)
|
| 502 |
+
|
| 503 |
+
if not sentences:
|
| 504 |
+
v2_winner = "v1_fallback"
|
| 505 |
+
judge_note = judge_note or "all_models_failed"
|
| 506 |
+
|
| 507 |
+
# 4. Honesty check
|
| 508 |
+
new_text = latex_to_text(new_src)
|
| 509 |
+
ok, violations = _v2_honesty_check(new_text, base_text)
|
| 510 |
+
if not ok:
|
| 511 |
+
log.warning("V2 honesty violation: %s — rebuilding with V1 placement", violations)
|
| 512 |
+
from src.latex_resume import place_keywords_structured
|
| 513 |
+
all_terms = []
|
| 514 |
+
for v in allocations.values():
|
| 515 |
+
if isinstance(v, list):
|
| 516 |
+
all_terms.extend(v)
|
| 517 |
+
elif isinstance(v, dict):
|
| 518 |
+
for pts in v.values():
|
| 519 |
+
all_terms.extend(pts)
|
| 520 |
+
new_src, placed = place_keywords_structured(latex_src, all_terms)
|
| 521 |
+
judge_note = f"honesty_fallback:{violations[:3]}"
|
| 522 |
+
v2_winner = "v1_honesty_fallback"
|
| 523 |
+
|
| 524 |
+
# 5. Compile
|
| 525 |
+
comp: dict = {"compiled": False, "engine": None, "pdf_path": None}
|
| 526 |
+
if compile_pdf and out_dir:
|
| 527 |
+
try:
|
| 528 |
+
comp = compile_latex_to_pdf(new_src, out_dir, jobname="resume_v2",
|
| 529 |
+
timeout=420)
|
| 530 |
+
except Exception as exc:
|
| 531 |
+
log.warning("V2 compile failed: %s", exc)
|
| 532 |
+
|
| 533 |
+
return _build_report(new_src, jd_text, decision,
|
| 534 |
+
compiled=comp.get("compiled", False),
|
| 535 |
+
engine=comp.get("engine"),
|
| 536 |
+
pdf_path=comp.get("pdf_path"),
|
| 537 |
+
v2_models_used=v2_models_used,
|
| 538 |
+
v2_winner=v2_winner,
|
| 539 |
+
judge_note=judge_note)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def _build_report(tex: str, jd_text: str, decision: dict,
|
| 543 |
+
compiled: bool, engine, pdf_path,
|
| 544 |
+
v2_models_used: list[str], v2_winner: str,
|
| 545 |
+
judge_note: str) -> dict:
|
| 546 |
+
text = latex_to_text(tex)
|
| 547 |
+
expected = decision.get("expected_terms", [])
|
| 548 |
+
cov = external_coverage(expected, text)
|
| 549 |
+
return {
|
| 550 |
+
"tex": tex,
|
| 551 |
+
"pdf_path": pdf_path,
|
| 552 |
+
"compiled": compiled,
|
| 553 |
+
"engine": engine,
|
| 554 |
+
"pct": cov.get("pct", 0),
|
| 555 |
+
"expected": cov.get("expected", 0),
|
| 556 |
+
"found": cov.get("found", 0),
|
| 557 |
+
"missing": cov.get("missing", []),
|
| 558 |
+
"keywords": decision.get("keywords", expected),
|
| 559 |
+
"source": "latex_v2",
|
| 560 |
+
"v2_models_used": v2_models_used,
|
| 561 |
+
"v2_winner": v2_winner,
|
| 562 |
+
"judge_note": judge_note,
|
| 563 |
+
}
|
src/telegram_bot.py
CHANGED
|
@@ -41,6 +41,7 @@ except Exception: # noqa: BLE001
|
|
| 41 |
_API = "https://api.telegram.org/bot{token}/{method}"
|
| 42 |
_URL_RE = re.compile(r"https?://\S+")
|
| 43 |
_SEND_RETRIES = 3
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def _proxies():
|
|
@@ -159,7 +160,7 @@ _HELP = (
|
|
| 159 |
"• Company/ATS links (Greenhouse, Lever, Ashby, Naukri) usually work directly.\n"
|
| 160 |
"• If I can't read a LinkedIn/Indeed link, just copy the description text and "
|
| 161 |
"send that instead.\n\n"
|
| 162 |
-
"Commands: /start, /help"
|
| 163 |
)
|
| 164 |
|
| 165 |
|
|
@@ -183,6 +184,20 @@ def process_update(update: dict) -> None:
|
|
| 183 |
send_message(chat_id, _HELP)
|
| 184 |
return
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
# 1. Resolve the JD: a URL we fetch, or pasted description text.
|
| 187 |
url_match = _URL_RE.search(text)
|
| 188 |
job_title = ""
|
|
@@ -223,11 +238,20 @@ def process_update(update: dict) -> None:
|
|
| 223 |
except Exception: # noqa: BLE001
|
| 224 |
blocked = []
|
| 225 |
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
pct = report.get("pct", 0) or 0
|
| 232 |
pdf_path = report.get("pdf_path")
|
| 233 |
|
|
|
|
| 41 |
_API = "https://api.telegram.org/bot{token}/{method}"
|
| 42 |
_URL_RE = re.compile(r"https?://\S+")
|
| 43 |
_SEND_RETRIES = 3
|
| 44 |
+
_user_version: dict[int, str] = {}
|
| 45 |
|
| 46 |
|
| 47 |
def _proxies():
|
|
|
|
| 160 |
"• Company/ATS links (Greenhouse, Lever, Ashby, Naukri) usually work directly.\n"
|
| 161 |
"• If I can't read a LinkedIn/Indeed link, just copy the description text and "
|
| 162 |
"send that instead.\n\n"
|
| 163 |
+
"Commands: /start, /help, /v1, /v2, /mode"
|
| 164 |
)
|
| 165 |
|
| 166 |
|
|
|
|
| 184 |
send_message(chat_id, _HELP)
|
| 185 |
return
|
| 186 |
|
| 187 |
+
# ── Version commands ───────────────────────────────────────────────
|
| 188 |
+
if text.lower() in ("/v1",):
|
| 189 |
+
_user_version[user_id] = "v1"
|
| 190 |
+
send_message(chat_id, "✅ Mode set to V1 (Structured keyword placement).")
|
| 191 |
+
return
|
| 192 |
+
if text.lower() in ("/v2",):
|
| 193 |
+
_user_version[user_id] = "v2"
|
| 194 |
+
send_message(chat_id, "✅ Mode set to V2 (Natural AI sentence integration).")
|
| 195 |
+
return
|
| 196 |
+
if text.lower() in ("/mode",):
|
| 197 |
+
ver = _user_version.get(user_id, "v1")
|
| 198 |
+
send_message(chat_id, f"Current mode: {ver.upper()}")
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
# 1. Resolve the JD: a URL we fetch, or pasted description text.
|
| 202 |
url_match = _URL_RE.search(text)
|
| 203 |
job_title = ""
|
|
|
|
| 238 |
except Exception: # noqa: BLE001
|
| 239 |
blocked = []
|
| 240 |
|
| 241 |
+
ver = _user_version.get(user_id, "v1")
|
| 242 |
+
if ver == "v2":
|
| 243 |
+
from src.resume_v2_natural import generate_v2
|
| 244 |
+
report = generate_v2(
|
| 245 |
+
get_default_resume_latex(), jd_text,
|
| 246 |
+
job_title=job_title, company="",
|
| 247 |
+
out_dir=out_dir, compile_pdf=True,
|
| 248 |
+
)
|
| 249 |
+
else:
|
| 250 |
+
report = optimize_latex_resume(
|
| 251 |
+
get_default_resume_latex(), jd_text,
|
| 252 |
+
maximum_ats_mode=True, blocked_terms=blocked,
|
| 253 |
+
compile_pdf=True, out_dir=out_dir, job_title=job_title,
|
| 254 |
+
)
|
| 255 |
pct = report.get("pct", 0) or 0
|
| 256 |
pdf_path = report.get("pdf_path")
|
| 257 |
|
tests/test_resume_v2.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""tests/test_resume_v2.py — V2 engine unit tests (mocked LLM, no network/Tectonic)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
# Import-chain guard: src/resume_v2_natural imports config which transitively
|
| 11 |
+
# pulls heavy dependencies. Skip gracefully if unavailable.
|
| 12 |
+
try:
|
| 13 |
+
from src.resume_v2_natural import (
|
| 14 |
+
generate_v2,
|
| 15 |
+
_validate_candidate,
|
| 16 |
+
_v2_honesty_check,
|
| 17 |
+
_allocate_keywords,
|
| 18 |
+
_place_sentences_structured,
|
| 19 |
+
_parse_llm_response,
|
| 20 |
+
_ensure_list,
|
| 21 |
+
)
|
| 22 |
+
except Exception as exc:
|
| 23 |
+
pytestmark = pytest.mark.skip(f"resume_v2_natural import unavailable: {exc}")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def real_resume():
|
| 30 |
+
"""Load the hardcoded resume source for testing."""
|
| 31 |
+
paths = [
|
| 32 |
+
os.path.join(os.path.dirname(__file__), "..",
|
| 33 |
+
".planning", "phases",
|
| 34 |
+
"09-hardcoded-resume-keyword-placement", "resume-source.tex"),
|
| 35 |
+
]
|
| 36 |
+
for p in paths:
|
| 37 |
+
if os.path.exists(p):
|
| 38 |
+
return open(p, encoding="utf-8").read()
|
| 39 |
+
try:
|
| 40 |
+
from src.default_resume import get_default_resume_latex
|
| 41 |
+
return get_default_resume_latex()
|
| 42 |
+
except Exception:
|
| 43 |
+
pytest.skip("No resume source available")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
SAMPLE_JD = (
|
| 47 |
+
"We are looking for a Product Manager with experience in roadmap planning, "
|
| 48 |
+
"stakeholder management, A/B testing, data-driven decisions, cross-functional "
|
| 49 |
+
"collaboration, product analytics, agile methodologies, scrum, user research, "
|
| 50 |
+
"feature prioritization, product strategy, SaaS, B2B, enterprise platform, "
|
| 51 |
+
"customer success, onboarding, retention, growth, engagement, conversion, "
|
| 52 |
+
"monetization, revenue, go-to-market, product discovery, product lifecycle, "
|
| 53 |
+
"PRD, user stories, acceptance criteria, backlog grooming, sprint planning, "
|
| 54 |
+
"OKRs, KPIs, metrics, SQL, dashboards, competitive analysis, wireframing"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ── _validate_candidate ─────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
def test_validate_short():
|
| 61 |
+
ok, r = _validate_candidate("short", "")
|
| 62 |
+
assert not ok
|
| 63 |
+
assert r == "too_short"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_validate_not_latex():
|
| 67 |
+
ok, r = _validate_candidate("x" * 600, "")
|
| 68 |
+
assert not ok
|
| 69 |
+
assert r == "not_latex"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_validate_missing_marker():
|
| 73 |
+
fake = r"\documentclass{article}\begin{document}" + "A" * 500 + r"\end{document}"
|
| 74 |
+
ok, r = _validate_candidate(fake, "")
|
| 75 |
+
assert not ok
|
| 76 |
+
assert "missing_marker" in r
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_validate_real_resume(real_resume):
|
| 80 |
+
ok, r = _validate_candidate(real_resume, "")
|
| 81 |
+
assert ok
|
| 82 |
+
assert r == "ok"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ── _v2_honesty_check ────────────────────────────────────────────────────────
|
| 86 |
+
|
| 87 |
+
def test_honesty_clean():
|
| 88 |
+
original = "Product Manager with experience in agile and roadmap planning"
|
| 89 |
+
winner = "Product Manager driving roadmap and agile sprints with stakeholders"
|
| 90 |
+
ok, violations = _v2_honesty_check(winner, original)
|
| 91 |
+
assert ok
|
| 92 |
+
assert violations == []
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_honesty_blocks_regulated_cred():
|
| 96 |
+
original = "Product Manager at BYJU'S"
|
| 97 |
+
winner = "CISSP-certified Product Manager at BYJU'S with PMP credentials"
|
| 98 |
+
ok, violations = _v2_honesty_check(winner, original)
|
| 99 |
+
assert not ok
|
| 100 |
+
assert any("cissp" in v.lower() or "pmp" in v.lower() for v in violations)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ── _allocate_keywords ───────────────────────────────────────────────────────
|
| 104 |
+
|
| 105 |
+
def test_allocate_keywords(real_resume):
|
| 106 |
+
allocations, decision = _allocate_keywords(SAMPLE_JD, "", real_resume)
|
| 107 |
+
assert "summary" in allocations
|
| 108 |
+
assert "psm" in allocations
|
| 109 |
+
assert "ps" in allocations
|
| 110 |
+
assert "ml_edutech" in allocations
|
| 111 |
+
assert "skills_other" in allocations
|
| 112 |
+
assert "projects" in allocations
|
| 113 |
+
assert "nxtwave" in allocations
|
| 114 |
+
assert isinstance(allocations["projects"], dict)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ── _parse_llm_response ─────────────────────────────────────────────────────
|
| 118 |
+
|
| 119 |
+
def test_parse_json():
|
| 120 |
+
raw = '{"summary": "test paragraph", "psm": ["bullet1"]}'
|
| 121 |
+
result = _parse_llm_response(raw)
|
| 122 |
+
assert result["summary"] == "test paragraph"
|
| 123 |
+
assert result["psm"] == ["bullet1"]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_parse_fenced():
|
| 127 |
+
raw = "```json\n{\"summary\": \"test\"}\n```"
|
| 128 |
+
result = _parse_llm_response(raw)
|
| 129 |
+
assert result["summary"] == "test"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_parse_garbage():
|
| 133 |
+
result = _parse_llm_response("not json at all")
|
| 134 |
+
assert result == {}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ── _ensure_list ─────────────────────────────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
def test_ensure_list_string():
|
| 140 |
+
assert _ensure_list("hello") == ["hello"]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_ensure_list_array():
|
| 144 |
+
assert _ensure_list(["a", "b"]) == ["a", "b"]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_ensure_list_empty():
|
| 148 |
+
assert _ensure_list("") == []
|
| 149 |
+
assert _ensure_list(None) == []
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ── _place_sentences_structured ──────────────────────────────────────────────
|
| 153 |
+
|
| 154 |
+
def test_place_sentences(real_resume):
|
| 155 |
+
sentences = {
|
| 156 |
+
"summary": "Experienced PM driving product strategy and roadmap.",
|
| 157 |
+
"psm": ["Led stakeholder management and retention analytics initiatives."],
|
| 158 |
+
"ps": ["Drove A/B testing and feature prioritization."],
|
| 159 |
+
"skills_other": "SQL, dashboards, agile",
|
| 160 |
+
}
|
| 161 |
+
allocations = {
|
| 162 |
+
"summary": ["product strategy", "roadmap"],
|
| 163 |
+
"psm": ["stakeholder management", "retention"],
|
| 164 |
+
"ps": ["A/B testing", "feature prioritization"],
|
| 165 |
+
"ml_edutech": [],
|
| 166 |
+
"skills_other": ["SQL", "dashboards", "agile"],
|
| 167 |
+
"projects": {},
|
| 168 |
+
"nxtwave": [],
|
| 169 |
+
}
|
| 170 |
+
new_src, placed = _place_sentences_structured(real_resume, sentences, allocations)
|
| 171 |
+
assert len(new_src) > len(real_resume)
|
| 172 |
+
assert "Experienced PM driving product strategy" in new_src
|
| 173 |
+
assert "Led stakeholder management" in new_src
|
| 174 |
+
assert "ats-item" in new_src
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ── generate_v2 (mocked LLM) ────────────────────────────────────────────────
|
| 178 |
+
|
| 179 |
+
def _fake_call_with_cfg(cfg, system, user, max_tokens=2000):
|
| 180 |
+
return json.dumps({
|
| 181 |
+
"summary": "Experienced Product Manager driving product strategy and roadmap.",
|
| 182 |
+
"psm": ["Led stakeholder management and cross-functional collaboration."],
|
| 183 |
+
"ps": ["Drove A/B testing and user research."],
|
| 184 |
+
"ml_edutech": ["Built AI product with data-driven approach."],
|
| 185 |
+
"skills_other": "SQL, dashboards, agile, scrum",
|
| 186 |
+
"nxtwave": ["Managed product lifecycle and sprint planning."],
|
| 187 |
+
})
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_generate_v2_report_shape(real_resume, monkeypatch):
|
| 191 |
+
monkeypatch.setattr("src.resume_v2_natural.LLMClient._call_with_cfg",
|
| 192 |
+
_fake_call_with_cfg)
|
| 193 |
+
report = generate_v2(
|
| 194 |
+
real_resume, SAMPLE_JD,
|
| 195 |
+
job_title="Product Manager", company="TestCo",
|
| 196 |
+
compile_pdf=False,
|
| 197 |
+
)
|
| 198 |
+
assert isinstance(report, dict)
|
| 199 |
+
for key in ("tex", "compiled", "engine", "pct", "expected", "found",
|
| 200 |
+
"missing", "keywords", "source", "v2_models_used",
|
| 201 |
+
"v2_winner", "judge_note"):
|
| 202 |
+
assert key in report, f"missing key: {key}"
|
| 203 |
+
assert report["source"] == "latex_v2"
|
| 204 |
+
assert isinstance(report["v2_models_used"], list)
|
| 205 |
+
assert report["tex"] != real_resume # keywords were placed
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_generate_v2_judge_fallback(real_resume, monkeypatch):
|
| 209 |
+
"""When the LLM raises, V2 falls back to V1-style placement."""
|
| 210 |
+
def _raise(*a, **kw):
|
| 211 |
+
raise RuntimeError("model unavailable")
|
| 212 |
+
monkeypatch.setattr("src.resume_v2_natural.LLMClient._call_with_cfg", _raise)
|
| 213 |
+
monkeypatch.setattr("src.resume_v2_natural._fan_out_cfgs", lambda: [])
|
| 214 |
+
|
| 215 |
+
report = generate_v2(
|
| 216 |
+
real_resume, SAMPLE_JD,
|
| 217 |
+
job_title="Product Manager", company="TestCo",
|
| 218 |
+
compile_pdf=False,
|
| 219 |
+
)
|
| 220 |
+
assert isinstance(report, dict)
|
| 221 |
+
assert "v1_fallback" in report["v2_winner"]
|
| 222 |
+
assert report["source"] == "latex_v2"
|
| 223 |
+
for key in ("tex", "pct", "expected", "found", "missing"):
|
| 224 |
+
assert key in report
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_generate_v2_minimal_jd(real_resume, monkeypatch):
|
| 228 |
+
"""Even with a trivial JD, V2 returns a valid report without crashing."""
|
| 229 |
+
monkeypatch.setattr("src.resume_v2_natural.LLMClient._call_with_cfg",
|
| 230 |
+
_fake_call_with_cfg)
|
| 231 |
+
report = generate_v2(
|
| 232 |
+
real_resume, "hello world",
|
| 233 |
+
compile_pdf=False,
|
| 234 |
+
)
|
| 235 |
+
assert isinstance(report, dict)
|
| 236 |
+
assert "tex" in report
|
| 237 |
+
assert report["source"] == "latex_v2"
|
ui.py
CHANGED
|
@@ -639,6 +639,7 @@ _DEFAULTS = {
|
|
| 639 |
"setup_step": 1,
|
| 640 |
"completed_jobs": [], # per-job results streamed in during a run
|
| 641 |
"custom_roles": [], # user-added custom role titles
|
|
|
|
| 642 |
"logged_in": False, "user_id": None, "user_email": "",
|
| 643 |
}
|
| 644 |
for _k, _v in _DEFAULTS.items():
|
|
@@ -1206,6 +1207,15 @@ if st.session_state.show_history:
|
|
| 1206 |
show_config = not (st.session_state.running or st.session_state.results)
|
| 1207 |
start = False # set to True only on step 7 launch button
|
| 1208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1209 |
# Platform imports (needed even when not showing config, for pipeline)
|
| 1210 |
from src.ever_jobs_bridge.platforms import PLATFORM_GROUPS, INDIA_DEFAULT_PLATFORMS, EVER_JOBS_PLATFORMS
|
| 1211 |
|
|
@@ -2314,16 +2324,50 @@ if show_config and start and not st.session_state.running:
|
|
| 2314 |
"review_terms": job.get("review_terms", []),
|
| 2315 |
}))
|
| 2316 |
|
| 2317 |
-
|
| 2318 |
-
|
| 2319 |
-
|
| 2320 |
-
|
| 2321 |
-
|
| 2322 |
-
|
| 2323 |
-
|
| 2324 |
-
|
| 2325 |
-
|
| 2326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2327 |
llm_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "LLM Tailored")
|
| 2328 |
tmpl_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "Template")
|
| 2329 |
pdf_done = sum(1 for j in assessed_jobs if j.get("resume_pdf_path"))
|
|
|
|
| 639 |
"setup_step": 1,
|
| 640 |
"completed_jobs": [], # per-job results streamed in during a run
|
| 641 |
"custom_roles": [], # user-added custom role titles
|
| 642 |
+
"_gen_version": "v1", # resume generation mode: "v1" or "v2"
|
| 643 |
"logged_in": False, "user_id": None, "user_email": "",
|
| 644 |
}
|
| 645 |
for _k, _v in _DEFAULTS.items():
|
|
|
|
| 1207 |
show_config = not (st.session_state.running or st.session_state.results)
|
| 1208 |
start = False # set to True only on step 7 launch button
|
| 1209 |
|
| 1210 |
+
# ── V1/V2 generation mode selector (home page, outside wizard) ──────────
|
| 1211 |
+
_vlabel = st.radio(
|
| 1212 |
+
"Resume generation mode",
|
| 1213 |
+
options=["V1 — Structured (keyword placement)", "V2 — Natural AI (sentence integration)"],
|
| 1214 |
+
index=0 if st.session_state.get("_gen_version", "v1") == "v1" else 1,
|
| 1215 |
+
horizontal=True, key="_gen_version_radio",
|
| 1216 |
+
)
|
| 1217 |
+
st.session_state["_gen_version"] = "v2" if _vlabel.startswith("V2") else "v1"
|
| 1218 |
+
|
| 1219 |
# Platform imports (needed even when not showing config, for pipeline)
|
| 1220 |
from src.ever_jobs_bridge.platforms import PLATFORM_GROUPS, INDIA_DEFAULT_PLATFORMS, EVER_JOBS_PLATFORMS
|
| 1221 |
|
|
|
|
| 2324 |
"review_terms": job.get("review_terms", []),
|
| 2325 |
}))
|
| 2326 |
|
| 2327 |
+
# ── V2 bulk: sentence-integration pipeline per job ──────────
|
| 2328 |
+
_bulk_version = st.session_state.get("_gen_version", "v1")
|
| 2329 |
+
_v2_bulk_done = False
|
| 2330 |
+
if _bulk_version == "v2":
|
| 2331 |
+
_q_log("📝 V2 mode: generating sentence-based resumes per job…")
|
| 2332 |
+
try:
|
| 2333 |
+
from src.default_resume import get_default_resume_latex
|
| 2334 |
+
from src.resume_v2_natural import generate_v2
|
| 2335 |
+
_v2_latex = get_default_resume_latex()
|
| 2336 |
+
_v2_count = 0
|
| 2337 |
+
for _j in assessed_jobs:
|
| 2338 |
+
if not _j.get("jd_text"):
|
| 2339 |
+
continue
|
| 2340 |
+
try:
|
| 2341 |
+
_v2_dir = os.path.join(_ocfg["resumes_dir"], f"v2_{_v2_count}")
|
| 2342 |
+
os.makedirs(_v2_dir, exist_ok=True)
|
| 2343 |
+
_v2r = generate_v2(
|
| 2344 |
+
_v2_latex, _j["jd_text"],
|
| 2345 |
+
job_title=_j.get("title", ""),
|
| 2346 |
+
company=_j.get("company", ""),
|
| 2347 |
+
out_dir=_v2_dir, compile_pdf=True,
|
| 2348 |
+
)
|
| 2349 |
+
_j["resume_path"] = _v2r.get("pdf_path") or ""
|
| 2350 |
+
_j["ats_score"] = _v2r.get("pct", 0)
|
| 2351 |
+
_v2_count += 1
|
| 2352 |
+
_q_log(f" V2 #{_v2_count}: {_j.get('title', '')} — {_v2r.get('pct', 0)}%")
|
| 2353 |
+
except Exception as _v2e:
|
| 2354 |
+
_q_log(f" V2 failed for {_j.get('title', '')}: {_v2e}")
|
| 2355 |
+
_q_log(f"✅ V2 generated {_v2_count} resumes")
|
| 2356 |
+
_v2_bulk_done = True
|
| 2357 |
+
except Exception as _v2_exc:
|
| 2358 |
+
_q_log(f"⚠️ V2 bulk failed, falling back to V1: {_v2_exc}")
|
| 2359 |
+
|
| 2360 |
+
if not _v2_bulk_done:
|
| 2361 |
+
customizer = ResumeCustomizer(llm, resume_text, _ocfg["resumes_dir"],
|
| 2362 |
+
fast_model_cfg=fast_cfg)
|
| 2363 |
+
assessed_jobs = customizer.customize_for_jobs(
|
| 2364 |
+
assessed_jobs,
|
| 2365 |
+
min_score_for_llm=_min_score,
|
| 2366 |
+
max_llm_resumes=len(assessed_jobs),
|
| 2367 |
+
generate_all=True,
|
| 2368 |
+
model_cfgs=phase2_cfgs,
|
| 2369 |
+
progress_cb=_resume_cb,
|
| 2370 |
+
)
|
| 2371 |
llm_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "LLM Tailored")
|
| 2372 |
tmpl_done = sum(1 for j in assessed_jobs if j.get("resume_generated") == "Template")
|
| 2373 |
pdf_done = sum(1 for j in assessed_jobs if j.get("resume_pdf_path"))
|