From c6476462b7b7e382e943ede7f6928a84843bce14 Mon Sep 17 00:00:00 2001 From: Anthony Date: Mon, 20 Jul 2026 21:06:44 +0800 Subject: [PATCH] Initial commit: job dashboard server with evening stand-down, Notion sync, cover letter generation --- backfill_cover_letters.py | 137 ++++++++++ evening.html | 491 ++++++++++++++++++++++++++++++++++ evening_standdown.py | 184 +++++++++++++ index.html | 232 ++++++++++++++++ job_dashboard_server.py | 406 ++++++++++++++++++++++++++++ regenerate_cover_letter.py | 509 ++++++++++++++++++++++++++++++++++++ templates/cover-letter.html | 164 ++++++++++++ 7 files changed, 2123 insertions(+) create mode 100755 backfill_cover_letters.py create mode 100644 evening.html create mode 100644 evening_standdown.py create mode 100644 index.html create mode 100644 job_dashboard_server.py create mode 100644 regenerate_cover_letter.py create mode 100644 templates/cover-letter.html diff --git a/backfill_cover_letters.py b/backfill_cover_letters.py new file mode 100755 index 0000000..e9788ef --- /dev/null +++ b/backfill_cover_letters.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Backfill HTML/DOCX from existing .txt files using canonical parser. + +This script uses the canonical parse_cover_letter() and render_cover_letter_html() +functions from regenerate_cover_letter.py to ensure consistent parsing across all +cover letter generation paths. + +Usage: + python3 backfill_cover_letters.py # Batch mode: all .txt files + python3 backfill_cover_letters.py path/to/file.txt # Single file mode +""" + +import os, sys, json, subprocess, glob + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from regenerate_cover_letter import parse_cover_letter, render_cover_letter_html + +DRAFTS = os.path.expanduser("~/.hermes/radar-ref/drafts") +DOCX_SCRIPT = os.path.expanduser("~/.hermes/scripts/build_cover_letter_docx.py") + + +def backfill_txt(txt_path): + """Regenerate HTML/DOCX from an existing .txt file using canonical parser.""" + errors = [] + + try: + with open(txt_path, "r", encoding="utf-8") as f: + text = f.read() + except Exception as e: + return False, [f"Read failed: {e}"] + + # Extract company/role from filename for better parsing + fname = os.path.basename(txt_path) + # Simple heuristic: cl-company-role-date.txt + parts = fname.replace("cl-", "").replace(".txt", "").split("-") + company = parts[0].replace("-", " ").title() if parts else "" + role = parts[1].replace("-", " ").title() if len(parts) > 1 else "" + + # Parse using canonical function + try: + structured = parse_cover_letter(text, company=company, role=role, location="Perth") + except Exception as e: + return False, [f"Parse failed: {e}"] + + # Validate parsed output + if not structured.get("opening") and not structured.get("bullets") and not structured.get("closing"): + return False, ["Parsed output is empty — check .txt file format"] + + 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 + try: + with open(json_path, "w", encoding="utf-8") as f: + json.dump(structured, f, indent=2) + except Exception as e: + return False, [f"JSON save failed: {e}"] + + # Render HTML + try: + html = render_cover_letter_html(structured, txt_url) + if html: + with open(html_path, "w", encoding="utf-8") as f: + f.write(html) + else: + return False, ["HTML render returned None"] + except Exception as e: + return False, [f"HTML render failed: {e}"] + + # Generate DOCX + try: + result = subprocess.run( + ["python3", DOCX_SCRIPT, json_path], + capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + errors.append(f"DOCX script error: {result.stderr[:200]}") + except Exception as e: + errors.append(f"DOCX subprocess failed: {e}") + + docx_ok = os.path.exists(docx_path) + html_ok = os.path.exists(html_path) + + return html_ok and docx_ok, errors + + +if __name__ == "__main__": + if len(sys.argv) > 1: + # Single file mode + txt_file = sys.argv[1] + print(f"Backfilling {os.path.basename(txt_file)}...") + success, errors = backfill_txt(txt_file) + if success: + print(f" ✓ {os.path.basename(txt_file)}") + else: + print(f" ✗ {os.path.basename(txt_file)}") + for err in errors: + print(f" - {err}") + sys.exit(0 if success else 1) + + # Batch mode: process all .txt files + print("Backfilling all cover letters from .txt files using canonical parser...") + print("=" * 70) + + results = {"ok": 0, "fail": 0, "errors": []} + + for txt_path in sorted(glob.glob(os.path.join(DRAFTS, "cl-*.txt"))): + base = os.path.basename(txt_path) + success, errors = backfill_txt(txt_path) + + if success: + results["ok"] += 1 + print(f" ✓ {base[:55]}") + else: + results["fail"] += 1 + print(f" ✗ {base[:55]}") + for err in errors: + results["errors"].append((base, err)) + + print("=" * 70) + print(f"Results: {results['ok']} OK, {results['fail']} failed") + + if results["errors"]: + print("\nErrors:") + for base, err in results["errors"]: + print(f" {base}: {err}") + + print("\nDone.") diff --git a/evening.html b/evening.html new file mode 100644 index 0000000..ac1497e --- /dev/null +++ b/evening.html @@ -0,0 +1,491 @@ + + + + + +Evening Stand-Down — Rhino Terminal + + + + + +
+ +
+
+ 🌆 rhino-terminal — evening-stand-down +
+ +
+
+
+
rhino@jacket:~$ ./briefing --mode=stand-down
+ +
+
Evening Stand-Down
+
Loading…
+
+
─── tap each line to read the briefing ───
+ +
+
+
+
+ + +
+ + + +
+ + + + \ No newline at end of file diff --git a/evening_standdown.py b/evening_standdown.py new file mode 100644 index 0000000..bcae5b4 --- /dev/null +++ b/evening_standdown.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Evening Stand-Down — data collector for the 6:45pm weekday briefing. +Fetches today's job activity, cron health, and Perth weather. +Saves JSON data to ~/.hermes/dashboard/evening/ for the interactive HTML page. +Outputs a chat-summary line for cron delivery.""" + +import json +import os +import sqlite3 +import sys +import urllib.request +from datetime import datetime, timezone, timedelta + +PERTH_TZ = timezone(timedelta(hours=8)) # AWST +DASHBOARD_DIR = os.path.expanduser("~/.hermes/dashboard") +EVENING_DIR = os.path.join(DASHBOARD_DIR, "evening") + +def fetch_json(url): + try: + with urllib.request.urlopen(url, timeout=10) as r: + return json.loads(r.read()) + except Exception as e: + return {"error": str(e)} + +def get_job_stats(): + """Call the dashboard API for today's data.""" + stats = fetch_json("http://localhost:9099/api/stats") + jobs_resp = fetch_json("http://localhost:9099/api/jobs?limit=200") + + if "error" in jobs_resp: + return {"error": jobs_resp["error"], "stats": stats} + + today = datetime.now(PERTH_TZ).strftime("%Y-%m-%d") + jobs_list = jobs_resp.get("jobs", []) + + today_added = [j for j in jobs_list if j.get("date_added") == today] + strong = [j for j in jobs_list if j.get("strong_match")] + urgent_closing = [j for j in jobs_list if j.get("closing_status") in ("urgent", "today")] + closing_all = [j for j in jobs_list if j.get("closing_date") or (j.get("closing_status") and j["closing_status"] not in ("none", "", None))] + closing = sorted(closing_all, key=lambda j: j.get("closing_date", "9999-99-99"))[:15] + + by_status = {} + for j in jobs_list: + s = j.get("status", "Backlog") + by_status[s] = by_status.get(s, 0) + 1 + + salaries_today = [j["salary"] for j in today_added if j.get("salary")] + + return { + "total": stats.get("total", 0), + "this_week": stats.get("this_week", 0), + "strong_matches": len(strong), + "today_added": len(today_added), + "today_roles": [ + { + "name": j.get("name", ""), + "role": j.get("role", ""), + "salary": j.get("salary"), + "source": j.get("source", ""), + "url": j.get("url", ""), + } + for j in today_added[:8] + ], + "urgent_closing": len(urgent_closing), + "closing_soon": [ + { + "name": j.get("name", ""), + "role": j.get("role", ""), + "closing_date": j.get("closing_date", ""), + "closing_status": j.get("closing_status", ""), + "salary": j.get("salary"), + "source": j.get("source", ""), + "strong_match": j.get("strong_match", False), + "url": j.get("url", ""), + } + for j in closing + ], + "status_breakdown": by_status, + "salary_range": {"min": min(salaries_today), "max": max(salaries_today)} if salaries_today else None, + "refreshed_at": stats.get("refreshed_at", ""), + } + +def get_cron_health(): + """Check recent cron execution health from SQLite.""" + db_path = os.path.expanduser("~/.hermes/cron/executions.db") + if not os.path.exists(db_path): + return {"error": "No executions DB found"} + + try: + conn = sqlite3.connect(db_path) + c = conn.cursor() + yesterday = (datetime.now() - timedelta(days=1)).isoformat() + c.execute(""" + SELECT job_id, status, started_at, error + FROM executions + WHERE started_at > ? + ORDER BY started_at DESC + LIMIT 30 + """, (yesterday,)) + rows = c.fetchall() + conn.close() + errors = [r for r in rows if r[1] in ("error", "timeout")] + return { + "recent_runs": len(rows), + "success": sum(1 for r in rows if r[1] == "success"), + "errors": len(errors), + "running": sum(1 for r in rows if r[1] == "running"), + "error_details": [{"job_id": e[0], "error": (e[3] or "")[:150]} for e in errors[:3]], + } + except Exception as e: + return {"error": str(e)} + +def get_weather(): + """Fetch Perth weather via wttr.in (free, no key).""" + try: + with urllib.request.urlopen("https://wttr.in/Perth?format=j1", timeout=10) as r: + data = json.loads(r.read()) + current = data.get("current_condition", [{}])[0] + forecast = data.get("weather", []) + tomorrow = forecast[1] if len(forecast) > 1 else forecast[0] if forecast else {} + return { + "temp_c": current.get("temp_C", "?"), + "feels_like": current.get("FeelsLikeC", "?"), + "condition": current.get("weatherDesc", [{}])[0].get("value", "?"), + "wind_kmh": current.get("windspeedKmph", "?"), + "humidity": current.get("humidity", "?"), + "tomorrow_high": tomorrow.get("maxtempC", "?"), + "tomorrow_low": tomorrow.get("mintempC", "?"), + "tomorrow_condition": ( + tomorrow.get("hourly", [{}])[0].get("weatherDesc", [{}])[0].get("value", "?") + if tomorrow.get("hourly") else "?" + ), + } + except Exception as e: + return {"error": str(e)} + +def main(): + now = datetime.now(PERTH_TZ) + is_weekday = now.weekday() < 5 + + jobs = get_job_stats() + cron = get_cron_health() + weather = get_weather() + + output = { + "timestamp": now.isoformat(), + "day": now.strftime("%A"), + "date": now.strftime("%d %B %Y"), + "jobs": jobs, + "cron_health": cron, + "weather": weather, + } + + # Save JSON data for the interactive HTML page + os.makedirs(EVENING_DIR, exist_ok=True) + + # Today's dated file + date_str = now.strftime("%Y-%m-%d") + dated_path = os.path.join(EVENING_DIR, f"{date_str}.json") + with open(dated_path, "w") as f: + json.dump(output, f, indent=2) + + # Overwrite latest.json + latest_path = os.path.join(EVENING_DIR, "latest.json") + with open(latest_path, "w") as f: + json.dump(output, f, indent=2) + + # Output chat-friendly summary for cron delivery + new_count = jobs.get("today_added", 0) if isinstance(jobs, dict) else 0 + strong_count = jobs.get("strong_matches", 0) if isinstance(jobs, dict) else 0 + urgent_count = jobs.get("urgent_closing", 0) if isinstance(jobs, dict) else 0 + total = jobs.get("total", 0) if isinstance(jobs, dict) else 0 + cron_ok = cron.get("errors", 0) == 0 if isinstance(cron, dict) else True + temp = weather.get("temp_c", "?") if isinstance(weather, dict) else "?" + condition = weather.get("condition", "?") if isinstance(weather, dict) else "?" + + print(f"🌆 Evening Stand-Down · {now.strftime('%A %d %B')}") + print(f"📊 {new_count} new · {strong_count} strong · {urgent_count} urgent · {total} total") + print(f"🌤️ {temp}°C · {condition}") + print(f"{'✅' if cron_ok else '⚠️'} Cron: {'all healthy' if cron_ok else 'errors detected'}") + print(f"🔗 https://hermes.kangaroo-eel.ts.net/jobs/evening.html") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..05eab4c --- /dev/null +++ b/index.html @@ -0,0 +1,232 @@ + + + + + +Job Tracker · Anthony + + + +
+

Job Tracker

Anthony Martin · Perth, WA
+
+
Total Jobs
Strong Matches
This Week
Stale (14d+)
Expiring Soon
Past Closing
+
+
0 selectedStatus:
+
AllBacklogAppliedInterviewsRejected
+ +
NewestStalestSalary
+

Opportunities

+
+
CompanyRoleStatusLocationSourceSalaryClosingAgeCL
Loading...
+
The Rhino in the Green Jacket · API
+
+ + \ No newline at end of file diff --git a/job_dashboard_server.py b/job_dashboard_server.py new file mode 100644 index 0000000..f293f9f --- /dev/null +++ b/job_dashboard_server.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""Job Dashboard API Server — serves the dashboard + Notion sync API. +Replaces static http.server. Handles: + - Static file serving (dashboard HTML, assets) + - REST API for job CRUD (syncs back to Notion) + - Auto-refresh from Notion every 5 min +""" +import json +import os +import re +import subprocess +import sys +import time +import traceback +from datetime import datetime, timezone +from http.server import HTTPServer, SimpleHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +# ── Config ─────────────────────────────────────────────────────────────────── +PORT = 9099 +DASHBOARD_DIR = os.path.expanduser("~/.hermes/dashboard") +DRAFTS_DIR = os.path.expanduser("~/.hermes/radar-ref/drafts") +DB_ID = "0f90ba2b-8b10-4d02-a62c-6f692d5b1168" +ENV_FILE = os.path.expanduser("~/.hermes/.env") +CACHE_FILE = os.path.join(DASHBOARD_DIR, "jobs_cache.json") +REFRESH_INTERVAL = 300 # 5 min + +# ── Cache ──────────────────────────────────────────────────────────────────── +_jobs_cache = None +_last_refresh = 0 + +def get_notion_key(): + with open(ENV_FILE) as f: + for line in f: + line = line.strip() + if line.startswith("NOTION_API_KEY="): + return line.split("=", 1)[1] + return None + +def notion_api(method, path, data=None): + """Call Notion REST API directly.""" + key = get_notion_key() + if not key: + return {"error": "NOTION_API_KEY not found"} + + url = f"https://api.notion.com/v1/{path.lstrip('/')}" + cmd = ["curl", "-s", "-X", method, url, + "-H", f"Authorization: Bearer {key}", + "-H", "Notion-Version: 2022-06-28", + "-H", "Content-Type: application/json"] + + if data is not None: + cmd += ["-d", json.dumps(data)] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + return json.loads(result.stdout) + +def refresh_cache(force=False): + """Fetch all jobs from Notion and cache.""" + global _jobs_cache, _last_refresh + now = time.time() + if not force and _jobs_cache and (now - _last_refresh) < REFRESH_INTERVAL: + return _jobs_cache + + all_results = [] + has_more = True + start_cursor = None + + while has_more: + payload = { + "page_size": 100, + "sorts": [{"property": "Date Added", "direction": "descending"}], + } + if start_cursor: + payload["start_cursor"] = start_cursor + + data = notion_api("POST", f"databases/{DB_ID}/query", payload) + if "error" in data: + print(f"Notion API error: {data['error']}", file=sys.stderr) + if _jobs_cache: + return _jobs_cache + return {"error": data["error"]} + + all_results.extend(data.get("results", [])) + has_more = data.get("has_more", False) + start_cursor = data.get("next_cursor") + + jobs = [] + for r in all_results: + props = r["properties"] + job = { + "id": r["id"], + "name": "", + "role": "", + "status": "Backlog", + "location": "", + "source": "", + "strong_match": False, + "ai_sourced": False, + "date_added": "", + "url": "", + "salary": None, + "notes": "", + "priority": "", + } + + if props.get("Name", {}).get("title"): + job["name"] = props["Name"]["title"][0].get("plain_text", "") + if props.get("Role", {}).get("rich_text"): + job["role"] = props["Role"]["rich_text"][0].get("plain_text", "") + if props.get("Status", {}).get("select"): + job["status"] = props["Status"]["select"]["name"] + if props.get("Location", {}).get("select"): + job["location"] = props["Location"]["select"]["name"] + if props.get("Source", {}).get("select"): + job["source"] = props["Source"]["select"]["name"] + job["strong_match"] = props.get("Strong Match", {}).get("checkbox", False) + job["ai_sourced"] = props.get("AI Sourced", {}).get("checkbox", False) + if props.get("Date Added", {}).get("date"): + job["date_added"] = props["Date Added"]["date"].get("start", "") + if props.get("URL", {}).get("url"): + job["url"] = props["URL"]["url"] + if props.get("Salary (AUD/yr)", {}).get("number") is not None: + job["salary"] = props["Salary (AUD/yr)"]["number"] + if props.get("Notes / JD Summary", {}).get("rich_text"): + job["notes"] = props["Notes / JD Summary"]["rich_text"][0].get("plain_text", "") + if props.get("Priority", {}).get("select"): + job["priority"] = props["Priority"]["select"]["name"] + + if props.get("Closing Date", {}).get("date"): + job["closing_date"] = props["Closing Date"]["date"].get("start", "") + else: + job["closing_date"] = "" + # Closing date calculations + job["closing_status"] = "none" + if job.get("closing_date"): + try: + from datetime import date + cd = datetime.strptime(job["closing_date"], "%Y-%m-%d").date() + today = date.today() + days_left = (cd - today).days + if days_left < 0: + job["closing_status"] = "past" + elif days_left <= 7: + job["closing_status"] = "urgent" + elif days_left <= 14: + job["closing_status"] = "warning" + except ValueError: + pass + + if props.get("Cover Letter", {}).get("url"): + job["cover_letter_url"] = props["Cover Letter"]["url"] + else: + job["cover_letter_url"] = "" + if job["date_added"]: + try: + added = datetime.strptime(job["date_added"], "%Y-%m-%d") + now_dt = datetime.now() + job["days_since"] = (now_dt - added).days + except ValueError: + job["days_since"] = 0 + else: + job["days_since"] = 0 + + jobs.append(job) + + _jobs_cache = {"jobs": jobs, "total": len(jobs), "refreshed_at": datetime.now().isoformat()} + _last_refresh = now + + # Also write the cache file + try: + os.makedirs(DASHBOARD_DIR, exist_ok=True) + with open(CACHE_FILE, "w") as f: + json.dump(_jobs_cache, f) + except Exception: + pass + + return _jobs_cache + +def update_job_status(job_id, updates): + """Update a Notion job page with new property values.""" + notion_props = {} + + if "status" in updates: + notion_props["Status"] = {"select": {"name": updates["status"]}} + if "strong_match" in updates: + notion_props["Strong Match"] = {"checkbox": bool(updates["strong_match"])} + if "priority" in updates: + notion_props["Priority"] = {"select": {"name": updates["priority"]}} + if "notes" in updates: + notion_props["Notes / JD Summary"] = { + "rich_text": [{"type": "text", "text": {"content": updates["notes"]}}] + } + if "cover_letter_url" in updates: + notion_props["Cover Letter"] = {"url": updates["cover_letter_url"]} + + if not notion_props: + return {"error": "No valid fields to update"} + + result = notion_api("PATCH", f"pages/{job_id}", {"properties": notion_props}) + return result + +# ── HTTP Handler ───────────────────────────────────────────────────────────── +class DashboardHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=DASHBOARD_DIR, **kwargs) + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + + # API routes + if path == "/api/jobs": + return self._json_response(refresh_cache()) + elif path == "/api/stats": + data = refresh_cache() + jobs = data.get("jobs", []) + statuses = {} + for j in jobs: + s = j["status"] + statuses[s] = statuses.get(s, 0) + 1 + locations = {} + for j in jobs: + l = j["location"] or "Unknown" + locations[l] = locations.get(l, 0) + 1 + sources = {} + for j in jobs: + s = j["source"] or "Unknown" + sources[s] = sources.get(s, 0) + 1 + salaries = [j["salary"] for j in jobs if j["salary"]] + stats = { + "total": data["total"], + "strong_matches": sum(1 for j in jobs if j["strong_match"]), + "ai_sourced": sum(1 for j in jobs if j["ai_sourced"]), + "this_week": sum(1 for j in jobs if j["days_since"] <= 7), + "stale_14d": sum(1 for j in jobs if j["days_since"] > 14), + "closing_past": sum(1 for j in jobs if j.get("closing_status") == "past"), + "closing_urgent": sum(1 for j in jobs if j.get("closing_status") == "urgent"), + "closing_warning": sum(1 for j in jobs if j.get("closing_status") == "warning"), + "has_closing": sum(1 for j in jobs if j.get("closing_date")), + "statuses": statuses, + "locations": locations, + "sources": sources, + "salary_min": min(salaries) if salaries else None, + "salary_max": max(salaries) if salaries else None, + "refreshed_at": data.get("refreshed_at", ""), + } + return self._json_response(stats) + elif path == "/api/refresh": + data = refresh_cache(force=True) + return self._json_response({"ok": True, "total": data["total"]}) + elif path.startswith("/api/jobs/") and path.endswith("/refresh"): + data = refresh_cache(force=True) + return self._json_response({"ok": True, "total": data["total"]}) + + # Serve draft files (cover letters) — preserve extension for browser rendering + # Support both /drafts/ and /jobs/drafts/ paths (Tailscale Serve uses /jobs/drafts/ from prompt) + drafts_path = None + if path.startswith("/drafts/"): + safe_path = os.path.normpath(path.lstrip("/")) + drafts_path = os.path.join(DRAFTS_DIR, os.path.relpath(safe_path, "drafts")) + elif path.startswith("/jobs/drafts/"): + safe_path = os.path.normpath(path.lstrip("/")) + drafts_path = os.path.join(DRAFTS_DIR, os.path.relpath(safe_path, "jobs/drafts")) + + if drafts_path: + if os.path.isfile(drafts_path) and os.path.realpath(drafts_path).startswith(os.path.realpath(DRAFTS_DIR)): + ext = os.path.splitext(drafts_path)[1].lower() + ctype = "text/plain; charset=utf-8" + if ext == ".html": + ctype = "text/html; charset=utf-8" + elif ext == ".json": + ctype = "application/json; charset=utf-8" + elif ext == ".docx": + ctype = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Disposition", f"inline; filename=\"{os.path.basename(drafts_path)}\"") + self.end_headers() + if ext == ".docx": + with open(drafts_path, "rb") as f: + self.wfile.write(f.read()) + else: + with open(drafts_path) as f: + self.wfile.write(f.read().encode("utf-8")) + return + else: + return self._json_response({"error": "Not found"}, 404) + + # Default: serve static file + return super().do_GET() + + def do_POST(self): + parsed = urlparse(self.path) + path = parsed.path + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) if content_length else b"{}" + + try: + payload = json.loads(body) + except json.JSONDecodeError: + return self._json_response({"error": "Invalid JSON"}, 400) + + # POST /api/refresh — force refresh from Notion + if path == "/api/refresh": + data = refresh_cache(force=True) + return self._json_response({"ok": True, "total": data["total"], "refreshed_at": data["refreshed_at"]}) + + # POST /api/jobs//update — update a job + match = re.match(r"^/api/jobs/([a-f0-9\-]+)/update$", path) + if match: + job_id = match.group(1) + result = update_job_status(job_id, payload) + if "error" in result: + return self._json_response(result, 400) + # Refresh cache after update + refresh_cache(force=True) + return self._json_response({"ok": True}) + + # POST /api/jobs//cover-letter/regenerate — regenerate cover letter with feedback + match_cl = re.match(r"^/api/jobs/([a-f0-9\-]+)/cover-letter/regenerate$", path) + if match_cl: + job_id = match_cl.group(1) + feedback = payload.get("feedback", "Make it stronger") + script = os.path.expanduser("~/.hermes/scripts/regenerate_cover_letter.py") + result = subprocess.run( + ["python3", script, job_id, feedback], + capture_output=True, text=True, timeout=120 + ) + try: + cl_result = json.loads(result.stdout) + if "error" in cl_result: + return self._json_response(cl_result, 400) + # Update Notion with the new URL + update_result = update_job_status(job_id, {"cover_letter_url": cl_result["url"]}) + if "error" in update_result: + return self._json_response({"error": "Draft saved but Notion update failed", "cl_result": cl_result, "notion_error": update_result}, 400) + # Refresh cache + refresh_cache(force=True) + return self._json_response({"ok": True, "url": cl_result["url"], "file": cl_result["file"]}) + except json.JSONDecodeError: + return self._json_response({"error": "Regeneration failed", "output": result.stdout[:500]}, 400) + + # POST /api/jobs/bulk-update — update multiple jobs + if path == "/api/jobs/bulk-update": + ids = payload.get("ids", []) + updates = payload.get("updates", {}) + results = [] + for jid in ids: + r = update_job_status(jid, updates) + results.append({"id": jid, "ok": "error" not in r}) + refresh_cache(force=True) + return self._json_response({"ok": True, "results": results}) + + return self._json_response({"error": "Not found"}, 404) + + def do_OPTIONS(self): + """CORS preflight.""" + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def _json_response(self, data, status=200): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def log_message(self, format, *args): + """Quiet logs — skip static file noise.""" + msg = format % args + if "/api/" in msg: + print(f"[API] {msg}", file=sys.stderr) + else: + # Keep it very quiet for static files + pass + +# ── Main ───────────────────────────────────────────────────────────────────── +def main(): + # Ensure dashboard dir exists + os.makedirs(DASHBOARD_DIR, exist_ok=True) + + # Pre-warm the cache + print("Pre-warming job cache from Notion...", file=sys.stderr) + refresh_cache(force=True) + print(f"Cache loaded: {_jobs_cache['total']} jobs", file=sys.stderr) + + server = HTTPServer(("0.0.0.0", PORT), DashboardHandler) + print(f"Dashboard server running on http://127.0.0.1:{PORT}", file=sys.stderr) + print(f"Dashboard URL: https://hermes.kangaroo-eel.ts.net/jobs", file=sys.stderr) + print(f"API: GET /api/jobs /api/stats /api/refresh", file=sys.stderr) + print(f"API: POST /api/jobs//update /api/jobs/bulk-update", file=sys.stderr) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down...", file=sys.stderr) + server.server_close() + +if __name__ == "__main__": + main() diff --git a/regenerate_cover_letter.py b/regenerate_cover_letter.py new file mode 100644 index 0000000..04d9efb --- /dev/null +++ b/regenerate_cover_letter.py @@ -0,0 +1,509 @@ +#!/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) diff --git a/templates/cover-letter.html b/templates/cover-letter.html new file mode 100644 index 0000000..0d47998 --- /dev/null +++ b/templates/cover-letter.html @@ -0,0 +1,164 @@ + + + + + +{{title}} + + + +
    +
    +
    Anthony Martin
    +
    90 Lansdowne Road, Kensington  |  0410 349 137  |  anthony@martinwa.org  |  linkedin.com/in/anthonymartin1987
    +
    + +
    {{date}}
    + +
    + {{recipient_block}} +
    + +
    Dear Hiring Manager,
    + +
    {{opening}}
    + +
      + {{bullets}} +
    + +
    {{closing}}
    + +
    + Sincerely,

    + Anthony Martin +
    +
    + +
    + ← Back to dashboard + Raw .txt + + ⬇ Word .docx +
    + +