Initial commit: job dashboard server with evening stand-down, Notion sync, cover letter generation

This commit is contained in:
2026-07-20 21:06:44 +08:00
commit c6476462b7
7 changed files with 2123 additions and 0 deletions

406
job_dashboard_server.py Normal file
View File

@@ -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/<id>/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/<id>/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/<id>/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()