Snapshot: full project state

This commit is contained in:
2026-10-06 23:43:21 -07:00
commit 1551408fc5
37 changed files with 2827 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Application #{{ application.id }}{% endblock %}
{% block content %}
<h1>Application #{{ application.id }}</h1>
<p class="sub">{{ job.title }} · {{ job.company }} · <span class="badge b-{{ application.status }}">{{ application.status }}</span></p>
<div class="panel">
<h2 style="margin-top:0">Self-Audit (final check before send)</h2>
<table>
<tr><th>Check</th><th>Result</th><th>Detail</th></tr>
{% for c in checks %}
<tr>
<td>{{ c.item }}</td>
<td class="{{ c.result|lower }}">{{ c.result }}</td>
<td class="muted" style="font-size:12px">{{ c.detail }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">No audit run yet.</td></tr>
{% endfor %}
</table>
{% set fails = checks|selectattr('result','equalto','FAIL')|list %}
{% if fails %}<p class="fail" style="margin-top:10px">⚠ {{ fails|length }} check(s) FAILED — fix before sending.</p>{% endif %}
</div>
<div class="panel">
<h2 style="margin-top:0">Email</h2>
<p><strong>Subject:</strong> {{ application.email_subject }}</p>
<p class="muted" style="font-size:12px">Body (plain text):</p>
<pre>{{ application.email_body }}</pre>
</div>
<div class="panel">
<h2 style="margin-top:0">Tailored resume</h2>
{% if application.tailored_pdf %}
<p>
<span class="badge b-sent">One-page PDF</span>
<a class="btn" href="/app/{{ application.id }}/pdf" style="display:inline-block;margin-left:8px">&#8595; Download PDF</a>
</p>
{% endif %}
{% if tailored_text %}
<pre>{{ tailored_text }}</pre>
{% else %}
<p class="muted">No tailored resume generated.</p>
{% endif %}
</div>
<div class="panel">
<h2 style="margin-top:0">Approve &amp; Send</h2>
{% set fails = checks|selectattr('result','equalto','FAIL')|list %}
{% if job.contact_email %}
<p style="margin-top:0">Scraped contact: <strong class="mono">{{ job.contact_email }}</strong></p>
{% else %}
<p class="muted" style="margin-top:0;font-size:12px">No contact email scraped for this job — approving will just mark it approved (open the posting or email manually).</p>
{% endif %}
{% if application.status != 'approved' %}
<form method="post" action="/app/{{ application.id }}/approve" class="inline">
<button class="btn" type="submit" {% if fails %}disabled title="Fix audit FAILs first"{% endif %}>Approve &amp; Auto-Send</button>
</form>
<p class="muted" style="font-size:12px">You review first, then approve. Approving AUTO-SENDS the email to the scraped contact (if found) and marks it Sent.</p>
{% else %}
{% if job.url %}
<div class="panel-inner" style="margin-bottom:14px">
<strong>Apply via the job posting:</strong>
<a class="btn" href="{{ job.url }}" target="_blank" rel="noopener" style="display:inline-block;margin:6px 0">Open Application &#8599;</a>
<form method="post" action="/app/{{ application.id }}/apply" class="inline">
<button class="btn ghost" type="submit">Mark as Applied</button>
</form>
<p class="muted" style="font-size:12px">Most board jobs (Greenhouse/Lever/RemoteOK) are applied through the posting link, not email. Open it, submit there, then hit "Mark as Applied" to track it.</p>
</div>
{% endif %}
<form method="post" action="/app/{{ application.id }}/send" class="flex">
<input name="to_email" placeholder="email a hiring manager directly" value="{{ job.contact_email or '' }}" style="max-width:320px">
<button class="btn ghost" type="submit">Send via Email</button>
</form>
<p class="muted" style="font-size:12px">Email path is optional — only if you have a direct contact. The posting link above is the normal route.</p>
{% endif %}
</div>
<div class="panel">
<h2 style="margin-top:0">Record outcome (self-improvement)</h2>
<form method="post" action="/app/{{ application.id }}/outcome" class="flex">
<select name="stage" style="max-width:180px">
<option value="response">Response received</option>
<option value="interview">Interview scheduled</option>
<option value="offer">Offer</option>
<option value="rejected">Rejected</option>
<option value="no-response">No response</option>
</select>
<input name="notes" placeholder="notes (optional)" style="max-width:320px">
<button class="btn ghost" type="submit">Log</button>
</form>
</div>
{% endblock %}

76
templates/base.html Normal file
View File

@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Procyon{% endblock %} · Job Hunt Engine</title>
<style>
:root{--bg:#05070d;--panel:#0d1426;--panel2:#111a33;--line:#22304f;--txt:#e8f0fb;--dim:#8fa3c8;--acc:#22d3ee;--ok:#34d399;--warn:#fbbf24;--fail:#f87171;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
a{color:var(--acc);text-decoration:none}
a:hover{text-decoration:underline}
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
header .brand{font-size:20px;font-weight:700;letter-spacing:.5px}
header .brand span{color:var(--acc)}
header nav{margin-left:auto;display:flex;gap:14px}
header nav a{color:var(--dim);font-size:14px}
header nav a.active{color:var(--acc)}
.wrap{max-width:1180px;margin:0 auto;padding:22px}
h1{font-size:22px;margin:0 0 4px}
h2{font-size:17px;margin:22px 0 10px;color:var(--acc)}
.sub{color:var(--dim);font-size:13px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin:16px 0}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px}
.card .num{font-size:30px;font-weight:700}
.card .lbl{color:var(--dim);font-size:13px;text-transform:uppercase;letter-spacing:.5px}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--line);font-size:14px;vertical-align:top}
th{background:var(--panel2);color:var(--dim);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.4px}
tr:last-child td{border-bottom:none}
.badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:12px;font-weight:600}
.b-new{background:#1e293b;color:var(--dim)}
.b-scored{background:#0c2a3a;color:var(--acc)}
.b-tailored{background:#16283a;color:#7dd3fc}
.b-audited{background:#172c26;color:var(--ok)}
.b-approved{background:#1c2b1a;color:#a3e635}
.b-sent{background:#173a3a;color:#2dd4bf}
.b-fail{background:#3a1414;color:var(--fail)}
.btn{display:inline-block;background:var(--acc);color:#041018;border:none;padding:9px 16px;border-radius:8px;font-weight:700;font-size:14px;cursor:pointer}
.btn:hover{filter:brightness(1.08)}
.btn.ghost{background:transparent;color:var(--acc);border:1px solid var(--acc)}
.btn.danger{background:var(--fail);color:#1a0505}
form.inline{display:inline}
input,textarea,select{background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:7px;padding:8px 10px;font-size:14px;width:100%;margin:4px 0}
label{color:var(--dim);font-size:13px}
.flash{padding:10px 14px;border-radius:8px;margin:0 0 14px;font-size:14px}
.flash.ok{background:#0c2a1c;color:var(--ok);border:1px solid #14532d}
.flash.warn{background:#3a2d0c;color:var(--warn);border:1px solid #713f12}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;margin:12px 0}
.pass{color:var(--ok);font-weight:700}
.fail{color:var(--fail);font-weight:700}
.warn{color:var(--warn);font-weight:700}
pre{white-space:pre-wrap;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:14px;font-size:13px}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.muted{color:var(--dim)}
.flex{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
</style>
</head>
<body>
<header>
<div class="brand">✦ Procyon <span>· Job Hunt Engine</span></div>
<nav>
<a href="/" class="{% block nav_dash %}{% endblock %}">Dashboard</a>
<a href="/library" class="{% block nav_library %}{% endblock %}">Resumes</a>
<a href="/insights" class="{% block nav_insights %}{% endblock %}">Insights</a>
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Settings</a>
</nav>
</header>
<div class="wrap">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>

58
templates/dashboard.html Normal file
View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block nav_dash %}active{% endblock %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<p class="sub">Discover jobs → score → tailor → self-audit → your approval → send → track.</p>
<div class="grid">
<div class="card"><div class="num">{{ stats.total_jobs }}</div><div class="lbl">Jobs Collected</div></div>
<div class="card"><div class="num">{{ stats.new }}</div><div class="lbl">New / Unscored</div></div>
<div class="card"><div class="num">{{ stats.scored }}</div><div class="lbl">Processed</div></div>
<div class="card"><div class="num">{{ stats.sent }}</div><div class="lbl">Sent</div></div>
</div>
<div class="panel">
<h2 style="margin-top:0">Discover jobs</h2>
<form method="post" action="/discover" class="flex">
<input name="query" placeholder="role (e.g. software engineer)" value="software engineer" style="max-width:280px">
<input name="location" placeholder="location (e.g. seattle wa)" value="seattle wa" style="max-width:200px">
<button class="btn" type="submit">Discover</button>
<button class="btn ghost" type="submit" formaction="/autopilot">Run Autopilot</button>
</form>
<p class="muted" style="font-size:12px">Pulls RemoteOK + Indeed RSS + Firecrawl search (+ feeds in Settings). Autopilot also scores and prepares the top matches automatically (never sends).</p>
</div>
<h2>Jobs ({{ jobs|length }})</h2>
<table>
<tr><th>Title</th><th>Company</th><th>Fit</th><th>Status</th><th>Opinion</th><th></th></tr>
{% for j in jobs %}
<tr>
<td><a href="/job/{{ j.id }}">{{ j.title }}</a><br><span class="muted" style="font-size:12px">{{ j.location }}</span></td>
<td>{{ j.company or '—' }}</td>
<td>{% if j.fit_score is not none %}{{ '%.2f'|format(j.fit_score) }}{% else %}—{% endif %}</td>
<td><span class="badge b-{{ j.status }}">{{ j.status }}</span></td>
<td class="muted" style="max-width:340px;font-size:12px">{{ j.opinion or '' }}</td>
<td><a class="btn ghost" href="/job/{{ j.id }}">Open</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No jobs yet — run Discover above.</td></tr>
{% endfor %}
</table>
<h2>Applications</h2>
<table>
<tr><th>Job</th><th>Company</th><th>Status</th><th>Outcome</th><th></th></tr>
{% for a in apps %}
<tr>
<td>{{ a.title }}</td>
<td>{{ a.company or '—' }}</td>
<td><span class="badge b-{{ a.status }}">{{ a.status }}</span></td>
<td class="muted">{{ a.outcome or '—' }}</td>
<td><a class="btn ghost" href="/app/{{ a.id }}">Open</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No applications prepared yet.</td></tr>
{% endfor %}
</table>
{% endblock %}

26
templates/insights.html Normal file
View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block nav_insights %}active{% endblock %}
{% block title %}Insights{% endblock %}
{% block content %}
<h1>Self-Improvement Insights</h1>
<p class="sub">The engine learns what gets responses and surfaces it.</p>
<div class="grid">
<div class="card"><div class="num">{{ insights.total_applications }}</div><div class="lbl">Applications</div></div>
<div class="card"><div class="num">{{ insights.positive_outcomes }}</div><div class="lbl">Positive outcomes</div></div>
<div class="card"><div class="num">{{ '%.0f'|format(insights.response_rate * 100) }}%</div><div class="lbl">Response rate</div></div>
</div>
<div class="panel">
<h2 style="margin-top:0">Outcome breakdown</h2>
<table>
<tr><th>Stage</th><th>Count</th></tr>
{% for stage, c in insights.by_stage.items() %}
<tr><td>{{ stage }}</td><td>{{ c }}</td></tr>
{% else %}
<tr><td colspan="2" class="muted">No outcomes logged yet.</td></tr>
{% endfor %}
</table>
<p class="muted" style="margin-top:10px">{{ insights.note }}</p>
</div>
{% endblock %}

37
templates/job.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}{{ job.title }}{% endblock %}
{% block content %}
<h1>{{ job.title }}</h1>
<p class="sub">{{ job.company }}{% if job.company and job.location %} · {% endif %}{{ job.location }}
· <a href="{{ job.url }}" target="_blank">source</a></p>
<div class="flex" style="margin:12px 0">
<form method="post" action="/job/{{ job.id }}/score"><button class="btn ghost" type="submit">Score</button></form>
<form method="post" action="/job/{{ job.id }}/prepare"><button class="btn" type="submit">Prepare (tailor + audit)</button></form>
</div>
<div class="grid">
<div class="card"><div class="lbl">Fit score</div><div class="num">{% if job.fit_score is not none %}{{ '%.2f'|format(job.fit_score) }}{% else %}—{% endif %}</div></div>
<div class="card"><div class="lbl">Status</div><div class="num" style="font-size:20px"><span class="badge b-{{ job.status }}">{{ job.status }}</span></div></div>
</div>
{% if job.opinion %}
<div class="panel"><h2 style="margin-top:0">Opinion</h2><p>{{ job.opinion }}</p></div>
{% endif %}
<div class="panel">
<h2 style="margin-top:0">Description</h2>
<pre>{{ job.description or 'No description captured.' }}</pre>
</div>
<h2>Applications for this job</h2>
<table>
<tr><th>#</th><th>Status</th><th>Outcome</th><th></th></tr>
{% for a in apps %}
<tr><td>{{ a.id }}</td><td><span class="badge b-{{ a.status }}">{{ a.status }}</span></td>
<td class="muted">{{ a.outcome or '—' }}</td><td><a class="btn ghost" href="/app/{{ a.id }}">Open</a></td></tr>
{% else %}
<tr><td colspan="4" class="muted">Nothing prepared yet.</td></tr>
{% endfor %}
</table>
{% endblock %}

19
templates/library.html Normal file
View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Resume Library{% endblock %}
{% block content %}
<h1>Resume Library</h1>
<p class="sub">The engine holds all your resumes, classifies each job, and combines the right
ones (e.g. the "grower who also builds" differentiator for hydro roles).</p>
<table>
<tr><th>Track</th><th>File</th><th>Status</th><th>Size</th></tr>
{% for r in library %}
<tr>
<td><span class="badge b-scored">{{ r.track }}</span></td>
<td class="mono">{{ r.file }}</td>
<td>{% if r.exists %}<span class="pass">loaded</span>{% else %}<span class="fail">missing</span>{% endif %}</td>
<td class="muted">{{ r.chars }} chars</td>
</tr>
{% endfor %}
</table>
{% endblock %}

68
templates/settings.html Normal file
View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block nav_settings %}active{% endblock %}
{% block title %}Settings{% endblock %}
{% block content %}
<h1>Settings</h1>
<p class="sub">Configure the LLM backend, email (SMTP), proxy, and resume.</p>
<form method="post" action="/settings" class="panel">
<h2 style="margin-top:0">LLM backend (inference runs on your LAN Ollama host, never this CT)</h2>
<label>Base URL</label>
<input name="llm_base_url" value="{{ settings.llm_base_url }}">
<label>Model</label>
<input name="llm_model" value="{{ settings.llm_model }}">
<p class="muted" style="font-size:12px">Default: nightmare Ollama (qwen3.8fast). The pipeline falls back to keyword matching if the model is saturated.</p>
<h2>Email (SMTP)</h2>
<label>Host</label><input name="smtp_host" value="{{ settings.smtp_host }}">
<label>Port</label><input name="smtp_port" value="{{ settings.smtp_port }}">
<label>Username (iCloud email)</label><input name="smtp_user" value="{{ settings.smtp_user }}" placeholder="indianaholmes1@icloud.com">
<label>Password (iCloud app-specific password)</label><input name="smtp_pass" type="password" value="{{ settings.smtp_pass }}" placeholder="generate at appleid.apple.com">
<label>From name</label><input name="from_name" value="{{ settings.from_name }}">
<label>From email</label><input name="from_email" value="{{ settings.from_email }}">
<label>Resume phone</label><input name="resume_phone" value="{{ settings.resume_phone }}">
<label>Resume email</label><input name="resume_email" value="{{ settings.resume_email }}">
<h2>Discovery</h2>
<label>Optional proxy URL (your own Nord proxy CT, e.g. http://10.30.20.154:3128)</label>
<input name="proxy_url" value="{{ settings.proxy_url }}" placeholder="leave blank for direct">
<label>Extra RSS feeds (one per line)</label>
<textarea name="rss_feeds" rows="3">{{ settings.rss_feeds }}</textarea>
<h2>Firecrawl (self-hosted search)</h2>
<label>Firecrawl URL</label>
<input name="firecrawl_url" value="{{ settings.firecrawl_url }}">
<label>Enabled</label>
<select name="firecrawl_enabled">
<option value="1" {% if settings.firecrawl_enabled == '1' %}selected{% endif %}>On</option>
<option value="0" {% if settings.firecrawl_enabled == '0' %}selected{% endif %}>Off</option>
</select>
<label>Search queries (one per line)</label>
<textarea name="firecrawl_queries" rows="4">{{ settings.firecrawl_queries }}</textarea>
<h2>Autopilot (scheduled)</h2>
<label>Enabled</label>
<select name="autopilot_enabled">
<option value="1" {% if settings.autopilot_enabled == '1' %}selected{% endif %}>On</option>
<option value="0" {% if settings.autopilot_enabled == '0' %}selected{% endif %}>Off</option>
</select>
<label>Max apps to auto-prepare per run</label>
<input name="autopilot_max_prepare" value="{{ settings.autopilot_max_prepare }}">
<label>Auto-send emails (skip the human Approve gate)</label>
<select name="auto_send">
<option value="0" {% if settings.auto_send != '1' %}selected{% endif %}>Off (approve manually)</option>
<option value="1" {% if settings.auto_send == '1' %}selected{% endif %}>On (auto-send to scraped emails)</option>
</select>
<p class="muted" style="font-size:12px">Runs every 6h via systemd timer (procyon-autopilot.timer). When Auto-send is On, prepared apps are emailed to their scraped contact automatically (audit + fabrication guards still apply). Jobs with a URL but no email still need you to apply via the posting.</p>
<h2>Master resume</h2>
<label>Path to master resume .txt on this CT (empty = auto library)</label>
<input name="master_resume" value="{{ settings.master_resume }}" placeholder="/opt/procyon/resumes/master.txt">
<br><br>
<div class="flex">
<button class="btn" type="submit">Save settings</button>
<button class="btn ghost" type="submit" formaction="/settings/test-smtp">Test SMTP</button>
</div>
</form>
{% endblock %}