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,746 @@
1
+ """
2
+ Chameleon CV Tailor — three-pass pipeline:
3
+ 1. Algorithmic TailoringBrief (deep JD analysis + experience mapping + repo matching)
4
+ 2. One-shot AI tailor (opencode run → tailored YAML)
5
+ 3. AI review pass (opencode run → alignment audit + corrections)
6
+ 4. Render (rendercv → PDF)
7
+
8
+ 0 hardcoded — all driven from profile, CV, and config.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import re
15
+ import shutil
16
+ import argparse
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import yaml
25
+
26
+ from scripts.config import config
27
+ from scripts.tailor_brief import (
28
+ generate_brief,
29
+ load_cv_yaml,
30
+ render_brief_text,
31
+ )
32
+ from scripts.tailor_prompts import TAILOR_PROMPT, REVIEW_PROMPT, FIX_PROMPT
33
+ # ats_ghost is imported lazily in tailor_and_render (optional, requires pypdf)
34
+
35
+ # ── Global subprocess registry for kill-on-shutdown ────────────────────────
36
+ _running_processes: list[subprocess.Popen] = []
37
+ _processes_lock = threading.Lock()
38
+
39
+
40
+ def _track_process(proc: subprocess.Popen) -> None:
41
+ with _processes_lock:
42
+ _running_processes.append(proc)
43
+
44
+
45
+ def _untrack_process(proc: subprocess.Popen) -> None:
46
+ with _processes_lock:
47
+ try:
48
+ _running_processes.remove(proc)
49
+ except ValueError:
50
+ pass
51
+
52
+
53
+ def kill_all_processes() -> None:
54
+ """Kill every tracked subprocess. Safe to call from any thread."""
55
+ with _processes_lock:
56
+ for proc in _running_processes:
57
+ try:
58
+ proc.kill()
59
+ proc.wait(timeout=5)
60
+ except Exception:
61
+ pass
62
+ _running_processes.clear()
63
+
64
+
65
+ _running = False
66
+
67
+
68
+ def _now_ms() -> int:
69
+ return int(time.time() * 1000)
70
+
71
+
72
+ def _opencode_run(prompt: str, timeout_s: int = 120) -> tuple[bool, str]:
73
+ """Run opencode with a one-shot prompt. Returns (success, response_text).
74
+
75
+ The subprocess is registered in the global kill registry so it can be
76
+ aborted on crash/shutdown from any thread.
77
+ """
78
+ opencode_bin = config.opencode_bin()
79
+ if not opencode_bin.exists():
80
+ return False, f"opencode not found at {opencode_bin}"
81
+
82
+ env = {
83
+ **config.env(),
84
+ "OPENCODE_CLI_DISABLE_ANIMATION": "true",
85
+ }
86
+
87
+ # Always run opencode from the chameleon project root so it finds config
88
+ chameleon_root = Path(__file__).resolve().parent.parent
89
+
90
+ try:
91
+ proc = subprocess.Popen(
92
+ [str(opencode_bin), "run", prompt],
93
+ stdout=subprocess.PIPE,
94
+ stderr=subprocess.PIPE,
95
+ text=True,
96
+ cwd=str(chameleon_root),
97
+ env=env,
98
+ )
99
+ except FileNotFoundError:
100
+ return False, f"opencode binary not executable at {opencode_bin}"
101
+
102
+ _track_process(proc)
103
+ try:
104
+ out, err = proc.communicate(timeout=timeout_s)
105
+ except subprocess.TimeoutExpired:
106
+ proc.kill()
107
+ proc.wait(timeout=5)
108
+ return False, f"opencode timed out after {timeout_s}s"
109
+ except Exception as e:
110
+ proc.kill()
111
+ proc.wait(timeout=5)
112
+ return False, str(e)
113
+ finally:
114
+ _untrack_process(proc)
115
+
116
+ if proc.returncode != 0:
117
+ stderr = (err or "").strip()
118
+ return False, (stderr or f"opencode exited non-zero ({proc.returncode})")[:200]
119
+ out = (out or "").strip()
120
+ if not out:
121
+ return False, (err or "opencode produced no output").strip()[:200]
122
+ return True, out
123
+
124
+
125
+ def _extract_yaml_from_response(text: str) -> str:
126
+ """Extract YAML from a response that may contain markdown fences or file paths."""
127
+ # Case 1: the AI wrote a file and tells us the path
128
+ m = re.search(r"(?:written to|saved to|saved at|saved as|→|->)[\s:]*`?([^\s`]+\.yaml)`?", text, re.IGNORECASE)
129
+ if m:
130
+ path_candidate = m.group(1).strip().strip("'\"")
131
+ p = Path(path_candidate)
132
+ if p.exists():
133
+ return p.read_text()
134
+ p2 = Path.cwd() / path_candidate
135
+ if p2.exists():
136
+ return p2.read_text()
137
+
138
+ # Case 2: between ```yaml fences
139
+ m = re.search(r"```(?:yaml)?\s*\n(.+?)\n```", text, re.DOTALL)
140
+ if m:
141
+ return m.group(1).strip()
142
+
143
+ # Case 3: between plain ``` fences that happen to be YAML
144
+ m = re.search(r"```\s*\n(cv:.+?)```", text, re.DOTALL)
145
+ if m:
146
+ return m.group(1).strip()
147
+
148
+ # Case 4: content starting with cv: (multiline)
149
+ m = re.search(r"(?:^|\n)(cv:[\s\S]*)", text)
150
+ if m:
151
+ return m.group(1).strip()
152
+
153
+ return text.strip()
154
+
155
+
156
+ def _extract_json_from_response(text: str) -> dict | None:
157
+ """Extract JSON from a response that may have markdown fences."""
158
+ m = re.search(r"```(?:json)?\s*\n(.+?)\n```", text, re.DOTALL)
159
+ raw = m.group(1) if m else text.strip()
160
+ try:
161
+ return json.loads(raw)
162
+ except json.JSONDecodeError:
163
+ # Try to find {...} in the response
164
+ m2 = re.search(r"(\{.*\})", raw, re.DOTALL)
165
+ if m2:
166
+ try:
167
+ return json.loads(m2.group(1))
168
+ except json.JSONDecodeError:
169
+ pass
170
+ return None
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Pass 1: Algorithmic Tailoring Brief
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def build_tailoring_brief(
179
+ jd_text: str, profile: dict | None = None
180
+ ) -> dict:
181
+ """Build a comprehensive TailoringBrief from JD + profile."""
182
+ cv = load_cv_yaml(config.master_cv_path())
183
+ if profile is None:
184
+ from scripts.job_matcher import load_profile
185
+ profile = load_profile()
186
+ brief = generate_brief(jd_text, profile, cv)
187
+ return brief
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Pass 2: One-shot AI Tailor via opencode
192
+ # ---------------------------------------------------------------------------
193
+
194
+ _TAILOR_RETRIES = 2
195
+
196
+
197
+ def ai_tailor(jd_text: str, brief_text: str, cv_yaml: str) -> tuple[bool, str]:
198
+ """Run one-shot opencode tailor. Returns (success, tailored_yaml)."""
199
+ prompt = TAILOR_PROMPT.format(cv_yaml=cv_yaml, brief_text=brief_text)
200
+ for attempt in range(1 + _TAILOR_RETRIES):
201
+ ok, response = _opencode_run(prompt, timeout_s=180)
202
+ if not ok:
203
+ if attempt < _TAILOR_RETRIES:
204
+ continue
205
+ return False, f"opencode call failed: {response}"
206
+
207
+ yaml_text = _extract_yaml_from_response(response)
208
+ if not yaml_text.startswith("cv:"):
209
+ if attempt < _TAILOR_RETRIES:
210
+ continue
211
+ return False, f"Response does not start with 'cv:':\n{yaml_text[:200]}"
212
+
213
+ # Validate it parses as YAML
214
+ try:
215
+ parsed = yaml.safe_load(yaml_text)
216
+ if not parsed or "cv" not in parsed:
217
+ if attempt < _TAILOR_RETRIES:
218
+ continue
219
+ return False, "Parsed YAML missing 'cv' top-level key"
220
+ except Exception as e:
221
+ if attempt < _TAILOR_RETRIES:
222
+ continue
223
+ return False, f"YAML parse failed: {e}"
224
+
225
+ return True, yaml_text
226
+
227
+ return False, "Max retries exceeded"
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Pass 3: AI Review Pass
232
+ # ---------------------------------------------------------------------------
233
+
234
+ _REVIEW_RETRIES = 2
235
+ _REVIEW_MIN_SCORE = 70
236
+
237
+
238
+ def ai_review(jd_text: str, tailored_yaml: str) -> tuple[bool, dict | None, str | None]:
239
+ """Run one-shot opencode review. Returns (approved, review_json, corrections_text)."""
240
+ prompt = REVIEW_PROMPT.format(jd_text=jd_text, tailored_yaml=tailored_yaml)
241
+ for attempt in range(1 + _REVIEW_RETRIES):
242
+ ok, response = _opencode_run(prompt, timeout_s=120)
243
+ if not ok:
244
+ if attempt < _REVIEW_RETRIES:
245
+ continue
246
+ return False, None, f"Review call failed: {response}"
247
+
248
+ review = _extract_json_from_response(response)
249
+ if not review:
250
+ if attempt < _REVIEW_RETRIES:
251
+ continue
252
+ return False, None, f"Could not parse review JSON:\n{response[:300]}"
253
+
254
+ score = review.get("overall_score", 0)
255
+ approved = score >= _REVIEW_MIN_SCORE and not review.get("fabrication_concerns")
256
+ review["approved"] = approved
257
+
258
+ corrections = review.get("corrections_needed", [])
259
+ corrections_text = json.dumps(corrections, indent=2) if corrections else None
260
+
261
+ return approved, review, corrections_text
262
+
263
+ return False, None, "Max review retries exceeded"
264
+
265
+
266
+ def double_review(jd_text: str, tailored_yaml: str) -> tuple[bool, dict, str | None]:
267
+ """Run two independent reviews + optional tiebreaker.
268
+
269
+ Returns (approved, consolidated_review, corrections_text).
270
+ The consolidated review takes the lower score and the union of corrections.
271
+ """
272
+ # Run two reviews
273
+ r1_ok, r1, c1 = ai_review(jd_text, tailored_yaml)
274
+ r2_ok, r2, c2 = ai_review(jd_text, tailored_yaml)
275
+
276
+ reviews = []
277
+ if r1_ok and r1:
278
+ reviews.append(r1)
279
+ if r2_ok and r2:
280
+ reviews.append(r2)
281
+
282
+ if not reviews:
283
+ return False, {"overall_score": 0, "approved": False}, None
284
+
285
+ # Consolidate — take the lower/more conservative score
286
+ scores = [r.get("overall_score", 0) for r in reviews]
287
+ final_score = min(scores)
288
+ tiebreaker_needed = max(scores) - min(scores) > 15
289
+
290
+ # If scores diverge by >15, run tiebreaker
291
+ if tiebreaker_needed and len(reviews) == 2:
292
+ _, r3, c3 = ai_review(jd_text, tailored_yaml)
293
+ if r3:
294
+ reviews.append(r3)
295
+ scores.append(r3.get("overall_score", 0))
296
+ final_score = sorted(scores)[len(scores) // 2] # median
297
+
298
+ # Merge corrections (union)
299
+ all_corrections: list[dict] = []
300
+ seen_corrections: set[str] = set()
301
+ for r in reviews:
302
+ for corr in r.get("corrections_needed", []):
303
+ key = json.dumps(corr, sort_keys=True)
304
+ if key not in seen_corrections:
305
+ seen_corrections.add(key)
306
+ all_corrections.append(corr)
307
+
308
+ # Merge missing terms (union)
309
+ all_missing: list[str] = []
310
+ seen_missing: set[str] = set()
311
+ for r in reviews:
312
+ for term in r.get("missing_terms", []):
313
+ if term.lower() not in seen_missing:
314
+ seen_missing.add(term.lower())
315
+ all_missing.append(term)
316
+
317
+ # Merge gaps (union)
318
+ all_gaps: list[dict] = []
319
+ seen_gaps: set[str] = set()
320
+ for r in reviews:
321
+ for gap in r.get("alignment_gaps", []):
322
+ key = json.dumps(gap, sort_keys=True)
323
+ if key not in seen_gaps:
324
+ seen_gaps.add(key)
325
+ all_gaps.append(gap)
326
+
327
+ # Take the most conservative fabrication concerns
328
+ all_concerns: list[str] = []
329
+ seen_concerns: set[str] = set()
330
+ for r in reviews:
331
+ for c in r.get("fabrication_concerns", []):
332
+ if c.lower() not in seen_concerns:
333
+ seen_concerns.add(c.lower())
334
+ all_concerns.append(c)
335
+
336
+ approved = final_score >= _REVIEW_MIN_SCORE and not all_concerns
337
+
338
+ # Per-section scores: take the lower score for each section
339
+ by_section = {}
340
+ section_keys = ["summary", "skills", "experience", "projects"]
341
+ for sk in section_keys:
342
+ sec_scores = [r.get("by_section", {}).get(sk, 0) for r in reviews if r.get("by_section")]
343
+ by_section[sk] = min(sec_scores) if sec_scores else 0
344
+
345
+ consolidated = {
346
+ "overall_score": final_score,
347
+ "by_section": by_section,
348
+ "missing_terms": all_missing,
349
+ "present_terms": reviews[0].get("present_terms", []) if reviews else [],
350
+ "alignment_gaps": all_gaps,
351
+ "fabrication_concerns": all_concerns,
352
+ "corrections_needed": all_corrections,
353
+ "approved": approved,
354
+ "_scores": scores,
355
+ "_tiebreaker": tiebreaker_needed,
356
+ }
357
+
358
+ corrections_text = json.dumps(all_corrections, indent=2) if all_corrections else None
359
+ return approved, consolidated, corrections_text
360
+
361
+
362
+ def apply_corrections(tailored_yaml: str, corrections_text: str) -> str:
363
+ """Apply corrections via opencode fix pass."""
364
+ prompt = FIX_PROMPT.format(
365
+ corrections_json=corrections_text,
366
+ tailored_yaml=tailored_yaml,
367
+ )
368
+ ok, response = _opencode_run(prompt, timeout_s=120)
369
+ if not ok:
370
+ return tailored_yaml
371
+ fixed = _extract_yaml_from_response(response)
372
+ if fixed.startswith("cv:"):
373
+ return fixed
374
+ return tailored_yaml
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Pass 4: Render
379
+ # ---------------------------------------------------------------------------
380
+
381
+
382
+ def _find_rendercv() -> Path | None:
383
+ """Locate the rendercv binary, checking venv, PATH, and common locations."""
384
+ import shutil as _shutil
385
+ chameleon_root = Path(__file__).resolve().parent.parent
386
+ # Check venv first (platform-aware)
387
+ venv_bin = chameleon_root / ".venv" / ("Scripts" if sys.platform == "win32" else "bin")
388
+ ext = ".exe" if sys.platform == "win32" else ""
389
+ rendercv_path = venv_bin / f"rendercv{ext}"
390
+ if rendercv_path.exists():
391
+ return rendercv_path
392
+ # Check if on PATH
393
+ on_path = _shutil.which("rendercv")
394
+ if on_path:
395
+ return Path(on_path)
396
+ return None
397
+
398
+
399
+ def render_yaml(yaml_path: Path) -> tuple[bool, str]:
400
+ """Render YAML to PDF using rendercv. Returns (success, message).
401
+
402
+ The subprocess is registered in the global kill registry so it can be
403
+ aborted on crash/shutdown from any thread.
404
+ """
405
+ chameleon_root = Path(__file__).resolve().parent.parent
406
+ rendercv_path = _find_rendercv()
407
+ output_dir = config.output_dir()
408
+ output_dir.mkdir(exist_ok=True)
409
+
410
+ if rendercv_path is None:
411
+ return False, "rendercv not found. Run: make install-tools"
412
+
413
+ stem = yaml_path.stem
414
+ tmp = Path(yaml_path.name)
415
+ shutil.copy2(yaml_path, tmp)
416
+
417
+ try:
418
+ proc = subprocess.Popen(
419
+ [
420
+ str(rendercv_path), "render", str(tmp),
421
+ "--pdf-path", str(output_dir / f"{stem}.pdf"),
422
+ "--typst-path", str(output_dir / f"{stem}.typ"),
423
+ "--markdown-path", str(output_dir / f"{stem}.md"),
424
+ "--html-path", str(output_dir / f"{stem}.html"),
425
+ ],
426
+ stdout=subprocess.PIPE,
427
+ stderr=subprocess.PIPE,
428
+ text=True,
429
+ cwd=str(chameleon_root),
430
+ )
431
+ except FileNotFoundError:
432
+ return False, f"rendercv binary not executable at {rendercv_path}"
433
+
434
+ _track_process(proc)
435
+ try:
436
+ out, err = proc.communicate(timeout=60)
437
+ except subprocess.TimeoutExpired:
438
+ proc.kill()
439
+ proc.wait(timeout=5)
440
+ return False, "Render timeout (60s)"
441
+ except Exception as e:
442
+ proc.kill()
443
+ proc.wait(timeout=5)
444
+ return False, str(e)
445
+ finally:
446
+ _untrack_process(proc)
447
+ tmp.unlink(missing_ok=True)
448
+
449
+ if proc.returncode == 0:
450
+ pdf_path = output_dir / f"{stem}.pdf"
451
+ return True, str(pdf_path) if pdf_path.exists() else "Rendered (no PDF)"
452
+ else:
453
+ err = (err or out or "unknown error").strip()[:300]
454
+ return False, err
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # Orchestration
459
+ # ---------------------------------------------------------------------------
460
+
461
+
462
+ def tailor_and_render(
463
+ jd_text: str,
464
+ company: str = "",
465
+ title: str = "",
466
+ profile: dict | None = None,
467
+ skip_review: bool = False,
468
+ skip_render: bool = False,
469
+ ) -> tuple[bool, str, str]:
470
+ """
471
+ Full three-pass pipeline: Brief → AI Tailor → AI Review → Render.
472
+ Returns (success, message, output_path).
473
+ """
474
+ global _running
475
+ _running = True
476
+ start_ms = _now_ms()
477
+
478
+ master_cv_path = config.master_cv_path()
479
+ if not master_cv_path.exists():
480
+ _running = False
481
+ return False, f"Master CV not found: {master_cv_path}", ""
482
+
483
+ # Derive company/title from JD text if not provided
484
+ if not company or not title:
485
+ lines = jd_text.strip().split("\n")
486
+ first_line = lines[0].strip() if lines else "Unknown Role"
487
+ if not title:
488
+ title = first_line[:80]
489
+ # Try to find company in first few lines
490
+ for line in lines[1:8]:
491
+ m = re.search(r"(?:at|@|Company[:\s]*)(.+?)(?:\s*[-–—]|\s*$)", line)
492
+ if m:
493
+ company = m.group(1).strip()
494
+ break
495
+ if not company:
496
+ company = "Unknown"
497
+
498
+ safe_co = re.sub(r"[^a-zA-Z0-9]", "_", company)[:20]
499
+ safe_role = re.sub(r"[^a-zA-Z0-9]", "_", title)[:20]
500
+ raw = f"{company}{title}".encode()
501
+ try:
502
+ analysis_id = hashlib.md5(raw, usedforsecurity=False).hexdigest()[:8]
503
+ except TypeError:
504
+ analysis_id = hashlib.md5(raw).hexdigest()[:8]
505
+ cv_basename = master_cv_path.stem.replace("_cv", "") or "cv"
506
+ yaml_name = f"{cv_basename}_{safe_co}_{safe_role}_cv.yaml"
507
+ yaml_path = config.templates_dir() / yaml_name
508
+
509
+ # ======================================================================
510
+ # PASS 1: Algorithmic Tailoring Brief
511
+ # ======================================================================
512
+ print(f"[1/4] Building TailoringBrief for '{title}' @ '{company}'...", flush=True)
513
+ brief = build_tailoring_brief(jd_text, profile)
514
+ brief_text = render_brief_text(brief)
515
+ print(f" Alignment: {brief['summary']['alignment_score']}/100 | "
516
+ f"Gaps: {len(brief['gaps'])} | "
517
+ f"Matching repos: {len(brief.get('repo_analysis', []))}", flush=True)
518
+
519
+ # Save analysis artifact
520
+ analysis_dir = config.analyses_dir()
521
+ analysis_dir.mkdir(parents=True, exist_ok=True)
522
+ analysis_name = f"{analysis_id}__{safe_co}__{safe_role}.json"
523
+ analysis_path = analysis_dir / analysis_name
524
+ analysis_artifact = {
525
+ "company_name": company,
526
+ "role_title": title,
527
+ "analysis_id": analysis_id,
528
+ "alignment_score": brief["summary"]["alignment_score"],
529
+ "seniority": brief["summary"]["seniority"],
530
+ "signal_count": {
531
+ k: len(v) for k, v in brief["summary"]["signal_count"].items()
532
+ },
533
+ "gaps": brief["gaps"],
534
+ "brief_summary": brief_text[:500],
535
+ }
536
+ try:
537
+ analysis_path.write_text(json.dumps(analysis_artifact, indent=2))
538
+ except Exception:
539
+ pass
540
+
541
+ # ======================================================================
542
+ # PASS 2: One-shot AI Tailor
543
+ # ======================================================================
544
+ print(f"[2/4] Running one-shot AI tailor (opencode)...", flush=True)
545
+ cv_yaml = master_cv_path.read_text()
546
+ ok, tailored_yaml = ai_tailor(jd_text, brief_text, cv_yaml)
547
+ if not ok:
548
+ _running = False
549
+ return False, f"AI tailoring failed: {tailored_yaml}", str(analysis_path)
550
+
551
+ print(f" YAML generated ({len(tailored_yaml)} chars)", flush=True)
552
+
553
+ # ======================================================================
554
+ # PASS 3: AI Review
555
+ # ======================================================================
556
+ review_result = None
557
+ if not skip_review:
558
+ print(f"[3/4] Running double AI review (opencode x2, tiebreaker if needed)...", flush=True)
559
+ approved, review_result, corrections_text = double_review(jd_text, tailored_yaml)
560
+
561
+ if review_result:
562
+ score = review_result.get("overall_score", 0)
563
+ concerns = review_result.get("fabrication_concerns", [])
564
+ gaps_found = review_result.get("alignment_gaps", [])
565
+ missing = review_result.get("missing_terms", [])
566
+ raw_scores = review_result.get("_scores", [score])
567
+ print(f" Scores: {' + '.join(str(s) for s in raw_scores)} -> final={score}/100", flush=True)
568
+ print(f" Missing: {len(missing)} | Gaps: {len(gaps_found)} | Concerns: {len(concerns)}", flush=True)
569
+
570
+ if corrections_text:
571
+ n_corrections = len(json.loads(corrections_text))
572
+ print(f" Applying {n_corrections} corrections (union of both reviews)...", flush=True)
573
+ tailored_yaml = apply_corrections(tailored_yaml, corrections_text)
574
+ print(f" Corrections applied", flush=True)
575
+ elif review_result and not approved and not corrections_text:
576
+ print(f" Review not approved (score {review_result.get('overall_score', 0)} < {_REVIEW_MIN_SCORE}) but no corrections provided — proceeding with generated YAML", flush=True)
577
+ else:
578
+ print(f"[3/4] Review skipped", flush=True)
579
+
580
+ # ======================================================================
581
+ # Write tailored YAML
582
+ # ======================================================================
583
+ yaml_path.write_text(tailored_yaml)
584
+ print(f" Saved: {yaml_path}", flush=True)
585
+
586
+ # ======================================================================
587
+ # PASS 4: Render
588
+ # ======================================================================
589
+ output_path = str(yaml_path)
590
+ ghost_injected = False
591
+ if not skip_render:
592
+ print(f"[4/4] Rendering PDF...", flush=True)
593
+ render_ok, render_msg = render_yaml(yaml_path)
594
+ if render_ok:
595
+ output_path = render_msg
596
+ print(f" PDF: {render_msg}", flush=True)
597
+
598
+ # PASS 5: ATS Ghost Text Injection (if enabled)
599
+ if config.get("ats_ghost_enabled", False):
600
+ pdf_path = Path(render_msg)
601
+ if pdf_path.exists():
602
+ from scripts.ats_ghost import collect_ats_terms as _collect, inject_ghost_text as _inject
603
+ extra = config.get("ats_ghost_extra_terms", "")
604
+ # Collect keywords from signals + gaps + extra terms
605
+ signal_terms = (
606
+ brief["summary"]["signal_count"]["languages"]
607
+ + brief["summary"]["signal_count"]["frameworks"]
608
+ )
609
+ gap_terms = [g["term"] for g in brief.get("gaps", [])]
610
+ existing_kws = list(
611
+ set(
612
+ signal_terms + gap_terms
613
+ + [kw for exp in brief.get("experience_analysis", [])
614
+ for kw in exp.get("keyword_matches", [])]
615
+ )
616
+ )
617
+ all_terms = _collect(
618
+ jd_text,
619
+ extra_terms=extra,
620
+ existing_keywords=existing_kws,
621
+ )
622
+ if all_terms:
623
+ ok_ghost = _inject(pdf_path, all_terms)
624
+ ghost_injected = ok_ghost
625
+ if ok_ghost:
626
+ print(f" ATS ghost text injected ({len(all_terms)} terms)", flush=True)
627
+ else:
628
+ print(f" ATS ghost text injection failed", flush=True)
629
+ else:
630
+ print(f" Render issue: {render_msg}", flush=True)
631
+
632
+ elapsed = _now_ms() - start_ms
633
+ _running = False
634
+
635
+ summary = (
636
+ f"Tailored in {elapsed/1000:.1f}s\n"
637
+ f" Analysis: {analysis_path}\n"
638
+ f" YAML: {yaml_path}\n"
639
+ f" PDF: {output_path if not skip_render else 'skipped'}\n"
640
+ f" Alignment: {brief['summary']['alignment_score']}/100"
641
+ )
642
+ if ghost_injected:
643
+ summary += " | ATS ghost: ✓"
644
+ if review_result:
645
+ raw_scores = review_result.get("_scores", [])
646
+ if raw_scores:
647
+ summary += f" | Reviews: {', '.join(str(s) for s in raw_scores)}"
648
+ summary += f" | Final: {review_result.get('overall_score', '?')}/100"
649
+ return True, summary, output_path
650
+
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # CLI
654
+ # ---------------------------------------------------------------------------
655
+
656
+
657
+ def main():
658
+ parser = argparse.ArgumentParser(
659
+ description="Tailor a CV from job-description text, a file, or a job-posting URL."
660
+ )
661
+ parser.add_argument("jd", nargs="?", help="Job description text, path, or URL")
662
+ parser.add_argument("--company", default="", help="Company name override")
663
+ parser.add_argument("--title", default="", help="Role title override")
664
+ parser.add_argument("--no-review", action="store_true", help="Skip AI review")
665
+ parser.add_argument("--no-render", action="store_true", help="Skip PDF rendering")
666
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable output")
667
+ parsed = parser.parse_args()
668
+
669
+ jd_arg = parsed.jd
670
+ if not jd_arg and not sys.stdin.isatty():
671
+ jd_arg = sys.stdin.read().strip()
672
+ if not jd_arg:
673
+ parser.error("a job description text, file, or URL is required")
674
+
675
+ # Handle URL input
676
+ if jd_arg.startswith("http://") or jd_arg.startswith("https://"):
677
+ print("[*] Crawling URL...", file=sys.stderr)
678
+ try:
679
+ import httpx
680
+ resp = httpx.get(jd_arg, timeout=30, follow_redirects=True, headers={
681
+ "User-Agent": "Mozilla/5.0 (compatible; Chameleon/1.0)"
682
+ })
683
+ resp.raise_for_status()
684
+ from html.parser import HTMLParser
685
+ class _TE(HTMLParser):
686
+ def __init__(self):
687
+ super().__init__()
688
+ self.parts = []
689
+ self._skip = False
690
+ def handle_starttag(self, tag, attrs):
691
+ if tag in ("script", "style"): self._skip = True
692
+ def handle_endtag(self, tag):
693
+ if tag in ("script", "style"): self._skip = False
694
+ def handle_data(self, data):
695
+ if not self._skip: self.parts.append(data)
696
+ te = _TE()
697
+ te.feed(resp.text)
698
+ import re as _re
699
+ desc = _re.sub(r"\s+", " ", " ".join(te.parts)).strip()
700
+ except ImportError:
701
+ import urllib.request
702
+ import re as _re
703
+ import html as _hm
704
+ req = urllib.request.Request(jd_arg, headers={"User-Agent": "Mozilla/5.0"})
705
+ with urllib.request.urlopen(req, timeout=30) as resp:
706
+ raw = resp.read().decode("utf-8", errors="replace")
707
+ raw = _re.sub(r"<script[^>]*>.*?</script>", "", raw, flags=_re.DOTALL | _re.IGNORECASE)
708
+ raw = _re.sub(r"<style[^>]*>.*?</style>", "", raw, flags=_re.DOTALL | _re.IGNORECASE)
709
+ raw = _re.sub(r"<[^>]+>", " ", raw)
710
+ raw = _hm.unescape(raw)
711
+ desc = _re.sub(r"\s+", " ", raw).strip()[:10000]
712
+ except Exception as e:
713
+ print(f"Error crawling URL: {e}", file=sys.stderr)
714
+ sys.exit(1)
715
+ elif Path(jd_arg).is_file():
716
+ desc = Path(jd_arg).read_text()
717
+ else:
718
+ desc = jd_arg
719
+
720
+ ok, msg, output_path = tailor_and_render(
721
+ desc,
722
+ company=parsed.company,
723
+ title=parsed.title,
724
+ skip_review=parsed.no_review,
725
+ skip_render=parsed.no_render,
726
+ )
727
+
728
+ if not parsed.json:
729
+ # Keep CLI output portable on Windows consoles configured for cp1252.
730
+ print(f"\n{'PASS' if ok else 'FAIL'}: {msg}")
731
+ sys.exit(0 if ok else 1)
732
+
733
+ if parsed.json:
734
+ import json as _json
735
+ print(_json.dumps({
736
+ "success": ok,
737
+ "message": msg,
738
+ "output_path": output_path,
739
+ }, indent=2))
740
+ else:
741
+ print(f"\n{'✓' if ok else '✗'} {'PASS' if ok else 'FAIL'}: {msg}")
742
+ sys.exit(0 if ok else 1)
743
+
744
+
745
+ if __name__ == "__main__":
746
+ main()