#!/usr/bin/env python3 """Regenerate a cover letter using an LLM, incorporating user feedback. Called by the dashboard server when the user clicks "Regenerate". Args: job_id: Notion page ID of the job feedback: User's instructions for the revision Outputs: Prints JSON with {"url": "...", "file": "..."} on success Prints JSON with {"error": "..."} on failure """ import json, os, sys, re, subprocess from datetime import datetime HERMES_DIR = os.path.expanduser("~/.hermes") DRAFTS_DIR = os.path.join(HERMES_DIR, "radar-ref/drafts") RESUMES_DIR = os.path.join(HERMES_DIR, "radar-ref/resumes") COVER_LETTER_DIR = os.path.join(HERMES_DIR, "radar-ref/cover-letters") CACHE_FILE = os.path.join(HERMES_DIR, "dashboard/jobs_cache.json") # ── Load context ─────────────────────────────────────────────────────────── def load_job(job_id): """Look up a job by its Notion page ID in the cache.""" with open(CACHE_FILE) as f: cache = json.load(f) for job in cache.get("jobs", []): if job["id"] == job_id: return job return None def read_file_first_line_or_none(path): if os.path.isfile(path): with open(path) as f: return f.read().strip() return None def find_first_file(dir_path): """Return first .pdf or .txt in a directory, or None.""" if not os.path.isdir(dir_path): return None for f in sorted(os.listdir(dir_path)): if f.endswith((".pdf", ".txt", ".md")): return os.path.join(dir_path, f) return None def slugify(text): """Turn company + role into a safe filename.""" s = text.lower().strip() s = re.sub(r'[^\w\s-]', '', s) s = re.sub(r'[-\s]+', '-', s) return s[:80] # ── LLM call ─────────────────────────────────────────────────────────────── def call_llm(system_prompt, user_prompt): """Call the OmniRoute cover-letter-writer model combo. Returns response text.""" payload = { "model": "cover-letter-writer", "stream": False, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], "temperature": 0.7, "max_tokens": 2000 } cmd = [ "curl", "-s", "http://omniroute:20128/v1/chat/completions", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer sk-1234", "-d", json.dumps(payload) ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) try: data = json.loads(result.stdout) return data["choices"][0]["message"]["content"] except (KeyError, json.JSONDecodeError) as e: error_text = result.stdout[:500] if result.stdout.strip() else "Empty response" return f"[Error: {e} — {error_text}]" def parse_cover_letter(text, company="", role="", location=""): """Parse cover letter text into structured parts for HTML template.""" lines = text.strip().split('\n') # Find subject line subject = "" first_idx = 0 for i, line in enumerate(lines): s = line.strip() if s.startswith("Subject:") or (s.startswith("**Subject:") and s.endswith("**")): subject = s.replace("**","").replace("Subject:","").replace("**","").strip() first_idx = i + 1 break # Find opening paragraph (between salutation and bullets) opening = "" bullets = [] closing = "" # Skip everything before "Dear Hiring Manager," — contact block + date belong # in the template header, not in the parsed opening paragraph. salutation_idx = None for i, line in enumerate(lines[first_idx:], start=first_idx): if line.strip().lower().startswith("dear ") and "hiring manager" in line.lower(): salutation_idx = i break parse_start = salutation_idx + 1 if salutation_idx is not None else first_idx in_bullets = False bridging = False for line in lines[parse_start:]: stripped = line.strip() if not stripped: continue # Detect bullet markers is_bullet = ( stripped.startswith('-') or stripped.startswith('•') or (len(stripped) > 2 and stripped[0].isdigit() and stripped[1] in '.):') ) # Detect salutation if stripped.startswith("Dear ") and "Dear Hiring Manager" in stripped: in_bullets = False bridging = False continue if is_bullet: in_bullets = True bridging = False # Clean bullet marker bullet_text = stripped.lstrip('-• ').strip() # Remove markdown bold markers per-line so the plain letter reads cleanly bullet_text = bullet_text.replace('**', '') # Remove leading numbering like "1. ", "2. " if len(bullet_text) > 2 and bullet_text[0].isdigit() and bullet_text[1] == '.': bullet_text = bullet_text[2:].strip() bullets.append(bullet_text) elif in_bullets and not is_bullet: in_bullets = False bridging = False if not stripped.startswith("Dear ") and not stripped.startswith("Dear Hiring"): closing += stripped + " " elif not in_bullets and not stripped.startswith("Dear ") and not stripped.startswith("Dear Hiring"): # Already past bullets — rest belongs to closing, not opening if closing: if "regards" in stripped.lower() or stripped.startswith("Sincerely") or stripped.startswith("Kind regards"): continue if stripped.lower() in ["sincerely,", "kind regards,", "regards,"] or stripped.startswith("Anthony Martin"): continue closing += stripped + " " continue # Still in opening section (no bullets seen yet) if "regards" in stripped.lower() or stripped.startswith("Sincerely") or stripped.startswith("Kind regards"): continue if stripped.lower() in ["sincerely,", "kind regards,", "regards,"] or stripped.startswith("Anthony Martin"): continue # Bridge text before bullets: drop it bridging_texts = ["my experience includes:", "my experience includes", "these include:", "these include", "key highlights:", "key highlights", "relevant experience includes:", "relevant experience includes", "relevant achievements include:", "relevant achievements include", "my relevant experience includes:"] lower = stripped.lower().rstrip(":") if lower in bridging_texts or any(stripped.lower().startswith(b) for b in ["my experience ", "i bring ", "i would ", "relevant experience ", "relevant achievements ", "key achievements "]): bridging = True continue if bridging: continue opening += stripped + " " # If the line just added ends with a bridge phrase, strip it opening_lower = opening.lower() for bt in bridging_texts: bt_stripped = bt.rstrip(":") if opening_lower.strip().rstrip(".;: ").endswith(bt_stripped): # Find actual position of bridge text in opening, then strip it idx = opening_lower.rfind(bt_stripped) if idx >= 0: opening = opening[:idx].strip() + " " break if not opening and len(lines) > 3: opening = lines[3].strip() if len(lines) > 3 else "" # Remove markdown bold markers from opening/closing opening = opening.replace('**', '') closing = closing.replace('**', '') return { "subject": subject or f"Application for {role} at {company}", "opening": opening.strip()[:800], "bullets": bullets[:5], "closing": closing.strip(), "date": datetime.now().strftime("%d %B %Y"), "company": company, "role": role, "location": location or "Perth, WA" } def render_cover_letter_html(structured, txt_url): """Render structured cover letter data as HTML using the template.""" try: template_path = os.path.expanduser("~/.hermes/dashboard/templates/cover-letter.html") with open(template_path) as f: template = f.read() # Build recipient block recipient = [structured.get("company", ""), structured.get("location", "Perth, WA")] # Format bullets bullets_html = "" for bullet in structured.get("bullets", []): bullets_html += f"
  • {bullet}
  • \n " if not bullets_html: bullets_html = "
  • " + structured.get("opening", "")[:200] + "
  • " # Replace template variables html = template.replace("{{title}}", structured.get("subject", "Cover Letter")) html = html.replace("{{date}}", structured.get("date", "")) html = html.replace("{{recipient_block}}", "\n".join(recipient)) html = html.replace("{{opening}}", structured.get("opening", "")) html = html.replace("{{bullets}}", bullets_html) html = html.replace("{{closing}}", structured.get("closing", "")) html = html.replace("{{txt_url}}", txt_url) html = html.replace("{{cover_letter_docx_url}}", txt_url.replace(".txt", ".docx")) return html except Exception as e: return None def update_notion_cover_letter(page_id, url): """Update the 'Cover Letter' URL property on a Notion page.""" env_path = os.path.expanduser("~/.hermes/.env") key = None with open(env_path) as f: for line in f: if line.startswith("NOTION_API_KEY="): key = line.split("=", 1)[1].strip() break if not key: return False payload = { "properties": { "Cover Letter": { "type": "url", "url": url } } } cmd = [ "curl", "-s", "-X", "PATCH", f"https://api.notion.com/v1/pages/{page_id}", "-H", f"Authorization: Bearer {key}", "-H", "Content-Type: application/json", "-H", "Notion-Version: 2022-06-28", "-d", json.dumps(payload) ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) try: data = json.loads(result.stdout) return data.get("object") == "page" except: return False # ── Main ─────────────────────────────────────────────────────────────────── def main(): if len(sys.argv) < 3: print(json.dumps({"error": "Usage: regenerate_cover_letter.py "})) sys.exit(1) job_id = sys.argv[1] feedback = sys.argv[2] job = load_job(job_id) if not job: print(json.dumps({"error": f"Job {job_id} not found in cache"})) sys.exit(1) company = job.get("name", "Unknown") role = job.get("role", "Unknown") location = job.get("location", "Perth, WA") salary = job.get("salary") salary_str = f"${salary:,}" if salary else "competitive" notes = job.get("notes", "") # Build the filename safe_name = slugify(f"{company}-{role}") ts = datetime.now().strftime("%Y%m%d") filename = f"cl-{safe_name}-{ts}.txt" filepath = os.path.join(DRAFTS_DIR, filename) # Read resume (just the first one found) resume_file = find_first_file(RESUMES_DIR) resume_text = "" if resume_file: if resume_file.endswith(".txt"): with open(resume_file) as f: resume_text = f.read() elif resume_file.lower().endswith(".pdf"): try: import pdfplumber with pdfplumber.open(resume_file) as pdf: pages = [p.extract_text() or "" for p in pdf.pages] resume_text = "\n".join(pages) except Exception: resume_text = f"[Resume file: {os.path.basename(resume_file)} — pdfplumber unavailable]" elif resume_file.lower().endswith(".docx"): try: from docx import Document doc = Document(resume_file) resume_text = "\n".join(p.text for p in doc.paragraphs) except Exception: resume_text = f"[Resume file: {os.path.basename(resume_file)} — python-docx unavailable]" # Read existing cover letter if re-generating existing_cl = "" # System prompt for cover letter generation system_prompt = """You are a professional cover letter writer. Write concise, impactful cover letters for Anthony Martin, a Perth-based marketing and communications professional. STYLE GUIDE (from Anthony's real cover letters): - Professional, warm, and direct — not overly formal or stiff - Open with a personal connection or belief statement about the company/role - Use bullet points (3-4) with specific metrics and achievements - Reference relevant experience from this resume only ANTHONY'S ACTUAL CAREER HISTORY (ABSOLUTE CONSTRAINT — DO NOT DEVIATE): - Pacific Energy (Kewdale, Perth, WA): Jun 2025 – Jul 2026 — Marketing Advisor. Corporate marketing, digital content, technical communications, major events, marketing AI strategy. Led website that won Science and Sustainability Website of the Year at 2026 Australian Web Awards. - Horizon Power (Perth, WA): 2019 – Sep 2024 — Marketing Specialist. Annual budgets >$2M, 80%+ customer satisfaction, WA EV Network marketing, Bright Horizons STEM program, regulatory compliance communications. - Synergy (Perth, WA): 2010 – 2018 — Multiple commercial/marketing roles. SolarReturn (40% sales increase), Synergy Sellback Program (150% above target), $2M+ billing resolution, 1M+ customer communications framework. CRITICAL RULES: - The THREE employers above are the ONLY ones Anthony has worked for. - NEVER mention Lotterywest, Tourism WA, Water Corporation, Department of Creative Industries, Empire Home, SVARA, Lenox Hill, Fairclough, Recruit Right, a&co Recruitment, Catholic Education WA, THE resources HUB — or any other employer — even as examples, comparisons, or "organisations including..." - If you list employers in the opening paragraph, ONLY list: Pacific Energy, Horizon Power, Synergy. - Treat any employer not explicitly listed here as FORBIDDEN. KEY ACHIEVEMENTS (use these exact metrics): - Pacific Energy 2026 Australian Web Awards — Science and Sustainability Website of the Year - Managed annual marketing budgets >$2M at Horizon Power - 80%+ customer satisfaction ratings - 40% sales increase (SolarReturn at Synergy, year one) - 150% above target (Synergy Sellback Program sales) - $1.3M billing discrepancy resolution at Synergy - 35% improvement in conversion rates - 20% reduction in inquiry-to-sale timeframes - Company-wide communications framework reaching 1M+ customers - WA EV Network marketing leadership - Created Bright Horizons STEM program (remote schools) BULLET STRUCTURE: - 3-4 bullets max - Each bullet: action verb + context + measurable outcome - Draw from the real achievements above — do NOT invent companies, roles, or metrics FORMAT: - Subject line with the role and company - Salutation (Dear Hiring Manager,) - Opening paragraph — why this role + company (1-2 sentences) - 3-4 bullet point achievements (draw from actual experience) - Closing paragraph — enthusiasm + call to action (1-2 sentences) - Signature: Anthony Martin""" user_prompt = f"""Write a tailored cover letter for this role: COMPANY: {company} ROLE: {role} LOCATION: {location} SALARY: {salary_str} ADDITIONAL NOTES: {notes} RESUME HIGHLIGHTS: {resume_text} FEEDBACK / REVISION INSTRUCTIONS: {feedback} IMPORTANT: Return ONLY the cover letter text — no commentary, no meta text. The letter must be ready to send.""" # Call LLM cl_text = call_llm(system_prompt, user_prompt) # Save os.makedirs(DRAFTS_DIR, exist_ok=True) with open(filepath, "w") as f: f.write(cl_text) # Build the URLs (both .txt raw and rendered HTML) tailscale_host = "https://hermes.kangaroo-eel.ts.net" base = f"cl-{safe_name}-{ts}" cl_url_txt = f"{tailscale_host}/jobs/drafts/{base}.txt" cl_url_html = f"{tailscale_host}/jobs/drafts/{base}.html" # Try to parse structured JSON from the plain text structured = parse_cover_letter(cl_text, company, role, location) # Save structured JSON for template rendering json_path = os.path.join(DRAFTS_DIR, base + ".json") with open(json_path, "w") as f: json.dump(structured, f, indent=2) # Render HTML version if we have structured data html_path = os.path.join(DRAFTS_DIR, base + ".html") html_doc = render_cover_letter_html(structured, cl_url_txt) if html_doc: with open(html_path, "w") as f: f.write(html_doc) # Generate .docx using python-docx builder docx_script = os.path.expanduser("~/.hermes/scripts/build_cover_letter_docx.py") docx_result = subprocess.run( ["python3", docx_script, base], capture_output=True, text=True, timeout=30, cwd=DRAFTS_DIR ) if docx_result.returncode != 0: print(f"[Warning] DOCX generation failed: {docx_result.stderr[:200]}") # Build the URLs (both .txt raw and rendered HTML) tailscale_host = "https://hermes.kangaroo-eel.ts.net" cl_url_txt = f"{tailscale_host}/jobs/drafts/{base}.txt" cl_url_html = f"{tailscale_host}/jobs/drafts/{base}.html" cl_url_docx = f"{tailscale_host}/jobs/drafts/{base}.docx" # Update Notion with the HTML URL (preferred view) update_notion_cover_letter(job_id, cl_url_html) print(json.dumps({ "url": cl_url_html, "txt_url": cl_url_txt, "docx_url": cl_url_docx, "file": filepath, "company": company, "role": role, "structured": structured is not None })) if __name__ == "__main__": main() def backfill_from_txt(txt_path): """Regenerate HTML/DOCX from an existing .txt file using the canonical parser.""" import subprocess with open(txt_path, "r", encoding="utf-8") as f: text = f.read() # Parse using canonical function structured = parse_cover_letter(text, company="", role="", location="") base = txt_path[:-4] json_path = base + ".json" html_path = base + ".html" docx_path = base + ".docx" # Add URLs txt_url = f"/jobs/drafts/{os.path.basename(txt_path)}" structured["txt_url"] = txt_url structured["html_url"] = txt_url.replace(".txt", ".html") structured["docx_url"] = txt_url.replace(".txt", ".docx") # Save JSON with open(json_path, "w", encoding="utf-8") as f: json.dump(structured, f, indent=2) # Render HTML using canonical function html = render_cover_letter_html(structured, txt_url) if html: with open(html_path, "w", encoding="utf-8") as f: f.write(html) # Generate DOCX docx_script = os.path.expanduser("~/.hermes/scripts/build_cover_letter_docx.py") subprocess.run(["python3", docx_script, json_path], capture_output=True, timeout=60) return os.path.exists(html_path) and os.path.exists(docx_path) if __name__ == "__main__": import sys # Check for backfill mode if len(sys.argv) == 2 and sys.argv[1].endswith(".txt"): txt_file = sys.argv[1] print(f"Backfilling {txt_file}...") success = backfill_from_txt(txt_file) print(f" {'✓' if success else '✗'} {os.path.basename(txt_file)}") sys.exit(0 if success else 1) # Normal mode: generate from Notion job if len(sys.argv) < 2: print("Usage: python3 regenerate_cover_letter.py [feedback]") print(" or: python3 regenerate_cover_letter.py ") sys.exit(1) job_id = sys.argv[1] feedback = sys.argv[2] if len(sys.argv) > 2 else "" main(job_id, feedback)