saitejatirunagari Claude Opus 4.6 commited on
Commit
f057ca2
·
1 Parent(s): 431d531

feat(phase-10): V2 natural sentence resume mode — Kimi LLM generates sentences, same V1 waterfall

Browse files

V2 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 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.7.0",
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
- report = optimize_latex_resume(
227
- get_default_resume_latex(), jd_text,
228
- maximum_ats_mode=True, blocked_terms=blocked,
229
- compile_pdf=True, out_dir=out_dir, job_title=job_title,
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
- customizer = ResumeCustomizer(llm, resume_text, _ocfg["resumes_dir"],
2318
- fast_model_cfg=fast_cfg)
2319
- assessed_jobs = customizer.customize_for_jobs(
2320
- assessed_jobs,
2321
- min_score_for_llm=_min_score,
2322
- max_llm_resumes=len(assessed_jobs),
2323
- generate_all=True,
2324
- model_cfgs=phase2_cfgs,
2325
- progress_cb=_resume_cb,
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"))