chameleon-cv 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +186 -0
- package/LICENSE +21 -0
- package/Makefile +136 -0
- package/README.md +356 -0
- package/bin/chameleon.js +136 -0
- package/chameleon.bat +286 -0
- package/chameleon.sh +353 -0
- package/package.json +39 -0
- package/profiles/github_repos.json +1266 -0
- package/requirements.txt +5 -0
- package/scan.sh +12 -0
- package/score.sh +12 -0
- package/scripts/__init__.py +1 -0
- package/scripts/__main_tui__.py +11 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
- package/scripts/__pycache__/config.cpython-312.pyc +0 -0
- package/scripts/__pycache__/config.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/db.cpython-312.pyc +0 -0
- package/scripts/__pycache__/db.cpython-313.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
- package/scripts/__pycache__/question.cpython-313.pyc +0 -0
- package/scripts/__pycache__/render.cpython-313.pyc +0 -0
- package/scripts/__pycache__/review.cpython-313.pyc +0 -0
- package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
- package/scripts/ats_ghost.py +170 -0
- package/scripts/cache.py +63 -0
- package/scripts/cli.py +109 -0
- package/scripts/config.py +376 -0
- package/scripts/cover_letter.py +105 -0
- package/scripts/db.py +331 -0
- package/scripts/fonts.py +186 -0
- package/scripts/ghost.py +88 -0
- package/scripts/job_matcher.py +636 -0
- package/scripts/job_scanner/__init__.py +8 -0
- package/scripts/job_scanner/__main__.py +6 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
- package/scripts/job_scanner/base.py +197 -0
- package/scripts/job_scanner/browser.py +244 -0
- package/scripts/job_scanner/cli.py +6 -0
- package/scripts/job_scanner/parsers/__init__.py +24 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/adzuna.py +81 -0
- package/scripts/job_scanner/parsers/arc.py +65 -0
- package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
- package/scripts/job_scanner/parsers/dailyremote.py +91 -0
- package/scripts/job_scanner/parsers/flexjobs.py +91 -0
- package/scripts/job_scanner/parsers/himalayas.py +47 -0
- package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
- package/scripts/job_scanner/parsers/indeed.py +98 -0
- package/scripts/job_scanner/parsers/jobicy.py +60 -0
- package/scripts/job_scanner/parsers/jooble.py +68 -0
- package/scripts/job_scanner/parsers/linkedin.py +140 -0
- package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
- package/scripts/job_scanner/parsers/relocate.py +95 -0
- package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
- package/scripts/job_scanner/parsers/remoteok.py +63 -0
- package/scripts/job_scanner/parsers/remotive.py +57 -0
- package/scripts/job_scanner/parsers/web3_career.py +92 -0
- package/scripts/job_scanner/parsers/wellfound.py +94 -0
- package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
- package/scripts/job_scanner/parsers/ycomb.py +83 -0
- package/scripts/job_scanner/registry.py +65 -0
- package/scripts/job_scanner/scanner.py +162 -0
- package/scripts/question.py +107 -0
- package/scripts/render.py +110 -0
- package/scripts/review.py +89 -0
- package/scripts/setup.py +139 -0
- package/scripts/tailor_brief.py +379 -0
- package/scripts/tailor_cv.py +746 -0
- package/scripts/tailor_prompts.py +101 -0
- package/scripts/tui/__init__.py +1 -0
- package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
- package/scripts/tui/app.py +1547 -0
- package/scripts/tui/helpers.py +65 -0
- package/scripts/tui/screens.py +642 -0
- package/scripts/tui/widgets.py +276 -0
- package/scripts/tui_app.py +25 -0
- package/scripts/wizard.py +109 -0
- package/tui.bat +50 -0
- package/tui.sh +59 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
9
|
+
|
|
10
|
+
from scripts.tailor_cv import double_review, _extract_yaml_from_response, _opencode_run
|
|
11
|
+
from scripts.tailor_prompts import REVIEW_PROMPT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
ap = argparse.ArgumentParser(description="Run AI double-review on a tailored CV")
|
|
16
|
+
ap.add_argument("yaml_path", help="Path to the tailored YAML file")
|
|
17
|
+
ap.add_argument("--jd", "-j", default="", help="Job description text file")
|
|
18
|
+
ap.add_argument("--jd-text", default="", help="Job description text directly")
|
|
19
|
+
ap.add_argument("--single", action="store_true", help="Single review instead of double")
|
|
20
|
+
ap.add_argument("--json", action="store_true", help="JSON output")
|
|
21
|
+
|
|
22
|
+
args = ap.parse_args()
|
|
23
|
+
|
|
24
|
+
yaml_path = Path(args.yaml_path)
|
|
25
|
+
if not yaml_path.exists():
|
|
26
|
+
_output({"success": False, "error": f"YAML not found: {yaml_path}"}, args.json)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
jd_text = ""
|
|
30
|
+
if args.jd:
|
|
31
|
+
jd_path = Path(args.jd)
|
|
32
|
+
if jd_path.exists():
|
|
33
|
+
jd_text = jd_path.read_text(encoding="utf-8")
|
|
34
|
+
if args.jd_text:
|
|
35
|
+
jd_text = args.jd_text
|
|
36
|
+
|
|
37
|
+
if not jd_text:
|
|
38
|
+
_output({"success": False, "error": "Job description required (--jd or --jd-text)"}, args.json)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
tailored_yaml = yaml_path.read_text(encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
if args.single:
|
|
44
|
+
from scripts.tailor_cv import ai_review
|
|
45
|
+
approved, review, corrections = ai_review(jd_text, tailored_yaml)
|
|
46
|
+
result = {
|
|
47
|
+
"success": True,
|
|
48
|
+
"approved": approved,
|
|
49
|
+
"single_review": True,
|
|
50
|
+
"review": review,
|
|
51
|
+
"corrections": corrections,
|
|
52
|
+
}
|
|
53
|
+
else:
|
|
54
|
+
approved, consolidated, corrections_text = double_review(jd_text, tailored_yaml)
|
|
55
|
+
result = {
|
|
56
|
+
"success": True,
|
|
57
|
+
"approved": approved,
|
|
58
|
+
"double_review": True,
|
|
59
|
+
"review": consolidated,
|
|
60
|
+
"corrections": corrections_text,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_output(result, args.json)
|
|
64
|
+
sys.exit(0 if result.get("success") else 1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _output(result: dict, as_json: bool):
|
|
68
|
+
if as_json:
|
|
69
|
+
print(json.dumps(result, indent=2, default=str))
|
|
70
|
+
else:
|
|
71
|
+
review = result.get("review", {})
|
|
72
|
+
if review:
|
|
73
|
+
score = review.get("overall_score", 0)
|
|
74
|
+
missing = review.get("missing_terms", [])
|
|
75
|
+
concerns = review.get("fabrication_concerns", [])
|
|
76
|
+
print(f"Score: {score}/100")
|
|
77
|
+
print(f"Missing terms: {len(missing)}")
|
|
78
|
+
if concerns:
|
|
79
|
+
print(f"Fabrication concerns: {len(concerns)}")
|
|
80
|
+
by_section = review.get("by_section", {})
|
|
81
|
+
if by_section:
|
|
82
|
+
print(f"By section: {json.dumps(by_section)}")
|
|
83
|
+
print(f"Approved: {result.get('approved', False)}")
|
|
84
|
+
else:
|
|
85
|
+
print(f"Error: {result.get('error', 'unknown')}", file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
package/scripts/setup.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared setup — called by every entry point (CLI, npm, wizard, Make, shell).
|
|
3
|
+
Creates venv, installs all deps + Playwright + Chromium, creates dirs + config.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
VENV = ROOT / ".venv"
|
|
13
|
+
BIN = VENV / ("Scripts" if sys.platform == "win32" else "bin")
|
|
14
|
+
PYTHON = BIN / ("python.exe" if sys.platform == "win32" else "python3")
|
|
15
|
+
PIP = BIN / ("pip.exe" if sys.platform == "win32" else "pip3")
|
|
16
|
+
|
|
17
|
+
CORE_DEPS = [
|
|
18
|
+
"textual", "pyyaml", "httpx", "reportlab",
|
|
19
|
+
"beautifulsoup4", "lxml", "markdownify",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
|
24
|
+
return subprocess.run(cmd, capture_output=True, text=True, **kw)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ensure_venv(log=print) -> bool:
|
|
28
|
+
"""Create venv if missing. Returns True on success."""
|
|
29
|
+
if PYTHON.exists():
|
|
30
|
+
return True
|
|
31
|
+
log(" Creating Python virtual environment...")
|
|
32
|
+
r = _run([sys.executable, "-m", "venv", str(VENV)])
|
|
33
|
+
if r.returncode != 0:
|
|
34
|
+
log(f" ✗ Failed to create venv: {r.stderr.strip()}")
|
|
35
|
+
return False
|
|
36
|
+
log(" ✓ Virtual environment created")
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def install_core(log=print) -> bool:
|
|
41
|
+
log(" Installing core dependencies...")
|
|
42
|
+
r = _run([str(PIP), "install", "--quiet", *CORE_DEPS])
|
|
43
|
+
if r.returncode == 0:
|
|
44
|
+
log(" ✓ Core dependencies installed")
|
|
45
|
+
return True
|
|
46
|
+
log(f" ✗ Core deps failed: {r.stderr.strip()}")
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def install_rendercv(log=print) -> bool:
|
|
51
|
+
log(" Installing RenderCV (PDF generation)...")
|
|
52
|
+
r = _run([str(PIP), "install", "--quiet", "rendercv[full]"], timeout=300)
|
|
53
|
+
if r.returncode == 0:
|
|
54
|
+
log(" ✓ RenderCV installed")
|
|
55
|
+
return True
|
|
56
|
+
log(f" ⚠ RenderCV install failed: {r.stderr.strip()}")
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def install_playwright(log=print) -> bool:
|
|
61
|
+
"""Install Playwright + Chromium + system deps. Required for headless browser automation."""
|
|
62
|
+
log(" Installing Playwright (browser automation)...")
|
|
63
|
+
r = _run([str(PIP), "install", "--quiet", "playwright"])
|
|
64
|
+
if r.returncode != 0:
|
|
65
|
+
log(f" ✗ Playwright pip install failed: {r.stderr.strip()}")
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
# Install Chromium with system dependencies (Linux only)
|
|
69
|
+
if sys.platform == "linux":
|
|
70
|
+
log(" Installing Chromium + system dependencies (~150MB)...")
|
|
71
|
+
r = _run([str(PYTHON), "-m", "playwright", "install", "--with-deps", "chromium"], timeout=300)
|
|
72
|
+
else:
|
|
73
|
+
log(" Installing Chromium browser (~150MB)...")
|
|
74
|
+
r = _run([str(PYTHON), "-m", "playwright", "install", "chromium"], timeout=300)
|
|
75
|
+
|
|
76
|
+
if r.returncode != 0:
|
|
77
|
+
log(f" ✗ Chromium install failed: {r.stderr.strip()}")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
log(" ✓ Playwright + Chromium installed")
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_dirs(log=print) -> None:
|
|
85
|
+
for d in [".chameleon", "output/job_analyses", "output/cover_letters", "output/scans"]:
|
|
86
|
+
(ROOT / d).mkdir(parents=True, exist_ok=True)
|
|
87
|
+
log(" ✓ Directories created")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_config(log=print) -> None:
|
|
91
|
+
cfg = ROOT / ".chameleon" / "config.json"
|
|
92
|
+
if cfg.exists():
|
|
93
|
+
log(" Config already exists")
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
sys.path.insert(0, str(ROOT))
|
|
97
|
+
from scripts.config import config
|
|
98
|
+
config.persist()
|
|
99
|
+
log(" ✓ Default config created")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
log(f" ⚠ Could not create config: {e}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_setup(log=print, skip_playwright: bool = False) -> bool:
|
|
105
|
+
"""Full setup. Returns True if all critical steps succeed."""
|
|
106
|
+
log("[Chameleon Setup]\n")
|
|
107
|
+
|
|
108
|
+
if not ensure_venv(log):
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
install_core(log)
|
|
112
|
+
install_rendercv(log)
|
|
113
|
+
|
|
114
|
+
if not skip_playwright:
|
|
115
|
+
install_playwright(log)
|
|
116
|
+
|
|
117
|
+
create_dirs(log)
|
|
118
|
+
create_config(log)
|
|
119
|
+
|
|
120
|
+
log("\n[*] Setup complete!")
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_fully_installed() -> bool:
|
|
125
|
+
"""Check if all critical components are installed."""
|
|
126
|
+
if not PYTHON.exists():
|
|
127
|
+
return False
|
|
128
|
+
try:
|
|
129
|
+
r = _run([str(PYTHON), "-c", "import textual, yaml, httpx, reportlab"])
|
|
130
|
+
if r.returncode != 0:
|
|
131
|
+
return False
|
|
132
|
+
r = _run([str(PYTHON), "-c", "from playwright.sync_api import sync_playwright"])
|
|
133
|
+
return r.returncode == 0
|
|
134
|
+
except Exception:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
run_setup()
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tailoring Brief Engine — deep algorithmic analysis of JD vs CV + repos.
|
|
3
|
+
Produces a comprehensive TailoringBrief with experience mapping,
|
|
4
|
+
repo analysis, gap detection, and highlight suggestions.
|
|
5
|
+
0 hardcoded — all driven from profile + CV + config.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from scripts.config import config
|
|
15
|
+
from scripts.job_matcher import (
|
|
16
|
+
load_profile, _build_dynamic_patterns, _build_cv_keywords,
|
|
17
|
+
extract_job_signals, find_matching_repos, _build_full_tech_profile,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_cv_yaml(path: Path | None = None) -> dict:
|
|
22
|
+
"""Load and parse the master CV YAML."""
|
|
23
|
+
path = path or config.master_cv_path()
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return {}
|
|
26
|
+
try:
|
|
27
|
+
import yaml
|
|
28
|
+
with open(path) as f:
|
|
29
|
+
return yaml.safe_load(f)
|
|
30
|
+
except Exception:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def analyze_seniority(jd_text: str) -> dict:
|
|
35
|
+
"""Deep seniority analysis from JD text."""
|
|
36
|
+
lower = jd_text.lower()
|
|
37
|
+
signals = {
|
|
38
|
+
"level": "mid",
|
|
39
|
+
"years_required": 0,
|
|
40
|
+
"leadership": False,
|
|
41
|
+
"mentorship": False,
|
|
42
|
+
"autonomy": False,
|
|
43
|
+
"confidence": 0.0,
|
|
44
|
+
}
|
|
45
|
+
# Extract years
|
|
46
|
+
years = re.findall(r"(\d+)\+?\s*(?:years|yrs)", lower)
|
|
47
|
+
if years:
|
|
48
|
+
signals["years_required"] = max(int(y) for y in years)
|
|
49
|
+
|
|
50
|
+
# Level indicators
|
|
51
|
+
if re.search(r"\bsenior\b|\bsr\.?\b|\bstaff\b|\blead\b|\bprincipal\b", lower):
|
|
52
|
+
signals["level"] = "senior"
|
|
53
|
+
signals["confidence"] += 0.4
|
|
54
|
+
if re.search(r"\bjunior\b|\bentry.?level\b|\bintern\b", lower):
|
|
55
|
+
signals["level"] = "junior"
|
|
56
|
+
signals["confidence"] += 0.3
|
|
57
|
+
if re.search(r"\bmid.?level\b|\bintermediate\b", lower):
|
|
58
|
+
signals["level"] = "mid"
|
|
59
|
+
signals["confidence"] += 0.2
|
|
60
|
+
if re.search(r"\blead\b|\bhead\b|\bmanager\b|\bdirector\b", lower):
|
|
61
|
+
signals["leadership"] = True
|
|
62
|
+
signals["confidence"] += 0.3
|
|
63
|
+
if re.search(r"\bmentor\b|\bcoach\b|\bguide\b", lower):
|
|
64
|
+
signals["mentorship"] = True
|
|
65
|
+
signals["confidence"] += 0.2
|
|
66
|
+
if re.search(r"\bself.?starter\b|\bindependent\b|\bautonomous\b|\bownership\b", lower):
|
|
67
|
+
signals["autonomy"] = True
|
|
68
|
+
signals["confidence"] += 0.2
|
|
69
|
+
if re.search(r"\b5\s*\+?\s*years?\b|\b7\s*\+?\s*years?\b|\b10\s*\+?\s*years?\b", lower):
|
|
70
|
+
signals["level"] = "senior"
|
|
71
|
+
signals["confidence"] = max(signals["confidence"], 0.6)
|
|
72
|
+
return signals
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extract_jd_sections(jd_text: str) -> dict:
|
|
76
|
+
"""Split JD into sections (about, requirements, responsibilities, etc.)."""
|
|
77
|
+
lower = jd_text.lower()
|
|
78
|
+
sections: dict[str, str] = {"full_text": jd_text}
|
|
79
|
+
# Common section headers in JDs
|
|
80
|
+
# Headers frequently have content on the same line (for example,
|
|
81
|
+
# ``Requirements: Python, Docker``), so do not require a second newline.
|
|
82
|
+
# Keep matches anchored to line starts to avoid classifying prose as a section.
|
|
83
|
+
patterns = [
|
|
84
|
+
(r"(?im)^[ \t]*(about the company|our company|the company)\s*:?[ \t]*", "company_about"),
|
|
85
|
+
(r"(?im)^[ \t]*(requirements|what you need|qualifications|skills)\s*:?[ \t]*", "requirements"),
|
|
86
|
+
(r"(?im)^[ \t]*(responsibilities|what you.?ll do|role|key duties)\s*:?[ \t]*", "responsibilities"),
|
|
87
|
+
(r"(?im)^[ \t]*(nice to have|bonus|preferred|plus)\s*:?[ \t]*", "bonus"),
|
|
88
|
+
(r"(?im)^[ \t]*(benefits|what we offer|perks)\s*:?[ \t]*", "benefits"),
|
|
89
|
+
(r"(?im)^[ \t]*(about|overview|who we are)\s*:?[ \t]*", "about"),
|
|
90
|
+
]
|
|
91
|
+
for pat, key in patterns:
|
|
92
|
+
m = re.search(pat, lower)
|
|
93
|
+
if m:
|
|
94
|
+
start = m.end()
|
|
95
|
+
# Find next section or end
|
|
96
|
+
next_pats = [p for p, k in patterns if k != key]
|
|
97
|
+
end = len(jd_text)
|
|
98
|
+
for np, _ in patterns:
|
|
99
|
+
nm = re.search(np, lower[start:])
|
|
100
|
+
if nm:
|
|
101
|
+
end = min(end, start + nm.start())
|
|
102
|
+
sections[key] = jd_text[m.start():end].strip()
|
|
103
|
+
return sections
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def analyze_experience_relevance(
|
|
107
|
+
cv: dict, signals: dict, jd_text: str
|
|
108
|
+
) -> list[dict]:
|
|
109
|
+
"""Score each experience entry against the JD."""
|
|
110
|
+
experiences = cv.get("cv", {}).get("sections", {}).get("experience", [])
|
|
111
|
+
results: list[dict] = []
|
|
112
|
+
lower = jd_text.lower()
|
|
113
|
+
|
|
114
|
+
for exp in experiences:
|
|
115
|
+
company = exp.get("company", "Unknown")
|
|
116
|
+
position = exp.get("position", "")
|
|
117
|
+
highlights = exp.get("highlights", [])
|
|
118
|
+
highlight_text = " ".join(h.lower() for h in highlights)
|
|
119
|
+
exp_text = f"{position.lower()} {highlight_text}"
|
|
120
|
+
|
|
121
|
+
# Score relevance
|
|
122
|
+
lang_matches = sum(1 for l in signals.get("languages", []) if l in exp_text)
|
|
123
|
+
fw_matches = sum(1 for f in signals.get("frameworks", []) if f in exp_text)
|
|
124
|
+
dom_matches = sum(1 for d in signals.get("domains", []) if d in exp_text)
|
|
125
|
+
total_keywords = lang_matches + fw_matches + dom_matches
|
|
126
|
+
|
|
127
|
+
# Find specific keywords from JD that appear in this experience
|
|
128
|
+
matching_keywords: list[str] = []
|
|
129
|
+
for kw in re.findall(r"\b[a-z][a-z0-9+#.-]{2,}\b", lower):
|
|
130
|
+
if kw in exp_text and len(kw) > 2:
|
|
131
|
+
matching_keywords.append(kw)
|
|
132
|
+
|
|
133
|
+
# Score 0-100
|
|
134
|
+
max_possible = max(len(signals.get("languages", [])) * 2, 1)
|
|
135
|
+
max_possible = max(max_possible, 1) # prevent ZeroDivisionError
|
|
136
|
+
relevance_score = min(int((total_keywords / max_possible) * 100), 100)
|
|
137
|
+
|
|
138
|
+
results.append({
|
|
139
|
+
"company": company,
|
|
140
|
+
"position": position,
|
|
141
|
+
"relevance_score": relevance_score,
|
|
142
|
+
"keyword_matches": matching_keywords[:10],
|
|
143
|
+
"highlight_count": len(highlights),
|
|
144
|
+
"suggested_order_priority": 0,
|
|
145
|
+
"highlights_analysis": [
|
|
146
|
+
{
|
|
147
|
+
"text": h,
|
|
148
|
+
"relevance": sum(1 for kw in matching_keywords if kw in h.lower()),
|
|
149
|
+
"jd_language_alignment": _compute_alignment(h, lower),
|
|
150
|
+
}
|
|
151
|
+
for h in highlights
|
|
152
|
+
],
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
# Rank by relevance
|
|
156
|
+
results.sort(key=lambda x: -x["relevance_score"])
|
|
157
|
+
for i, r in enumerate(results):
|
|
158
|
+
r["suggested_order_priority"] = i + 1
|
|
159
|
+
return results
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _compute_alignment(text: str, jd_lower: str) -> float:
|
|
163
|
+
"""Compute how well a highlight aligns with JD language (0.0-1.0)."""
|
|
164
|
+
words = set(re.findall(r"\b[a-z][a-z0-9+#.-]{2,}\b", text.lower()))
|
|
165
|
+
jd_words = set(re.findall(r"\b[a-z][a-z0-9+#.-]{2,}\b", jd_lower))
|
|
166
|
+
if not words:
|
|
167
|
+
return 0.0
|
|
168
|
+
overlap = words & jd_words
|
|
169
|
+
return len(overlap) / max(len(words), 1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def find_gaps(signals: dict, cv: dict) -> list[dict]:
|
|
173
|
+
"""Find gaps between JD requirements and CV content."""
|
|
174
|
+
cv_text = json.dumps(cv).lower() if cv else ""
|
|
175
|
+
jd_terms = set(
|
|
176
|
+
signals.get("languages", []) + signals.get("frameworks", [])
|
|
177
|
+
+ signals.get("domains", [])
|
|
178
|
+
)
|
|
179
|
+
gaps: list[dict] = []
|
|
180
|
+
for term in jd_terms:
|
|
181
|
+
if term not in cv_text:
|
|
182
|
+
gaps.append({
|
|
183
|
+
"term": term,
|
|
184
|
+
"category": (
|
|
185
|
+
"language" if term in signals.get("languages", [])
|
|
186
|
+
else "framework" if term in signals.get("frameworks", [])
|
|
187
|
+
else "domain"
|
|
188
|
+
),
|
|
189
|
+
"severity": "high" if term in signals.get("languages", []) else "medium",
|
|
190
|
+
"mitigation": "Use adjacent experience" if term in _get_adjacent_terms(cv, term) else "Cannot fabricate — omit",
|
|
191
|
+
})
|
|
192
|
+
return gaps
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _get_adjacent_terms(cv: dict, term: str) -> set[str]:
|
|
196
|
+
"""Check if a close-adjacent term exists in the CV."""
|
|
197
|
+
cv_text = json.dumps(cv).lower()
|
|
198
|
+
adj_map = {
|
|
199
|
+
"react": ["vue", "vue.js", "typescript", "javascript"],
|
|
200
|
+
"angular": ["vue", "vue.js", "typescript"],
|
|
201
|
+
"next.js": ["nuxt", "nuxt.js", "vue"],
|
|
202
|
+
"django": ["fastapi", "python"],
|
|
203
|
+
"flask": ["fastapi", "python"],
|
|
204
|
+
"express": ["fastapi", "python", "node.js"],
|
|
205
|
+
"svelte": ["vue", "javascript", "typescript"],
|
|
206
|
+
"pytorch": ["langchain", "crewai", "ai", "ml", "rag"],
|
|
207
|
+
"tensorflow": ["langchain", "crewai", "ai", "ml", "rag"],
|
|
208
|
+
"aws": ["azure", "docker", "kubernetes", "s3", "ec2"],
|
|
209
|
+
"gcp": ["azure", "docker", "kubernetes"],
|
|
210
|
+
"mysql": ["postgresql", "sqlite"],
|
|
211
|
+
"mongodb": ["postgresql", "sqlite", "redis"],
|
|
212
|
+
"graphql": ["rest", "rest api", "api"],
|
|
213
|
+
"kubernetes": ["docker", "docker-compose", "containerized"],
|
|
214
|
+
}
|
|
215
|
+
for adj in adj_map.get(term, []):
|
|
216
|
+
if adj in cv_text:
|
|
217
|
+
return {adj}
|
|
218
|
+
return set()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def analyze_repos_for_jd(profile: dict, signals: dict) -> list[dict]:
|
|
222
|
+
"""Deep repo analysis — find which repos map to which JD needs."""
|
|
223
|
+
results: list[dict] = []
|
|
224
|
+
job_langs = set(signals.get("languages", []))
|
|
225
|
+
job_fws = set(signals.get("frameworks", []))
|
|
226
|
+
job_domains = set(signals.get("domains", []))
|
|
227
|
+
|
|
228
|
+
for repo in profile.get("repos", []):
|
|
229
|
+
name = repo.get("name", "")
|
|
230
|
+
lang = (repo.get("language") or "").lower()
|
|
231
|
+
techs = set(t.lower() for t in repo.get("tech_stack_inferred", []))
|
|
232
|
+
domains = set(d.lower() for d in repo.get("domains", []))
|
|
233
|
+
desc = repo.get("description") or repo.get("summary", "") or ""
|
|
234
|
+
|
|
235
|
+
lang_overlap = (job_langs & techs) or (lang and lang in job_langs)
|
|
236
|
+
fw_overlap = bool(job_fws & techs)
|
|
237
|
+
dom_overlap = bool(job_domains & domains)
|
|
238
|
+
score = int(bool(lang_overlap)) + int(fw_overlap) + int(dom_overlap)
|
|
239
|
+
|
|
240
|
+
if score > 0:
|
|
241
|
+
matched_terms = list(
|
|
242
|
+
(job_langs & techs) | (job_fws & techs) | (job_domains & domains)
|
|
243
|
+
)
|
|
244
|
+
# Suggest CV highlight angle based on repo
|
|
245
|
+
suggestion = _suggest_repo_highlight(name, desc, matched_terms, signals)
|
|
246
|
+
results.append({
|
|
247
|
+
"repo_name": name,
|
|
248
|
+
"language": lang,
|
|
249
|
+
"match_score": score,
|
|
250
|
+
"matched_terms": matched_terms,
|
|
251
|
+
"description": desc,
|
|
252
|
+
"suggested_highlight_for_cv": suggestion,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
results.sort(key=lambda x: -x["match_score"])
|
|
256
|
+
return results[: config.get("max_matching_repos", 5)]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _suggest_repo_highlight(
|
|
260
|
+
name: str, desc: str, terms: list[str], signals: dict
|
|
261
|
+
) -> str:
|
|
262
|
+
"""Suggest a CV highlight angle based on repo match."""
|
|
263
|
+
domains = signals.get("domains", [])
|
|
264
|
+
lang_str = ", ".join(terms[:3]) if terms else "relevant technologies"
|
|
265
|
+
domain_hint = f" in {domains[0]}" if domains else ""
|
|
266
|
+
return f"Built {name} using {lang_str}{domain_hint} — {desc[:100] if desc else 'hands-on implementation'}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def generate_brief(
|
|
270
|
+
jd_text: str,
|
|
271
|
+
profile: dict,
|
|
272
|
+
cv: dict,
|
|
273
|
+
) -> dict:
|
|
274
|
+
"""Generate the complete TailoringBrief."""
|
|
275
|
+
|
|
276
|
+
signals = extract_job_signals(jd_text, profile)
|
|
277
|
+
seniority = analyze_seniority(jd_text)
|
|
278
|
+
jd_sections = extract_jd_sections(jd_text)
|
|
279
|
+
experience_analysis = analyze_experience_relevance(cv, signals, jd_text)
|
|
280
|
+
gaps = find_gaps(signals, cv)
|
|
281
|
+
matching_repos = find_matching_repos(signals, profile)
|
|
282
|
+
repo_analysis = analyze_repos_for_jd(profile, signals)
|
|
283
|
+
cv_keywords = _build_cv_keywords(config.master_cv_path())
|
|
284
|
+
|
|
285
|
+
# Compute alignment score (0-100)
|
|
286
|
+
# Part 1: what fraction of CV keywords appear in the JD (0-50)
|
|
287
|
+
kw_matches = sum(1 for kw in cv_keywords if kw in jd_text.lower())
|
|
288
|
+
kw_ratio = kw_matches / max(len(cv_keywords), 1)
|
|
289
|
+
# Part 2: how many distinct signal categories are represented (0-50)
|
|
290
|
+
lang_count = len(signals.get("languages", []))
|
|
291
|
+
fw_count = len(signals.get("frameworks", []))
|
|
292
|
+
dom_count = len(signals.get("domains", []))
|
|
293
|
+
signal_categories = sum(1 for c in (lang_count, fw_count, dom_count) if c > 0)
|
|
294
|
+
signal_score = (signal_categories / 3) * 50
|
|
295
|
+
alignment_score = min(int(kw_ratio * 50 + signal_score), 100)
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
"summary": {
|
|
299
|
+
"target_role": _extract_role(jd_text),
|
|
300
|
+
"target_company": _extract_company(jd_text),
|
|
301
|
+
"alignment_score": alignment_score,
|
|
302
|
+
"seniority": seniority,
|
|
303
|
+
"signal_count": {
|
|
304
|
+
"languages": signals.get("languages", []),
|
|
305
|
+
"frameworks": signals.get("frameworks", []),
|
|
306
|
+
"domains": signals.get("domains", []),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
"jd_sections": {k: v[:500] for k, v in jd_sections.items() if k != "full_text"},
|
|
310
|
+
"experience_analysis": experience_analysis,
|
|
311
|
+
"gaps": gaps,
|
|
312
|
+
"matching_repos": matching_repos,
|
|
313
|
+
"repo_analysis": repo_analysis,
|
|
314
|
+
"keywords": cv_keywords[:50],
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _extract_role(text: str) -> str:
|
|
319
|
+
lines = text.strip().split("\n")
|
|
320
|
+
return lines[0][:100] if lines else "Unknown Role"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _extract_company(text: str) -> str:
|
|
324
|
+
for line in text.split("\n")[:10]:
|
|
325
|
+
lower = line.lower().strip()
|
|
326
|
+
if lower.startswith("company:"):
|
|
327
|
+
return line.split(":", 1)[1].strip()
|
|
328
|
+
# Match " at CompanyName" only at start or after a space, not inside words
|
|
329
|
+
import re as _re
|
|
330
|
+
m = _re.search(r"(?:^|\s)at\s+([A-Z][^\n-]{2,40})", line)
|
|
331
|
+
if m:
|
|
332
|
+
return m.group(1).strip()
|
|
333
|
+
return "Unknown Company"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def render_brief_text(brief: dict) -> str:
|
|
337
|
+
"""Render the TailoringBrief as formatted text for the opencode prompt."""
|
|
338
|
+
lines: list[str] = []
|
|
339
|
+
s = brief["summary"]
|
|
340
|
+
lines.append("=" * 72)
|
|
341
|
+
lines.append(f"TAILORING BRIEF: {s['target_role']} @ {s['target_company']}")
|
|
342
|
+
lines.append(f"Alignment Score: {s['alignment_score']}/100")
|
|
343
|
+
lines.append(f"Seniority: {s['seniority']['level']} ({s['seniority']['years_required']}yrs req)")
|
|
344
|
+
lines.append(f"Signals — Languages: {', '.join(s['signal_count']['languages'][:8]) or 'none found'}")
|
|
345
|
+
lines.append(f"Signals — Frameworks: {', '.join(s['signal_count']['frameworks'][:8]) or 'none found'}")
|
|
346
|
+
lines.append(f"Signals — Domains: {', '.join(s['signal_count']['domains'][:6]) or 'none found'}")
|
|
347
|
+
lines.append("=" * 72)
|
|
348
|
+
|
|
349
|
+
# Experience analysis
|
|
350
|
+
lines.append("\n--- EXPERIENCE RELEVANCE MAPPING ---")
|
|
351
|
+
for exp in brief.get("experience_analysis", []):
|
|
352
|
+
lines.append(f"\n[{exp['suggested_order_priority']}] {exp['company']} — {exp['position']}")
|
|
353
|
+
lines.append(f" Relevance: {exp['relevance_score']}/100 | Priority in CV: {exp['suggested_order_priority']}")
|
|
354
|
+
lines.append(f" Keyword overlap: {', '.join(exp['keyword_matches'][:6]) or 'none'}")
|
|
355
|
+
for ha in exp.get("highlights_analysis", []):
|
|
356
|
+
alignment = "✓" if ha.get("jd_language_alignment", 0) > 0.3 else "△" if ha.get("jd_language_alignment", 0) > 0.1 else "✗"
|
|
357
|
+
lines.append(f" [{alignment}] {ha['text'][:80]}...")
|
|
358
|
+
|
|
359
|
+
# Matching repos
|
|
360
|
+
lines.append("\n--- MATCHING REPOS & CV HIGHLIGHT SUGGESTIONS ---")
|
|
361
|
+
for ra in brief.get("repo_analysis", []):
|
|
362
|
+
lines.append(f"\n [{ra['match_score']}] {ra['repo_name']} ({ra['language']})")
|
|
363
|
+
lines.append(f" Matches: {', '.join(ra['matched_terms'][:5])}")
|
|
364
|
+
lines.append(f" CV Angle: {ra['suggested_highlight_for_cv'][:120]}")
|
|
365
|
+
|
|
366
|
+
# Gaps
|
|
367
|
+
gaps = brief.get("gaps", [])
|
|
368
|
+
if gaps:
|
|
369
|
+
lines.append("\n--- GAPS (JD terms NOT in CV) ---")
|
|
370
|
+
for g in gaps:
|
|
371
|
+
lines.append(f" [{g['severity']}] {g['term']} ({g['category']}) — {g['mitigation']}")
|
|
372
|
+
else:
|
|
373
|
+
lines.append("\n--- GAPS: None detected ---")
|
|
374
|
+
|
|
375
|
+
lines.append("\n--- PRIORITY KEYWORDS FOR ATS ---")
|
|
376
|
+
for kw in brief.get("keywords", [])[:20]:
|
|
377
|
+
lines.append(f" • {kw}")
|
|
378
|
+
|
|
379
|
+
return "\n".join(lines)
|