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.
Files changed (151) hide show
  1. package/AGENTS.md +186 -0
  2. package/LICENSE +21 -0
  3. package/Makefile +136 -0
  4. package/README.md +356 -0
  5. package/bin/chameleon.js +136 -0
  6. package/chameleon.bat +286 -0
  7. package/chameleon.sh +353 -0
  8. package/package.json +39 -0
  9. package/profiles/github_repos.json +1266 -0
  10. package/requirements.txt +5 -0
  11. package/scan.sh +12 -0
  12. package/score.sh +12 -0
  13. package/scripts/__init__.py +1 -0
  14. package/scripts/__main_tui__.py +11 -0
  15. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
  18. package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
  19. package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
  20. package/scripts/__pycache__/config.cpython-312.pyc +0 -0
  21. package/scripts/__pycache__/config.cpython-313.pyc +0 -0
  22. package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
  23. package/scripts/__pycache__/db.cpython-312.pyc +0 -0
  24. package/scripts/__pycache__/db.cpython-313.pyc +0 -0
  25. package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
  26. package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
  27. package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
  28. package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
  29. package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
  30. package/scripts/__pycache__/question.cpython-313.pyc +0 -0
  31. package/scripts/__pycache__/render.cpython-313.pyc +0 -0
  32. package/scripts/__pycache__/review.cpython-313.pyc +0 -0
  33. package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
  34. package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
  35. package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
  36. package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
  37. package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
  38. package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
  39. package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
  40. package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
  41. package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
  42. package/scripts/ats_ghost.py +170 -0
  43. package/scripts/cache.py +63 -0
  44. package/scripts/cli.py +109 -0
  45. package/scripts/config.py +376 -0
  46. package/scripts/cover_letter.py +105 -0
  47. package/scripts/db.py +331 -0
  48. package/scripts/fonts.py +186 -0
  49. package/scripts/ghost.py +88 -0
  50. package/scripts/job_matcher.py +636 -0
  51. package/scripts/job_scanner/__init__.py +8 -0
  52. package/scripts/job_scanner/__main__.py +6 -0
  53. package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
  56. package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
  57. package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
  58. package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
  59. package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
  60. package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
  61. package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
  62. package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
  63. package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
  64. package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
  65. package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
  66. package/scripts/job_scanner/base.py +197 -0
  67. package/scripts/job_scanner/browser.py +244 -0
  68. package/scripts/job_scanner/cli.py +6 -0
  69. package/scripts/job_scanner/parsers/__init__.py +24 -0
  70. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
  71. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
  72. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
  73. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
  74. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
  75. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
  76. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
  77. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
  78. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
  79. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
  80. package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
  81. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
  82. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
  83. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
  84. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
  85. package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
  86. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
  87. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
  88. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
  89. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
  90. package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
  91. package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
  92. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
  93. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
  94. package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
  95. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
  96. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
  97. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
  98. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
  99. package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
  100. package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
  101. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
  102. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
  103. package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
  104. package/scripts/job_scanner/parsers/adzuna.py +81 -0
  105. package/scripts/job_scanner/parsers/arc.py +65 -0
  106. package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
  107. package/scripts/job_scanner/parsers/dailyremote.py +91 -0
  108. package/scripts/job_scanner/parsers/flexjobs.py +91 -0
  109. package/scripts/job_scanner/parsers/himalayas.py +47 -0
  110. package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
  111. package/scripts/job_scanner/parsers/indeed.py +98 -0
  112. package/scripts/job_scanner/parsers/jobicy.py +60 -0
  113. package/scripts/job_scanner/parsers/jooble.py +68 -0
  114. package/scripts/job_scanner/parsers/linkedin.py +140 -0
  115. package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
  116. package/scripts/job_scanner/parsers/relocate.py +95 -0
  117. package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
  118. package/scripts/job_scanner/parsers/remoteok.py +63 -0
  119. package/scripts/job_scanner/parsers/remotive.py +57 -0
  120. package/scripts/job_scanner/parsers/web3_career.py +92 -0
  121. package/scripts/job_scanner/parsers/wellfound.py +94 -0
  122. package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
  123. package/scripts/job_scanner/parsers/ycomb.py +83 -0
  124. package/scripts/job_scanner/registry.py +65 -0
  125. package/scripts/job_scanner/scanner.py +162 -0
  126. package/scripts/question.py +107 -0
  127. package/scripts/render.py +110 -0
  128. package/scripts/review.py +89 -0
  129. package/scripts/setup.py +139 -0
  130. package/scripts/tailor_brief.py +379 -0
  131. package/scripts/tailor_cv.py +746 -0
  132. package/scripts/tailor_prompts.py +101 -0
  133. package/scripts/tui/__init__.py +1 -0
  134. package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
  135. package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
  136. package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
  137. package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
  138. package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
  139. package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
  140. package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
  141. package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
  142. package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
  143. package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
  144. package/scripts/tui/app.py +1547 -0
  145. package/scripts/tui/helpers.py +65 -0
  146. package/scripts/tui/screens.py +642 -0
  147. package/scripts/tui/widgets.py +276 -0
  148. package/scripts/tui_app.py +25 -0
  149. package/scripts/wizard.py +109 -0
  150. package/tui.bat +50 -0
  151. 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()
@@ -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)