Initial commit: job dashboard server with evening stand-down, Notion sync, cover letter generation
This commit is contained in:
137
backfill_cover_letters.py
Executable file
137
backfill_cover_letters.py
Executable file
@@ -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.")
|
||||
491
evening.html
Normal file
491
evening.html
Normal file
@@ -0,0 +1,491 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1">
|
||||
<title>Evening Stand-Down — Rhino Terminal</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0d0f14;--surf:#141820;--card:#181c26;
|
||||
--border:#1e2332;--amber:#ffb000;--amber-bright:#ffd866;
|
||||
--amber-dim:#665500;--amber-faint:#2a2200;
|
||||
--green:#4caf7d;--red:#ef5350;--cyan:#7ae9ff;
|
||||
--muted:#3a3e4a;
|
||||
--radius:0;--font:'JetBrains Mono','Fira Code','Cascadia Code',monospace
|
||||
}
|
||||
body{
|
||||
font-family:var(--font);background:var(--bg);color:var(--amber);
|
||||
min-height:100vh;padding:16px;line-height:1.7;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
font-size:14px
|
||||
}
|
||||
.container{max-width:600px;margin:0 auto}
|
||||
|
||||
/* ── CRT Glow ───────────────────────────────────────── */
|
||||
body::after{
|
||||
content:'';position:fixed;top:0;left:0;width:100%;height:100%;
|
||||
pointer-events:none;
|
||||
background:radial-gradient(ellipse at center,rgba(255,176,0,0.015) 0%,transparent 70%);
|
||||
z-index:999
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────── */
|
||||
.term-bar{
|
||||
border:1px solid var(--amber-faint);border-radius:4px 4px 0 0;
|
||||
background:var(--surf);overflow:hidden
|
||||
}
|
||||
.term-title{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:6px 12px;background:var(--card);border-bottom:1px solid var(--amber-faint);
|
||||
font-size:11px;color:var(--amber-dim);user-select:none
|
||||
}
|
||||
.term-title .dot{
|
||||
display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px
|
||||
}
|
||||
.term-title .dots{display:flex;gap:4px}
|
||||
.term-title .dots .dot{width:10px;height:10px;margin:0}
|
||||
.dot.r{background:#ff5f56}.dot.y{background:#ffbd2e}.dot.g{background:#27c93f}
|
||||
.term-title .name{font-size:10px;text-transform:uppercase;letter-spacing:0.8px}
|
||||
.term-body{padding:18px 16px 14px;min-height:200px}
|
||||
|
||||
.prompt-line{
|
||||
display:flex;align-items:center;gap:2px;margin-bottom:2px;
|
||||
font-size:13px;color:var(--amber-dim)
|
||||
}
|
||||
.prompt-line .ps1{color:var(--amber-bright);font-weight:500}
|
||||
.prompt-line .ps2{color:var(--muted)}
|
||||
.blink-cursor::after{
|
||||
content:'█';animation:blink 1s step-end infinite;
|
||||
color:var(--amber);font-size:12px;margin-left:1px
|
||||
}
|
||||
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
|
||||
@keyframes rippleAnim{from{transform:scale(0);opacity:1}to{transform:scale(2.5);opacity:0}}
|
||||
|
||||
.header-content{padding:6px 0 12px}
|
||||
.header-content .greeting{font-size:16px;font-weight:700;color:var(--amber-bright)}
|
||||
.header-content .date{font-size:12px;color:var(--amber-dim);margin-top:2px}
|
||||
.header-content .lin{font-size:11px;color:var(--muted);margin-top:2px}
|
||||
.header-content .lin sp{color:var(--amber-dim)}
|
||||
|
||||
/* ── Terminal Section Cards ────────────────────────── */
|
||||
.sect{margin-bottom:6px}
|
||||
.sect-head{
|
||||
display:flex;align-items:center;gap:8px;
|
||||
padding:10px 12px;cursor:pointer;user-select:none;
|
||||
border:1px solid var(--border);border-radius:0;
|
||||
background:var(--surf);
|
||||
transition:border-color 0.25s,background 0.2s;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
font-family:var(--font);font-size:13px;color:var(--amber-dim);position:relative;overflow:hidden
|
||||
}
|
||||
.sect-head:hover{border-color:var(--amber-faint);background:var(--card)}
|
||||
.sect-head .ico{font-size:16px;width:22px;text-align:center;flex-shrink:0}
|
||||
.sect-head .lb{flex:1;color:var(--amber);font-weight:500;letter-spacing:-0.3px}
|
||||
.sect-head .ar{font-size:10px;color:var(--amber-dim);transition:transform 0.4s cubic-bezier(0.34,1.56,0.64,1)}
|
||||
.sect.open .sect-head .ar{transform:rotate(180deg)}
|
||||
.sect.open .sect-head{background:var(--card);border-color:var(--amber-faint)}
|
||||
|
||||
.sect-body{
|
||||
max-height:0;overflow:hidden;
|
||||
transition:max-height 0.5s cubic-bezier(0.22,1,0.36,1);
|
||||
border-left:1px solid var(--amber-faint);border-right:1px solid var(--amber-faint)
|
||||
}
|
||||
.sect.open .sect-body{max-height:900px}
|
||||
.sect-inner{padding:10px 14px 14px;border-top:1px solid var(--amber-faint)}
|
||||
|
||||
/* ── Terminal-style Content ─────────────────────────── */
|
||||
.kpi-grid{
|
||||
display:grid;grid-template-columns:repeat(3,1fr);gap:1px;
|
||||
background:var(--border);border:1px solid var(--border)
|
||||
}
|
||||
.kpi-grid>div{
|
||||
background:var(--surf);padding:12px 6px;text-align:center
|
||||
}
|
||||
.kpi-grid .v{font-size:28px;font-weight:700;line-height:1;margin-bottom:2px}
|
||||
.kpi-grid .l{font-size:9px;text-transform:uppercase;letter-spacing:1px;color:var(--amber-dim)}
|
||||
|
||||
.arrow-join{position:relative;padding:2px 0 2px 18px}
|
||||
.arrow-join::before{
|
||||
content:'▸';position:absolute;left:0;color:var(--amber-dim);font-size:11px
|
||||
}
|
||||
|
||||
.role-line{
|
||||
padding:8px 0 8px 18px;position:relative;
|
||||
border-bottom:1px solid var(--border)
|
||||
}
|
||||
.role-line:last-child{border:0}
|
||||
.role-line::before{
|
||||
content:'▶';position:absolute;left:0;top:10px;font-size:8px;color:var(--amber-dim)
|
||||
}
|
||||
.role-line .co{font-size:13px;font-weight:500;color:var(--amber-bright)}
|
||||
.role-line .ti{font-size:11px;color:var(--amber-dim);margin-top:1px}
|
||||
.role-line .tg{
|
||||
display:flex;gap:6px;margin-top:4px;font-size:10px
|
||||
}
|
||||
.role-line .tg sp{
|
||||
padding:1px 6px;border:1px solid var(--amber-faint);
|
||||
color:var(--amber-dim);border-radius:2px
|
||||
}
|
||||
.role-line .tg .sal{border-color:rgba(76,175,125,0.3);color:var(--green)}
|
||||
.role-line .tg .src{border-color:var(--amber-faint);color:var(--amber-dim)}
|
||||
|
||||
.wdg{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.wdg sp{
|
||||
font-size:10px;padding:3px 8px;border:1px solid var(--border)
|
||||
}
|
||||
.wdg .ok{border-color:rgba(76,175,125,0.3);color:var(--green)}
|
||||
.wdg .err{border-color:rgba(239,83,80,0.3);color:var(--red)}
|
||||
.wdg .neu{border-color:var(--amber-faint);color:var(--amber-dim)}
|
||||
|
||||
.w-row{
|
||||
display:flex;justify-content:space-between;align-items:center;
|
||||
padding:4px 0
|
||||
}
|
||||
.w-row .lft{display:flex;align-items:center;gap:10px}
|
||||
.w-row .lft .t{font-size:30px;font-weight:700;line-height:1}
|
||||
.w-row .lft .c{font-size:11px;color:var(--amber-dim)}
|
||||
.w-row .rgt{text-align:right;font-size:11px;color:var(--amber-dim)}
|
||||
.w-row .rgt .b{font-weight:500;color:var(--amber-dbright);font-size:13px}
|
||||
|
||||
.mosq-box{text-align:center;padding:6px 0 2px}
|
||||
.mosq-box .b{font-size:36px;display:inline-block;animation:buzz 1.5s ease-in-out infinite}
|
||||
@keyframes buzz{
|
||||
0%,100%{transform:translate(0,0)rotate(0)}
|
||||
20%{transform:translate(1px,-1px)rotate(2deg)}
|
||||
40%{transform:translate(-2px,1px)rotate(-2deg)}
|
||||
60%{transform:translate(1px,1px)rotate(1deg)}
|
||||
80%{transform:translate(-1px,-1px)rotate(-1deg)}
|
||||
}
|
||||
.mosq-box .m{margin-top:6px;font-size:12px;color:var(--amber-dim);line-height:1.6;font-style:italic}
|
||||
.mosq-box .z{font-size:9px;color:var(--muted);margin-top:4px}
|
||||
|
||||
.rhn-box{text-align:center;padding:4px 0}
|
||||
.rhn-box .i{font-size:30px;margin-bottom:4px}
|
||||
.rhn-box .m{font-size:13px;line-height:1.6;color:var(--amber);font-style:italic}
|
||||
.rhn-box .s{font-size:9px;color:var(--muted);margin-top:6px}
|
||||
|
||||
.empty{color:var(--muted);font-size:12px;text-align:center;padding:12px 0}
|
||||
|
||||
.dash-link{
|
||||
display:block;text-align:center;
|
||||
padding:8px;margin-top:8px;
|
||||
border:1px solid var(--amber-faint);border-radius:0;
|
||||
color:var(--amber);text-decoration:none;font-size:11px;
|
||||
transition:background 0.2s
|
||||
}
|
||||
.dash-link:hover{background:var(--amber-faint)}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────── */
|
||||
.term-footer{
|
||||
border:1px solid var(--amber-faint);border-top:0;
|
||||
padding:8px 12px;background:var(--surf);
|
||||
font-size:10px;color:var(--amber-dim);text-align:center;border-radius:0 0 4px 4px
|
||||
}
|
||||
|
||||
/* ── Animations ─────────────────────────────────────── */
|
||||
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||||
.anim{animation:fadeUp 0.4s ease-out forwards;opacity:0}
|
||||
.anim.d1{animation-delay:0.05s}.anim.d2{animation-delay:0.1s}
|
||||
.anim.d3{animation-delay:0.15s}.anim.d4{animation-delay:0.2s}
|
||||
.anim.d5{animation-delay:0.25s}.anim.d6{animation-delay:0.3s}
|
||||
.anim.d7{animation-delay:0.35s}.anim.d8{animation-delay:0.4s}
|
||||
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.anim{animation-duration:0.15s}
|
||||
.mosq-box .b{animation:none}
|
||||
@keyframes blink{0%,100%{opacity:1}}
|
||||
}
|
||||
|
||||
@media(max-width:480px){
|
||||
body{padding:10px;font-size:13px}
|
||||
.term-body{padding:14px 12px 10px}
|
||||
.kpi-grid .v{font-size:22px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container anim">
|
||||
<!-- Terminal Window -->
|
||||
<div class="term-bar">
|
||||
<div class="term-title">
|
||||
<span class="name">🌆 rhino-terminal — evening-stand-down</span>
|
||||
<div class="dots">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<div class="prompt-line"><span class="ps1">rhino</span><span class="ps2">@</span><span class="ps1">jacket</span><span class="ps2">:~$ </span><span id="greetingCmd">./briefing --mode=stand-down</span><span class="blink-cursor"></span></div>
|
||||
|
||||
<div class="header-content anim d1">
|
||||
<div class="greeting" id="greetingLine">Evening Stand-Down</div>
|
||||
<div class="date" id="dateLine">Loading…</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
|
||||
<div class="lin">─── <sp>tap each line</sp> to read the briefing ───</div>
|
||||
<div id="progressLabel" style="display:none;font-size:10px;color:var(--amber-dim);background:var(--card);padding:2px 8px;border:1px solid var(--amber-faint)"><span id="progressCount">0/7</span> <span id="progressBar">sections</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Cards -->
|
||||
<div id="cards" class="anim d2"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="term-footer">
|
||||
rhino@jacket:~$ <span id="footerCmd">last brief</span><span class="blink-cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const QUOTES=[
|
||||
"You're not looking for a job — you're curating your next chapter.",
|
||||
"Every application is a data point. The more you send, the tighter your signal.",
|
||||
"The job you want is also looking for you. Keep showing up.",
|
||||
"Consistency beats intensity. You showed up today. That's a win.",
|
||||
"Your resume is a story. Every role you apply to is a new audience.",
|
||||
"The market is noisy. Your signal is the green jacket. Stand out.",
|
||||
"Rejection is redirection. Every no is one closer to the right yes.",
|
||||
"You've built brands. Now build your own narrative.",
|
||||
"The rhino doesn't rush. The rhino is patient. The rhino gets results.",
|
||||
"Three applications this week is three conversations you didn't have last week.",
|
||||
];
|
||||
function gQ(d){return QUOTES[d%QUOTES.length]}
|
||||
|
||||
const MOSQ=[
|
||||
"The mosquito was seen loitering near the database server. The jacket is monitoring.",
|
||||
"🦟 Radar contact lost over the cron scheduler. Presumed plotting something.",
|
||||
"The mosquito has been spotted attempting to learn Python. Threat level: amused.",
|
||||
"Mosquito activity detected around port 9099. The jacket installed a tiny bug zapper.",
|
||||
"The mosquito issued a manifesto. Three words: 'buzz buzz buzz'. We're watching.",
|
||||
"No mosquito sightings today. Either planning something big, or it took a day off.",
|
||||
"The mosquito tried to submit a PR. Denied for lack of credentials.",
|
||||
"SIGINT suggests the mosquito is recruiting. The jacket is building countermeasures.",
|
||||
"The mosquito sent a cease-and-desist. The jacket laughed. It never stops.",
|
||||
"Mosquito attempted to file a bug report. Closed: 'working as intended'.",
|
||||
"Agent Mosquito last seen heading toward the Tailscale tunnel. Perimeter secure.",
|
||||
"The mosquito has been quiet. Too quiet. The jacket is not fooled.",
|
||||
];
|
||||
function gM(d){return MOSQ[d%MOSQ.length]}
|
||||
|
||||
const RHINO=[
|
||||
"The jacket and I reviewed your week. You're making progress, even when it doesn't feel like it. Keep going.",
|
||||
"I've consulted the pipe. The pipe says: 'You've got this.' The pipe is rarely wrong.",
|
||||
"A gentle reminder from the Monocled Menace: the right role won't find you unless you're putting yourself out there. You are.",
|
||||
"Three things the jacket knows: (1) you're capable, (2) you're consistent, (3) you look good in green.",
|
||||
"The rhino does not panic. The rhino adapts. Today you adapted. That's growth.",
|
||||
"One does not simply apply to jobs — one curates one's destiny. You curated today. The jacket approves.",
|
||||
"You are not behind. You are exactly on time for where you need to be.",
|
||||
"I was once mistaken for a very distinguished armchair. I sat with it. Now I'm telling you: sit with patience. It pays off.",
|
||||
"The green jacket has seen many campaigns. This one's not over yet.",
|
||||
"You applied to roles that didn't exist six months ago. You're ahead and don't know it.",
|
||||
];
|
||||
function gR(d){return RHINO[d%RHINO.length]}
|
||||
|
||||
function wi(c){c=(c||'').toLowerCase()
|
||||
if(c.includes('sun')||c.includes('clear'))return'☀️';if(c.includes('rain')||c.includes('drizzle')||c.includes('shower'))return'🌧️'
|
||||
if(c.includes('cloud')||c.includes('overcast'))return'☁️';if(c.includes('fog')||c.includes('mist'))return'🌫️'
|
||||
if(c.includes('thunder')||c.includes('storm'))return'⛈️';if(c.includes('wind'))return'💨';return'🌤️'}
|
||||
function es(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
/* ── Typewriter Effect ───────────────────────────────── */
|
||||
function typewrite(el,text,speed=35,cb){
|
||||
let i=0;el.textContent='';const iv=setInterval(()=>{
|
||||
el.textContent+=text[i++];if(i>=text.length){clearInterval(iv);if(cb)cb()}
|
||||
},speed)
|
||||
}
|
||||
|
||||
function typewriteFull(text,speed,cb){
|
||||
const el=document.getElementById('greetingCmd');el.textContent='';
|
||||
typewrite(el,text,speed,cb)
|
||||
}
|
||||
|
||||
/* ── Animated Counters ────────────────────────────────── */
|
||||
function ani(el,t){
|
||||
const d=800,s=performance.now();
|
||||
function f(n){
|
||||
const p=Math.min((n-s)/d,1),e=1-Math.pow(1-p,3);
|
||||
el.textContent=Math.floor(0+(t-0)*e);
|
||||
if(p<1)requestAnimationFrame(f)
|
||||
}
|
||||
requestAnimationFrame(f)
|
||||
}
|
||||
|
||||
/* ── Section Counter ──────────────────────────────────── */
|
||||
let readCount=0;const totalSections=7;
|
||||
function updateProgress(){
|
||||
readCount++;
|
||||
const el=document.getElementById('progressCount');
|
||||
if(el)el.textContent=`${readCount}/${totalSections}`;
|
||||
// Check if all read
|
||||
if(readCount===totalSections){
|
||||
setTimeout(()=>{
|
||||
const bar=document.getElementById('progressBar');
|
||||
if(bar)bar.textContent='✓ all sections read';
|
||||
},400)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tap Ripple ────────────────────────────────────────── */
|
||||
function ripple(e){
|
||||
const el=e.currentTarget;
|
||||
const r=document.createElement('span');
|
||||
r.style.cssText=`position:absolute;pointer-events:none;border-radius:50%;
|
||||
background:rgba(255,176,0,0.08);transform:scale(0);animation:rippleAnim 0.5s ease-out forwards;
|
||||
width:${Math.max(el.offsetWidth,el.offsetHeight)}px;
|
||||
height:${Math.max(el.offsetWidth,el.offsetHeight)}px;
|
||||
left:50%;top:50%;margin-left:-${Math.max(el.offsetWidth,el.offsetHeight)/2}px;
|
||||
margin-top:-${Math.max(el.offsetWidth,el.offsetHeight)/2}px;z-index:0`;
|
||||
el.appendChild(r);
|
||||
// Style the label to be above ripple
|
||||
setTimeout(()=>r.remove(),500)
|
||||
}
|
||||
|
||||
/* ── Build Section Card ────────────────────────────────── */
|
||||
function mkSect(icon,title,contentFn,delay,dataObj){
|
||||
const div=document.createElement('div');div.className=`sect anim d${delay}`;
|
||||
div.innerHTML=`<div class="sect-head"><span class="ico">${icon}</span>
|
||||
<span class="lb">${es(title)}</span><span class="ar">▾</span></div>
|
||||
<div class="sect-body"><div class="sect-inner"></div></div>`;
|
||||
const h=div.querySelector('.sect-head'),b=div.querySelector('.sect-inner');let opened=false;
|
||||
h.addEventListener('click',(e)=>{
|
||||
const wasOpen=div.classList.contains('open');
|
||||
if(wasOpen){div.classList.remove('open');return}
|
||||
// Close others
|
||||
document.querySelectorAll('.sect.open').forEach(c=>c.classList.remove('open'));
|
||||
div.classList.add('open');
|
||||
ripple(e);
|
||||
if(!opened){opened=true;contentFn(dataObj,b,div)}
|
||||
updateProgress();
|
||||
});
|
||||
return div
|
||||
}
|
||||
|
||||
/* ── Content Renderers ────────────────────────────────── */
|
||||
function rRecap(d,b,section){
|
||||
const j=d.jobs||{};const s=j.status_breakdown||{};const t=Object.values(s).reduce((a,v)=>a+v,0)||0;
|
||||
b.innerHTML=`
|
||||
<div class="kpi-grid">
|
||||
<div><div class="v" style="color:var(--amber-bright)"><sp1 class="c-n">0</sp1></div><div class="l">New Today</div></div>
|
||||
<div><div class="v" style="color:var(--green)"><sp2 class="c-s">0</sp2></div><div class="l">Strong</div></div>
|
||||
<div><div class="v" style="color:var(--red)"><sp3 class="c-u">0</sp3></div><div class="l">Urgent</div></div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:11px;color:var(--amber-dim);margin-top:6px">${t} roles tracked · ${j.this_week||0} this week</div>`;
|
||||
setTimeout(()=>{ani(b.querySelector('.c-n'),j.today_added||0);ani(b.querySelector('.c-s'),j.strong_matches||0);ani(b.querySelector('.c-u'),j.urgent_closing||0)},150)
|
||||
}
|
||||
|
||||
function rRoles(d,b){
|
||||
const r=(d.jobs&&d.jobs.today_roles)||[];
|
||||
if(!r.length){b.innerHTML=`<div class="empty">⏳ No new matches today</div>`;return}
|
||||
const sal=d.jobs.salary_range;
|
||||
let h='';if(sal)h+=`<div class="arrow-join" style="font-size:11px;color:var(--amber-dim);margin-bottom:4px">salary range: $${(sal.min/1000).toFixed(0)}k – $${(sal.max/1000).toFixed(0)}k</div>`;
|
||||
r.forEach(o=>{
|
||||
const nm=es(o.name);
|
||||
const link=o.url?`<a href="${es(o.url)}" target="_blank" rel="noopener" style="color:var(--amber-bright);text-decoration:none;border-bottom:1px dashed var(--amber-faint)">${nm}</a>`:nm;
|
||||
h+=`<div class="role-line"><div class="co">${link}</div><div class="ti">${es(o.role)}</div><div class="tg">${o.salary?`<sp class="sal">$${(o.salary/1000).toFixed(0)}k</sp>`:''}<sp class="src">${es(o.source)}</sp></div></div>`
|
||||
});
|
||||
b.innerHTML=h
|
||||
}
|
||||
|
||||
function rClosing(d,b){
|
||||
const list=(d.jobs&&d.jobs.closing_soon)||[];
|
||||
if(!list.length){b.innerHTML=`<div class="empty">✓ No upcoming deadlines</div>`;return}
|
||||
const now=new Date();
|
||||
let h='';
|
||||
list.forEach(j=>{
|
||||
const cd=j.closing_date;let days='',badge='neu';
|
||||
if(cd){
|
||||
const dt=new Date(cd+'T23:59:59');
|
||||
const diff=Math.ceil((dt-now)/86400000);
|
||||
if(diff<0){days='past due';badge='err'}
|
||||
else if(diff===0){days='closes today';badge='err'}
|
||||
else if(diff===1){days='closes tomorrow';badge='err'}
|
||||
else if(diff<=3){days=`${diff} days`;badge='err'}
|
||||
else if(diff<=7){days=`${diff} days`;badge='err'}
|
||||
else days=`${diff} days`;
|
||||
}
|
||||
const s=j.salary?`<sp class="sal">$${(j.salary/1000).toFixed(0)}k</sp>`:'';
|
||||
const emoji=badge==='err'?'🔴':'⚠️';
|
||||
const nm=es(j.name);
|
||||
const roleName=es(j.role);
|
||||
const link=j.url?`<a href="${es(j.url)}" target="_blank" rel="noopener" style="color:var(--amber-bright);text-decoration:none;border-bottom:1px dashed var(--amber-faint)">${nm}</a>`:nm;
|
||||
h+=`<div class="role-line"><div class="co">${emoji} ${link}</div>
|
||||
<div class="ti">${roleName}${cd?` · closes ${cd}`:''}</div>
|
||||
<div class="tg">${days?`<sp class="${badge}">${days}</sp>`:''}${s}${j.strong_match?`<sp class="ok">★ strong</sp>`:''}</div></div>`;
|
||||
});
|
||||
b.innerHTML=h;
|
||||
if(list.length>5)b.innerHTML+=`<div style="font-size:10px;color:var(--amber-dim);margin-top:4px;text-align:center">${list.length} closing tracked · <a href="/jobs/" style="color:var(--amber);text-decoration:underline;text-underline-offset:2px">view all on dashboard →</a></div>`
|
||||
}
|
||||
|
||||
function rWeather(d,b){
|
||||
const w=d.weather||{};
|
||||
if(w.error){b.innerHTML=`<div class="empty">weather offline</div>`;return}
|
||||
b.innerHTML=`
|
||||
<div class="w-row">
|
||||
<div class="lft"><span style="font-size:28px">${wi(w.condition)}</span><div><div class="t">${w.temp_c}°</div><div class="c">${w.condition}</div></div></div>
|
||||
<div class="rgt"><div class="b">${w.tomorrow_high}° / ${w.tomorrow_low}°</div><div>${wi(w.tomorrow_condition)} ${w.tomorrow_condition}</div></div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--amber-dim);margin-top:4px">wind ${w.wind_kmh}km/h · humidity ${w.humidity}%</div>`
|
||||
}
|
||||
|
||||
function rHealth(d,b){
|
||||
const c=d.cron_health||{};if(c.error){b.innerHTML=`<div class="empty">health unavailable</div>`;return}
|
||||
const e=c.errors||0
|
||||
b.innerHTML=`<div class="wdg">${e>0?`<sp class="err">⚠ ${e} error${e!==1?'s':''}</sp>`:`<sp class="ok">✓ all healthy</sp>`}<sp class="neu">${c.recent_runs||0} runs</sp>${(c.running||0)>0?`<sp class="err">⟳ ${c.running} running</sp>`:''}</div>`
|
||||
}
|
||||
|
||||
function rMosq(d,b){
|
||||
const day=Math.floor((Date.now()-new Date(new Date().getFullYear(),0,0))/86400000);
|
||||
b.innerHTML=`<div class="mosq-box"><div class="b">🦟</div><div class="m">"${gM(day)}"</div><div class="z">Green Jacket Society · est. 2026</div></div>`
|
||||
}
|
||||
|
||||
function rRhino(d,b){
|
||||
const day=Math.floor((Date.now()-new Date(new Date().getFullYear(),0,0))/86400000);
|
||||
b.innerHTML=`<div class="rhn-box"><div class="i">🚬</div><div class="m">"${gR(day)}"</div><div class="s">— The Rhino in the Green Jacket</div></div>`
|
||||
}
|
||||
|
||||
/* ── Boot ───────────────────────────────────────────────── */
|
||||
async function load(){
|
||||
const today=new Date().toISOString().slice(0,10);
|
||||
let r=await fetch(`/evening/${today}.json`).catch(()=>null);
|
||||
if((!r||!r.ok))r=await fetch('/evening/latest.json').catch(()=>null);
|
||||
if(!r||!r.ok){
|
||||
document.getElementById('cards').innerHTML=`
|
||||
<div style="text-align:center;padding:40px 20px;border:1px solid var(--amber-faint);margin-top:6px">
|
||||
<div style="font-size:36px;margin-bottom:8px;opacity:0.3">🌆</div>
|
||||
<div style="font-size:13px;color:var(--amber-dim)">no briefing yet today</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">check back after 18:45 AWST</div>
|
||||
</div>`;
|
||||
return
|
||||
}
|
||||
const d=await r.json();const dt=new Date(d.timestamp);
|
||||
const dateStr=dt.toLocaleDateString('en-AU',{day:'numeric',month:'long',year:'numeric'});
|
||||
const dayName=dt.toLocaleDateString('en-AU',{weekday:'long'});
|
||||
|
||||
// Typewriter the command line, then reveal content
|
||||
const cmd=`./briefing --mode=stand-down --date=${d.timestamp.slice(0,10)}`;
|
||||
typewriteFull(cmd,25,()=>{
|
||||
document.getElementById('greetingLine').textContent=`Evening Stand-Down — ${dayName}`;
|
||||
document.getElementById('dateLine').textContent=`${dateStr} AWST`;
|
||||
document.getElementById('progressLabel').style.display='';
|
||||
document.getElementById('footerCmd').textContent=`last brief — ${dt.toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short'})}`;
|
||||
});
|
||||
|
||||
const cards=document.getElementById('cards');
|
||||
const sections=[
|
||||
['📊','recap — today\'s numbers',rRecap,1],
|
||||
['🆕','new matches — today\'s additions',rRoles,2],
|
||||
['⚠️','closing soon — urgent deadlines',rClosing,3],
|
||||
['🌤️','weather — perth forecast',rWeather,4],
|
||||
['⚙️','health — homelab pulse',rHealth,5],
|
||||
['🦟','mosquito report — threat brief',rMosq,6],
|
||||
['🚬','rhino\'s corner — words of wisdom',rRhino,7],
|
||||
];
|
||||
sections.forEach(([i,t,fn,dl])=>cards.appendChild(mkSect(i,t,fn,dl,d)))
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
184
evening_standdown.py
Normal file
184
evening_standdown.py
Normal file
@@ -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()
|
||||
232
index.html
Normal file
232
index.html
Normal file
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Job Tracker · Anthony</title>
|
||||
<script>
|
||||
const API_BASE = (window.location.pathname.replace(/\/index\.html$/,'').replace(/\/+$/,'')) || '';
|
||||
const API = { jobs:API_BASE+'/api/jobs', stats:API_BASE+'/api/stats', refresh:API_BASE+'/api/refresh', update:(id)=>API_BASE+'/api/jobs/'+id+'/update', bulk:API_BASE+'/api/jobs/bulk-update', clRegen:(id)=>API_BASE+'/api/jobs/'+id+'/cover-letter/regenerate' };
|
||||
const STATUSES = ['Backlog','Applied','Phone Screen','Interview','Offer','Accepted','Rejected','Ghosted','Declined'];
|
||||
const PRIOS = ['','Low','Medium','High','Urgent'];
|
||||
const SCOLORS = { 'Backlog':'#64748b','Applied':'#3b82f6','Phone Screen':'#8b5cf6','Interview':'#f59e0b','Offer':'#22c55e','Accepted':'#16a34a','Rejected':'#ef4444','Ghosted':'#78716c','Declined':'#6b7280' };
|
||||
const SORDER = ['Backlog','Applied','Phone Screen','Interview','Offer','Accepted','Rejected','Ghosted','Declined'];
|
||||
let state = { jobs:[], stats:{}, filtered:[], selected:new Set(), filter:'all', search:'', sort:'date-desc' };
|
||||
|
||||
function notify(m,t) {
|
||||
const e=document.getElementById('n');
|
||||
e.textContent=m; e.className='n '+(t||'ok'); e.style.display='block';
|
||||
setTimeout(()=>e.style.display='none',3500);
|
||||
}
|
||||
async function f(url,opts) {
|
||||
try { const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opts}); return await r.json(); }
|
||||
catch(e){ notify('API error: '+e.message,'err'); return null; }
|
||||
}
|
||||
async function load() {
|
||||
const [jd,sd]=await Promise.all([f(API.jobs,{}),f(API.stats,{})]);
|
||||
if(jd&&jd.jobs) state.jobs=jd.jobs;
|
||||
if(sd) state.stats=sd;
|
||||
filter(); render();
|
||||
}
|
||||
async function upd(id,up) { const r=await f(API.update(id),{method:'POST',body:JSON.stringify(up)}); if(r&&r.ok){notify('Saved','ok');load();}else notify('Save failed','err'); }
|
||||
async function bulk(ids,up) { if(!ids.length)return; const r=await f(API.bulk,{method:'POST',body:JSON.stringify({ids,updates:up})}); if(r&&r.ok){notify('Updated '+(r.results?r.results.filter(x=>x.ok).length+' jobs':'')+'','ok');load();} }
|
||||
async function refresh() { const r=await f(API.refresh,{method:'POST'}); if(r&&r.ok){notify('Refreshed — '+r.total+' jobs','ok');load();} }
|
||||
|
||||
function filter() {
|
||||
let fi=[...state.jobs];
|
||||
if(state.filter==='__urgent'){ fi=fi.filter(j=>j.closing_status==='urgent'); }
|
||||
else if(state.filter==='__past'){ fi=fi.filter(j=>j.closing_status==='past'); }
|
||||
else if(state.filter==='__strong'){ fi=fi.filter(j=>j.strong_match); }
|
||||
else if(state.filter!=='all') fi=fi.filter(j=>j.status===state.filter);
|
||||
if(state.search){ const q=state.search.toLowerCase(); fi=fi.filter(j=>(j.name+(j.role||'')+(j.location||'')+(j.notes||'')).toLowerCase().includes(q)); }
|
||||
const[field,dir]=state.sort.split('-');
|
||||
fi.sort((a,b)=>{ let va,vb; switch(field){
|
||||
case'date': va=a.date_added||'0'; vb=b.date_added||'0'; break;
|
||||
case'days': va=a.days_since||0; vb=b.days_since||0; break;
|
||||
case'name': va=(a.name||'').toLowerCase(); vb=(b.name||'').toLowerCase(); break;
|
||||
case'salary': va=a.salary||0; vb=b.salary||0; break;
|
||||
default: va=a.date_added||'0'; vb=b.date_added||'0';
|
||||
} return va<vb?dir==='asc'?-1:1:va>vb?dir==='asc'?1:-1:0; });
|
||||
state.filtered=fi; state.selected=new Set();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const st=state.stats||{};
|
||||
document.getElementById('kt').textContent=st.total||0; document.getElementById('km').textContent=st.strong_matches||0;
|
||||
document.getElementById('kw').textContent=st.this_week||0; document.getElementById('ks').textContent=st.stale_14d||0;
|
||||
document.getElementById('ku').textContent=st.closing_urgent||0; document.getElementById('kp').textContent=st.closing_past||0;
|
||||
document.getElementById('kr').textContent=st.refreshed_at?new Date(st.refreshed_at).toLocaleString('en-AU',{timeZone:'Australia/Perth'}):'-';
|
||||
const total=state.jobs.length||1, counts={}; state.jobs.forEach(j=>{counts[j.status]=(counts[j.status]||0)+1;});
|
||||
document.getElementById('funnel').innerHTML=SORDER.map(s=>{const c=counts[s]||0; if(!c)return''; return'<div class="fi"><div class="fl"><span>'+s+'</span><span class="fc">'+c+'</span></div><div class="ftr"><div class="ff" style="width:'+(c/total*100).toFixed(0)+'%;background:'+(SCOLORS[s]||'#64748b')+'"></div></div></div>';}).join('');
|
||||
const tb=document.getElementById('tb');
|
||||
if(!state.filtered.length){ tb.innerHTML='<tr><td colspan="11" style="text-align:center;padding:48px 0;color:#64748b">No jobs match this filter</td></tr>'; return; }
|
||||
tb.innerHTML=state.filtered.map(j=>{
|
||||
const sel=state.selected.has(j.id)?'checked':'';
|
||||
const sCol=SCOLORS[j.status]||'#64748b', sal=j.salary?'$'+Number(j.salary).toLocaleString():'—', age=(j.days_since||0)+'d';
|
||||
const stale=(j.days_since||0)>14?'stale-14':(j.days_since||0)>7?'stale-7':'';
|
||||
// Closing date urgency
|
||||
let closeHtml = '—', clsCls = '';
|
||||
if(j.closing_date){
|
||||
const cd=new Date(j.closing_date);
|
||||
const now=new Date();
|
||||
const days=Math.round((cd-now)/86400000);
|
||||
const ds=j.closing_date.split('-');
|
||||
const fmt=ds[2]+'/'+ds[1];
|
||||
closeHtml=fmt;
|
||||
if(j.closing_status==='past'){ clsCls='cls-past'; }
|
||||
else if(j.closing_status==='urgent'){ clsCls='cls-urgent'; }
|
||||
else if(j.closing_status==='warning'){ clsCls='cls-warn'; }
|
||||
}
|
||||
const rowCls=[stale,clsCls].filter(Boolean).join(' ');
|
||||
const name=j.url?'<a href="'+j.url+'" target="_blank">'+j.name+'</a>':j.name;
|
||||
const mb=j.strong_match?'<span class="badge badge-sm">★</span>':'';
|
||||
return '<tr class="'+rowCls+'" data-id="'+j.id+'"><td class="c-chk"><input type="checkbox" '+sel+' onchange="tog(\''+j.id+'\')"></td><td class="c-co">'+name+mb+'</td><td class="c-ro">'+(j.role||"—")+'</td><td class="c-st"><span class="stat-pill" style="background:'+sCol+'18;color:'+sCol+';border-color:'+sCol+'40" onclick="showMenu(event,\''+j.id+'\',\''+j.status+'\')">'+j.status+'</span></td><td class="c-lo">'+(j.location||"—")+'</td><td class="c-sr">'+(j.source||"—")+'</td><td class="c-sa">'+sal+'</td><td class="c-cls">'+closeHtml+'</td><td class="c-ag">'+age+'</td><td class="c-cl">'+(j.cover_letter_url?'<a href="'+j.cover_letter_url+'" target="_blank" class="cl-link" title="View cover letter">📝</a><button class="cl-rg" onclick="regenerateCL(\''+j.id+'\')" title="Regenerate">🔄</button>':'<button class="cl-rg" onclick="regenerateCL(\''+j.id+'\')" title="Generate cover letter">+CL</button>')+'</td><td class="c-no"><button class="nb" onclick="editNote(\''+j.id+'\')">'+(j.notes?'✎':'+')+'</button></td></tr>';
|
||||
}).join('');
|
||||
document.getElementById('rc').textContent=state.filtered.length+' of '+state.jobs.length;
|
||||
document.getElementById('bc').textContent=state.selected.size;
|
||||
document.getElementById('bb').style.display=state.selected.size?'flex':'none';
|
||||
}
|
||||
|
||||
function tog(id){ state.selected.has(id)?state.selected.delete(id):state.selected.add(id); render(); }
|
||||
function selA(){ state.filtered.forEach(j=>state.selected.add(j.id)); render(); }
|
||||
function desel(){ state.selected.clear(); render(); }
|
||||
function setFilter(f){
|
||||
state.filter=f;
|
||||
// Update tab active states
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('a',t.dataset.f===f));
|
||||
// Update KPI card active states
|
||||
document.getElementById('kf-strong').style.borderColor=f==='__strong'?'var(--cy)':'';
|
||||
document.getElementById('kf-urgent').style.borderColor=f==='__urgent'?'var(--cy)':'#f59e0b';
|
||||
document.getElementById('kf-past').style.borderColor=f==='__past'?'var(--cy)':'#64748b';
|
||||
filter(); render();
|
||||
}
|
||||
function doSearch(){ state.search=document.getElementById('s').value; filter(); render(); }
|
||||
function setSort(s){ state.sort=s; document.querySelectorAll('.sort-btn').forEach(b=>b.classList.toggle('a',b.dataset.s===s)); filter(); render(); }
|
||||
function applyBulk(){ const v=document.getElementById('bs').value; bulk([...state.selected],{status:v}); }
|
||||
function showMenu(ev,id,cur){
|
||||
const m=document.getElementById('sm');
|
||||
m.innerHTML=STATUSES.map(s=>'<div class="sm-item'+(s===cur?' sm-cur':'')+'" onclick="changeStat(\''+id+'\',\''+s+'\')">'+s+'</div>').join('');
|
||||
m.style.display='block'; m.style.left=Math.min(ev.clientX,window.innerWidth-200)+'px'; m.style.top=ev.clientY+'px';
|
||||
setTimeout(()=>document.addEventListener('click',function(){m.style.display='none';},{once:true}),10);
|
||||
}
|
||||
async function changeStat(id,s){ await upd(id,{status:s}); document.getElementById('sm').style.display='none'; }
|
||||
function editNote(id){ const j=state.jobs.find(x=>x.id===id); const n=prompt('Edit notes / JD summary:',j&&j.notes?j.notes:''); if(n!==null) upd(id,{notes:n}); }
|
||||
|
||||
async function regenerateCL(id){
|
||||
const j=state.jobs.find(x=>x.id===id);
|
||||
const hasCL=j&&j.cover_letter_url;
|
||||
const defaultFB=hasCL?'Make it stronger':'Generate a professional cover letter for this role';
|
||||
const pText=hasCL?'How should the cover letter change? (e.g. "focus on CRM experience", "more professional tone"):':'What should the cover letter emphasise? (e.g. "highlight budget management", "reference government experience"):';
|
||||
const fb=prompt(pText,defaultFB);
|
||||
if(!fb)return;
|
||||
const btn=event&&event.target||document.querySelector('[onclick*="'+id+'"]');
|
||||
if(btn)btn.textContent='...';
|
||||
const r=await f(API.clRegen(id),{method:'POST',body:JSON.stringify({feedback:fb})});
|
||||
if(r&&r.ok){ notify('Cover letter '+(hasCL?'regenerated':'generated')+' ✅','ok'); load(); }
|
||||
else{ notify('Generation failed: '+(r&&r.error||'unknown error'),'err'); if(btn)btn.textContent=hasCL?'🔄':'+CL'; }
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
// Theme init
|
||||
if(localStorage.getItem('theme')==='light'){ document.documentElement.classList.add('light'); document.getElementById('themeBtn').textContent='🌙'; }
|
||||
load(); setInterval(load,120000);
|
||||
});
|
||||
function toggleTheme(){
|
||||
const h=document.documentElement; const b=document.getElementById('themeBtn');
|
||||
h.classList.toggle('light');
|
||||
const isLight=h.classList.contains('light');
|
||||
b.textContent=isLight?'🌙':'☀️';
|
||||
localStorage.setItem('theme',isLight?'light':'dark');
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root{--bg:#0b1120;--srf:#141d30;--srf2:#1e2a45;--bdr:#1e3050;--txt:#e2e8f0;--m:#64748b;--cy:#22d3ee;--gr:#22c55e;--am:#f59e0b;--rd:#ef4444;--cz:#0b1120;--r-hover:rgba(34,211,238,0.02);--r-td:rgba(30,46,80,0.3)}
|
||||
.light{--bg:#f8fafc;--srf:#ffffff;--srf2:#f1f5f9;--bdr:#e2e8f0;--txt:#0f172a;--cy:#0891b2;--cz:#ffffff;--r-hover:rgba(8,145,178,0.04);--r-td:rgba(15,23,42,0.06)}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--txt);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;transition:background .25s,color .25s}
|
||||
.c{max-width:1440px;margin:0 auto;padding:16px}
|
||||
.h{display:flex;justify-content:space-between;align-items:center;padding:8px 0 16px;border-bottom:1px solid var(--bdr);margin-bottom:20px;flex-wrap:wrap;gap:8px}
|
||||
.h h1{font-size:18px;font-weight:700;color:var(--txt)}
|
||||
.h .sub{font-size:12px;color:var(--m);margin-top:2px}
|
||||
.h .rh{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--m)}
|
||||
.h .rh button{background:var(--srf2);color:var(--m);border:1px solid var(--srf2);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:11px;font-weight:500}
|
||||
.h .rh button:hover{border-color:var(--cy);color:var(--cy)}
|
||||
.n{display:none;padding:10px 14px;border-radius:6px;margin-bottom:14px;font-size:13px}
|
||||
.n.ok{background:rgba(34,197,94,0.12);color:var(--gr);border:1px solid rgba(34,197,94,0.2)}
|
||||
.n.err{background:rgba(239,68,68,0.12);color:var(--rd);border:1px solid rgba(239,68,68,0.2)}
|
||||
.k{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-bottom:18px}
|
||||
.kc{background:var(--srf);border-radius:8px;padding:14px;border:1px solid var(--bdr)}
|
||||
.kc .kv{font-size:26px;font-weight:700;letter-spacing:-0.02em;line-height:1.1}
|
||||
.kc .kl{font-size:11px;color:var(--m);margin-top:3px;font-weight:500}
|
||||
.kc.cy .kv{color:var(--cy)} .kc.gr .kv{color:var(--gr)} .kc.am .kv{color:var(--am)} .kc.rd .kv{color:var(--rd)}
|
||||
.fu{background:var(--srf);border-radius:8px;padding:16px;margin-bottom:18px;border:1px solid var(--bdr)}
|
||||
.fi{margin-bottom:5px} .fl{display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px} .fc{color:var(--m)}
|
||||
.ftr{height:5px;background:var(--srf2);border-radius:3px;overflow:hidden} .ff{height:100%;border-radius:3px}
|
||||
.bulk{display:none;align-items:center;gap:10px;padding:8px 14px;background:rgba(34,211,238,0.06);border:1px solid rgba(34,211,238,0.15);border-radius:6px;margin-bottom:12px;font-size:12px}
|
||||
.bulk select{background:var(--srf2);color:var(--txt);border:1px solid var(--bdr);padding:4px 8px;border-radius:4px;font-size:12px}
|
||||
.bulk .b-btn{background:var(--cy);color:var(--cz);border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600}
|
||||
.bulk .b-btn2{background:var(--srf2);color:var(--m);border:1px solid var(--bdr);padding:5px 12px;border-radius:4px;cursor:pointer;font-size:11px}
|
||||
.ct{display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center}
|
||||
.tabs{display:flex;gap:3px;flex-wrap:wrap}
|
||||
.tab{padding:5px 14px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;background:var(--srf);color:var(--m);border:1px solid transparent}
|
||||
.tab:hover{color:var(--txt)} .tab.a{background:var(--cy);color:var(--cz)}
|
||||
.sbox{background:var(--srf);border:1px solid var(--bdr);padding:5px 10px;border-radius:6px;color:var(--txt);font-size:12px;width:180px}
|
||||
.sbox:focus{outline:none;border-color:var(--cy)}
|
||||
.sg{display:flex;gap:2px;margin-left:auto}
|
||||
.sort-btn{padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;color:var(--m);border:1px solid transparent}
|
||||
.sort-btn:hover{color:var(--txt)} .sort-btn.a{border-color:var(--cy);color:var(--cy)}
|
||||
.tw{background:var(--srf);border-radius:8px;overflow:hidden;border:1px solid var(--bdr)}
|
||||
.th{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--bdr)}
|
||||
.th h2{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--m)}
|
||||
.th .rc{color:var(--m);font-size:11px}
|
||||
.ts{overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse;font-size:12px}
|
||||
th{text-align:left;padding:8px 10px;font-weight:600;color:var(--m);font-size:10px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--bdr);position:sticky;top:0;background:var(--srf);white-space:nowrap}
|
||||
td{padding:6px 10px;border-bottom:1px solid var(--r-td);vertical-align:middle}
|
||||
tr:hover td{background:var(--r-hover)}
|
||||
.stale-14 td{opacity:.65;color:var(--rd)} .stale-7 td{opacity:.8;color:var(--am)}
|
||||
/* Closing date urgency */
|
||||
.cls-past td{opacity:0.4}
|
||||
.cls-urgent .c-cls{color:var(--rd);font-weight:700}
|
||||
.cls-warn .c-cls{color:var(--am)}
|
||||
.c-chk{width:28px} .c-co{min-width:160px} .c-co a{color:var(--cy);text-decoration:none;font-weight:500}
|
||||
.c-co a:hover{text-decoration:underline} .c-ro{min-width:170px;color:var(--m)} .c-lo{min-width:100px;color:var(--m)}
|
||||
.c-sr{min-width:65px;color:var(--m)} .c-sa{text-align:right;white-space:nowrap} .c-cls{text-align:right;white-space:nowrap;font-size:11px}
|
||||
.c-ag{text-align:right;white-space:nowrap;color:var(--m)}
|
||||
.c-no{width:30px;text-align:center}
|
||||
.c-cl{width:55px;text-align:center;white-space:nowrap}
|
||||
.cl-link{text-decoration:none;font-size:15px;cursor:pointer;margin-right:4px}
|
||||
.cl-link:hover{filter:brightness(1.3)}
|
||||
.cl-rg{background:none;border:none;color:var(--m);cursor:pointer;font-size:11px;padding:2px 5px;border-radius:4px}
|
||||
.cl-rg:hover{background:var(--srf2);color:var(--cy)}
|
||||
.stat-pill{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500;cursor:pointer;border:1px solid transparent}
|
||||
.stat-pill:hover{filter:brightness(1.2)}
|
||||
.nb{background:none;border:none;color:var(--m);cursor:pointer;font-size:13px;padding:2px 4px;border-radius:3px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.nb:hover{background:var(--srf2);color:var(--txt)}
|
||||
.badge{display:inline-block;font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;margin-left:4px}
|
||||
.badge-sm{background:rgba(34,211,238,0.12);color:var(--cy)}
|
||||
.sm{display:none;position:fixed;z-index:100;background:var(--srf);border:1px solid var(--bdr);border-radius:6px;padding:3px;box-shadow:0 8px 16px rgba(0,0,0,.3);min-width:140px}
|
||||
.sm-item{padding:6px 10px;border-radius:4px;cursor:pointer;font-size:12px}
|
||||
.sm-item:hover{background:var(--srf2)} .sm-cur{background:var(--cy);color:var(--cz);font-weight:600}
|
||||
.ft{text-align:center;padding:20px 0 12px;color:var(--m);font-size:10px} .ft a{color:var(--cy);text-decoration:none}
|
||||
@media(max-width:768px){.c{padding:10px}.k{grid-template-columns:repeat(2,1fr)}.sbox{width:140px}.c-lo,.c-sr,.c-sa{display:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="c">
|
||||
<div class="h"><div><h1>Job Tracker</h1><div class="sub">Anthony Martin · Perth, WA</div></div><div class="rh"><span id="kr">—</span><button onclick="toggleTheme()" id="themeBtn" title="Toggle light/dark mode">☀️</button><button onclick="refresh()">↻ Refresh</button></div></div>
|
||||
<div id="n" class="n"></div>
|
||||
<div class="k"><div class="kc cy" onclick="setFilter('all')" style="cursor:pointer"><div class="kv" id="kt">—</div><div class="kl">Total Jobs</div></div><div class="kc gr" id="kf-strong" onclick="setFilter('__strong')" style="cursor:pointer"><div class="kv" id="km">—</div><div class="kl">Strong Matches</div></div><div class="kc am"><div class="kv" id="kw">—</div><div class="kl">This Week</div></div><div class="kc rd"><div class="kv" id="ks">—</div><div class="kl">Stale (14d+)</div></div><div class="kc" id="kf-urgent" onclick="setFilter('__urgent')" style="border-color:#f59e0b;cursor:pointer"><div class="kv" id="ku">—</div><div class="kl" style="color:var(--am)">Expiring Soon</div></div><div class="kc" id="kf-past" onclick="setFilter('__past')" style="border-color:#64748b;cursor:pointer"><div class="kv" id="kp">—</div><div class="kl" style="color:var(--m)">Past Closing</div></div></div>
|
||||
<div class="fu" id="funnel"></div>
|
||||
<div class="bulk" id="bb"><span><strong id="bc">0</strong> selected</span><span>Status:</span><select id="bs"></select><button class="b-btn" onclick="applyBulk()">Apply</button><button class="b-btn2" onclick="desel()">Clear</button></div>
|
||||
<div class="ct"><div class="tabs" id="tabs"><span class="tab a" data-f="all" onclick="setFilter('all')">All</span><span class="tab" data-f="Backlog" onclick="setFilter('Backlog')">Backlog</span><span class="tab" data-f="Applied" onclick="setFilter('Applied')">Applied</span><span class="tab" data-f="Interview" onclick="setFilter('Interview')">Interviews</span><span class="tab" data-f="Rejected" onclick="setFilter('Rejected')">Rejected</span></div>
|
||||
<input class="sbox" id="s" placeholder="Search..." oninput="doSearch()">
|
||||
<div class="sg"><span class="sort-btn a" data-s="date-desc" onclick="setSort('date-desc')">Newest</span><span class="sort-btn" data-s="days-desc" onclick="setSort('days-desc')">Stalest</span><span class="sort-btn" data-s="salary-desc" onclick="setSort('salary-desc')">Salary</span></div></div>
|
||||
<div class="tw"><div class="th"><h2>Opportunities</h2><span class="rc" id="rc">—</span></div>
|
||||
<div class="ts"><table><thead><tr><th><input type="checkbox" onchange="if(this.checked)selA();else desel();"></th><th>Company</th><th>Role</th><th>Status</th><th>Location</th><th>Source</th><th style="text-align:right">Salary</th><th style="text-align:right">Closing</th><th style="text-align:right">Age</th><th>CL</th><th></th></tr></thead>
|
||||
<tbody id="tb"><tr><td colspan="11" style="text-align:center;padding:48px 0;color:var(--m)">Loading...</td></tr></tbody></table></div></div>
|
||||
<div class="ft">The Rhino in the Green Jacket · <a href="/api/jobs">API</a></div></div>
|
||||
<div id="sm" class="sm"></div>
|
||||
<script>
|
||||
// Populate bulk status dropdown after STATUSES is defined
|
||||
document.getElementById('bs').innerHTML=STATUSES.map(s=>'<option value="'+s+'">'+s+'</option>').join('');
|
||||
</script>
|
||||
</body></html>
|
||||
406
job_dashboard_server.py
Normal file
406
job_dashboard_server.py
Normal 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()
|
||||
509
regenerate_cover_letter.py
Normal file
509
regenerate_cover_letter.py
Normal file
@@ -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"<li>{bullet}</li>\n "
|
||||
if not bullets_html:
|
||||
bullets_html = "<li>" + structured.get("opening", "")[:200] + "</li>"
|
||||
|
||||
# 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 <job_id> <feedback>"}))
|
||||
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 <notion_job_id> [feedback]")
|
||||
print(" or: python3 regenerate_cover_letter.py <path/to/cover-letter.txt>")
|
||||
sys.exit(1)
|
||||
|
||||
job_id = sys.argv[1]
|
||||
feedback = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
main(job_id, feedback)
|
||||
164
templates/cover-letter.html
Normal file
164
templates/cover-letter.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background: #f5f5f0;
|
||||
padding: 60px 20px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.page {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 80px 90px;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
|
||||
border-top: 4px solid #1a472a;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 2px solid #1a472a;
|
||||
padding-bottom: 18px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.name {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #1a472a;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.contact {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
margin-top: 4px;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.date {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 28px;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
.recipient {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 18px;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.salutation {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.body-text {
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 14px;
|
||||
text-align: justify;
|
||||
}
|
||||
.bullets {
|
||||
margin: 14px 0 14px 22px;
|
||||
padding: 0;
|
||||
}
|
||||
.bullets li {
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 8px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.bullets li::marker {
|
||||
color: #1a472a;
|
||||
}
|
||||
.closing {
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.signature {
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.actions {
|
||||
max-width: 780px;
|
||||
margin: 24px auto 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
.actions a, .actions button {
|
||||
padding: 9px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #1a472a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #143520;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #e8e8e3;
|
||||
color: #333;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #d8d8d0;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.page { padding: 40px 28px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<div class="name">Anthony Martin</div>
|
||||
<div class="contact">90 Lansdowne Road, Kensington | 0410 349 137 | anthony@martinwa.org | linkedin.com/in/anthonymartin1987</div>
|
||||
</div>
|
||||
|
||||
<div class="date">{{date}}</div>
|
||||
|
||||
<div class="recipient">
|
||||
{{recipient_block}}
|
||||
</div>
|
||||
|
||||
<div class="salutation">Dear Hiring Manager,</div>
|
||||
|
||||
<div class="body-text">{{opening}}</div>
|
||||
|
||||
<ul class="bullets">
|
||||
{{bullets}}
|
||||
</ul>
|
||||
|
||||
<div class="closing">{{closing}}</div>
|
||||
|
||||
<div class="signature">
|
||||
Sincerely,<br><br>
|
||||
Anthony Martin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn-secondary" href="/jobs">← Back to dashboard</a>
|
||||
<a class="btn-secondary" href="{{txt_url}}">Raw .txt</a>
|
||||
<button class="btn-primary" onclick="window.print()">Print / Save PDF</button>
|
||||
<a class="btn-primary" href="{{cover_letter_docx_url}}" download>⬇ Word .docx</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user