Snapshot: full project state
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
resumes/*.pdf
|
||||
resumes/master.txt
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Procyon — Job Hunt Workflow Engine
|
||||
|
||||
Multi-step job-hunt pipeline with a web GUI, running on a Proxmox LXC (never on the MacBook).
|
||||
|
||||
## Pipeline
|
||||
1. **Discover** — pull jobs from RemoteOK + Indeed RSS (+ optional extra feeds), store in SQLite.
|
||||
2. **Score** — fit score (0–1) + a one-line "why" opinion, LLM-first, keyword fallback.
|
||||
3. **Tailor** — clone the master resume and reorder/re-emphasize for the role (honest-only:
|
||||
no fabricated employers/dates/skills). Draft a tailored cover email.
|
||||
4. **Self-Audit** — automatic checklist (employer name, no placeholders, contact info, subject,
|
||||
resume targets the job). Every item must PASS.
|
||||
5. **Approve (human gate)** — you review and click Approve. Nothing sends without this.
|
||||
6. **Send** — SMTP email with tailored resume attached. Send is BLOCKED unless approved AND
|
||||
the audit passed AND SMTP is configured.
|
||||
7. **Track** — log outcomes (response/interview/offer) and surface response-rate insights.
|
||||
|
||||
## Design rules
|
||||
- Inference runs on the LAN Ollama host (`llm_base_url`), NEVER on this CT (drjones rule).
|
||||
- Graceful degradation: if the model is saturated, scoring/tailoring falls back to
|
||||
deterministic keyword matching — the pipeline never blocks on inference.
|
||||
- Honest resumes only. The LLM system prompt forbids fabricating credentials.
|
||||
- Optional outbound proxy (his own Nord proxy CT) for discovery fetches — not an evasion rig.
|
||||
|
||||
## Setup
|
||||
- `systemctl restart procyon` after deploy.
|
||||
- Configure SMTP (iCloud app-specific password) in Settings before sending.
|
||||
|
||||
## Files
|
||||
- `app.py` — Flask routes + pipeline orchestration
|
||||
- `db.py` — SQLite schema + helpers
|
||||
- `scraper.py` — discovery (RSS/JSON + proxy)
|
||||
- `llm.py` — Ollama client + keyword fallback
|
||||
- `tailor.py` — scoring + resume tailoring + email drafting
|
||||
- `audit.py` — pre-send self-audit
|
||||
- `emailer.py` — SMTP send
|
||||
- `improve.py` — outcome tracking + insights
|
||||
270
app.py
Normal file
270
app.py
Normal file
@@ -0,0 +1,270 @@
|
||||
# Procyon — Job Hunt Workflow Engine (Flask)
|
||||
# Pipeline: discover -> score -> tailor -> audit -> approve -> send -> track.
|
||||
|
||||
import os
|
||||
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, send_file
|
||||
|
||||
import db
|
||||
import scraper
|
||||
import tailor
|
||||
import audit
|
||||
import emailer
|
||||
import improve
|
||||
import llm # noqa: F401 (kept importable for ad-hoc use)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get('PROCYON_SECRET', 'procyon-local-dev-key')
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def dashboard():
|
||||
s = db.get_settings()
|
||||
jobs = db.list_jobs(limit=200)
|
||||
apps = db.list_applications(limit=50)
|
||||
ins = improve.insights()
|
||||
stats = {
|
||||
'total_jobs': len(jobs),
|
||||
'new': sum(1 for j in jobs if j['status'] == 'new'),
|
||||
'scored': sum(1 for j in jobs if j['status'] in ('scored', 'tailored', 'audited', 'approved', 'sent')),
|
||||
'sent': sum(1 for a in apps if a.get('status') == 'sent'),
|
||||
}
|
||||
return render_template('dashboard.html', jobs=jobs, apps=apps, stats=stats,
|
||||
insights=ins, settings=s)
|
||||
|
||||
|
||||
@app.route('/discover', methods=['POST'])
|
||||
def discover():
|
||||
query = request.form.get('query', 'software engineer')
|
||||
location = request.form.get('location', 'seattle wa')
|
||||
added = scraper.discover(query=query, location=location)
|
||||
flash(f'Discovered {added} new job(s) for "{query}" in "{location}".', 'ok')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
|
||||
@app.route('/job/<int:job_id>')
|
||||
def job_detail(job_id):
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
return redirect(url_for('dashboard'))
|
||||
apps = db.list_applications()
|
||||
app_rows = [a for a in apps if a['job_id'] == job_id]
|
||||
return render_template('job.html', job=job, apps=app_rows)
|
||||
|
||||
|
||||
@app.route('/job/<int:job_id>/score', methods=['POST'])
|
||||
def score_job(job_id):
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
return redirect(url_for('dashboard'))
|
||||
score, opinion = tailor.score_job(job)
|
||||
db.update_job_score(job_id, score, opinion)
|
||||
flash(f'Scored {job["title"]} — fit {score}.', 'ok')
|
||||
return redirect(url_for('job_detail', job_id=job_id))
|
||||
|
||||
|
||||
@app.route('/job/<int:job_id>/prepare', methods=['POST'])
|
||||
def prepare_job(job_id):
|
||||
"""Full prepare: score + tailor + draft email + audit. Creates an application."""
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
return redirect(url_for('dashboard'))
|
||||
import pipeline
|
||||
app_id = pipeline.prepare(job)
|
||||
checks = db.get_audit(app_id)
|
||||
ok = audit.all_pass(checks)
|
||||
msg = 'Prepared + audited — ready for your approval.' if ok else \
|
||||
'Prepared, but audit found issues — review before approving.'
|
||||
flash(msg, 'ok' if ok else 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
|
||||
@app.route('/app/<int:app_id>')
|
||||
def application_detail(app_id):
|
||||
application = db.get_application(app_id)
|
||||
if not application:
|
||||
return redirect(url_for('dashboard'))
|
||||
job = db.get_job(application['job_id'])
|
||||
checks = db.get_audit(app_id)
|
||||
tailored_text = ''
|
||||
tp = application.get('resume_path')
|
||||
if tp and os.path.exists(tp):
|
||||
with open(tp, 'r', encoding='utf-8', errors='replace') as f:
|
||||
tailored_text = f.read()
|
||||
return render_template('application.html', application=application, job=job, checks=checks,
|
||||
tailored_text=tailored_text)
|
||||
|
||||
|
||||
@app.route('/app/<int:app_id>/approve', methods=['POST'])
|
||||
def approve_app(app_id):
|
||||
application = db.get_application(app_id)
|
||||
if not application:
|
||||
return redirect(url_for('dashboard'))
|
||||
job = db.get_job(application['job_id'])
|
||||
checks = db.get_audit(app_id)
|
||||
if not audit.all_pass(checks):
|
||||
flash('Cannot approve: self-audit has FAIL items.', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
to_email = (job.get('contact_email') or '').strip() if job else ''
|
||||
if to_email and emailer.configured():
|
||||
try:
|
||||
emailer.send(application['email_subject'], application['email_body'], to_email,
|
||||
attachment_path=application.get('tailored_pdf') or application.get('resume_path'))
|
||||
db.update_application(app_id, status='sent', notes=f'auto-sent to {to_email}')
|
||||
improve.log_outcome(app_id, 'sent', f'auto-sent to {to_email}')
|
||||
db.set_job_status(application['job_id'], 'sent')
|
||||
flash(f'Approved + auto-sent to {to_email}.', 'ok')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
except Exception as e:
|
||||
flash(f'Approved, but auto-send failed: {e}. Open the posting or send manually.', 'warn')
|
||||
|
||||
db.update_application(app_id, status='approved')
|
||||
if to_email:
|
||||
flash(f'Approved. (Auto-send skipped — SMTP not configured.)', 'warn')
|
||||
else:
|
||||
flash('Approved. No contact email scraped — open the posting or email manually.', 'ok')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
|
||||
@app.route('/app/<int:app_id>/pdf')
|
||||
def download_pdf(app_id):
|
||||
"""Download the tailored one-page resume PDF."""
|
||||
application = db.get_application(app_id)
|
||||
if not application:
|
||||
return redirect(url_for('dashboard'))
|
||||
pdf = application.get('tailored_pdf')
|
||||
if not pdf or not os.path.exists(pdf):
|
||||
flash('No PDF resume available for this application.', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
return send_file(pdf, as_attachment=True, download_name='resume.pdf')
|
||||
|
||||
|
||||
@app.route('/app/<int:app_id>/send', methods=['POST'])
|
||||
def send_app(app_id):
|
||||
application = db.get_application(app_id)
|
||||
if not application:
|
||||
return redirect(url_for('dashboard'))
|
||||
job = db.get_job(application['job_id'])
|
||||
|
||||
# final self-audit gate: nothing goes out with a FAIL or without approval
|
||||
checks = db.get_audit(app_id)
|
||||
if not audit.all_pass(checks):
|
||||
flash('Send blocked: self-audit has FAIL items.', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
if application['status'] != 'approved':
|
||||
flash('Send blocked: application must be Approved first.', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
to_email = request.form.get('to_email', '').strip()
|
||||
if not to_email:
|
||||
flash('Send blocked: no recipient email provided.', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
if not emailer.configured():
|
||||
flash('SMTP not configured — add iCloud app-specific password in Settings.', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
try:
|
||||
emailer.send(application['email_subject'], application['email_body'], to_email,
|
||||
attachment_path=application.get('tailored_pdf') or application.get('resume_path'))
|
||||
db.update_application(app_id, status='sent', notes=f'sent to {to_email}')
|
||||
improve.log_outcome(app_id, 'sent', f'sent to {to_email}')
|
||||
db.set_job_status(application['job_id'], 'sent')
|
||||
flash(f'Sent to {to_email}.', 'ok')
|
||||
except Exception as e:
|
||||
flash(f'Send failed: {e}', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
|
||||
@app.route('/app/<int:app_id>/apply', methods=['POST'])
|
||||
def apply_link(app_id):
|
||||
"""Mark an application as submitted via the external job-posting link (no email needed —
|
||||
the ATS/board jobs apply through their URL, not a hiring-manager inbox)."""
|
||||
application = db.get_application(app_id)
|
||||
if not application:
|
||||
return redirect(url_for('dashboard'))
|
||||
if application['status'] != 'approved':
|
||||
flash('Apply blocked: application must be Approved first.', 'warn')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
db.update_application(app_id, status='applied', notes='applied via external posting link')
|
||||
improve.log_outcome(app_id, 'applied', 'submitted via external link')
|
||||
db.set_job_status(application['job_id'], 'applied')
|
||||
flash('Marked as applied via the external posting.', 'ok')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
|
||||
@app.route('/app/<int:app_id>/outcome', methods=['POST'])
|
||||
def outcome_app(app_id):
|
||||
stage = request.form.get('stage', 'response')
|
||||
notes = request.form.get('notes', '')
|
||||
improve.log_outcome(app_id, stage, notes)
|
||||
flash(f'Recorded outcome: {stage}.', 'ok')
|
||||
return redirect(url_for('application_detail', app_id=app_id))
|
||||
|
||||
|
||||
@app.route('/insights')
|
||||
def insights_page():
|
||||
ins = improve.insights()
|
||||
return render_template('insights.html', insights=ins)
|
||||
|
||||
|
||||
@app.route('/library')
|
||||
def library_page():
|
||||
return render_template('library.html', library=tailor.list_library())
|
||||
|
||||
|
||||
@app.route('/settings', methods=['GET', 'POST'])
|
||||
def settings_page():
|
||||
if request.method == 'POST':
|
||||
for k in request.form:
|
||||
if k in db.DEFAULTS or k.startswith('smtp') or k in ('llm_base_url', 'llm_model',
|
||||
'proxy_url', 'rss_feeds',
|
||||
'master_resume', 'resume_phone',
|
||||
'resume_email', 'from_email',
|
||||
'from_name', 'headless_browser'):
|
||||
db.set_setting(k, request.form[k])
|
||||
flash('Settings saved.', 'ok')
|
||||
return redirect(url_for('settings_page'))
|
||||
return render_template('settings.html', settings=db.get_settings())
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
return jsonify(status='ok', name='procyon')
|
||||
|
||||
|
||||
@app.route('/autopilot', methods=['POST'])
|
||||
def autopilot_now():
|
||||
"""Trigger a discovery+score+prepare run in the background."""
|
||||
import threading
|
||||
def _run():
|
||||
try:
|
||||
import autopilot
|
||||
autopilot.run()
|
||||
except Exception as e:
|
||||
print(f'autopilot error: {e}')
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
flash('Autopilot started in the background — new jobs will appear as they finish.', 'ok')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
|
||||
@app.route('/settings/test-smtp', methods=['POST'])
|
||||
def test_smtp():
|
||||
to = db.get_setting('from_email', 'indianaholmes1@icloud.com')
|
||||
if not emailer.configured():
|
||||
flash('SMTP not configured — set username + app-specific password first.', 'warn')
|
||||
return redirect(url_for('settings_page'))
|
||||
try:
|
||||
emailer.send('Procyon test email', 'This is a test send from your Procyon job-hunt engine.', to)
|
||||
flash(f'Test email sent to {to}.', 'ok')
|
||||
except Exception as e:
|
||||
flash(f'Test send failed: {e}', 'warn')
|
||||
return redirect(url_for('settings_page'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.init_db()
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
87
audit.py
Normal file
87
audit.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# Procyon — self-audit before send. Every item must PASS before an application
|
||||
# is eligible for the user's approval/send gate.
|
||||
|
||||
import re
|
||||
|
||||
import db
|
||||
import tailor
|
||||
|
||||
|
||||
def run_audit(app_id, job, email_subject, email_body, tailored_resume):
|
||||
"""Run the checklist, log results, return list of {item, result, detail}."""
|
||||
checks = []
|
||||
|
||||
def add(item, result, detail):
|
||||
checks.append({'item': item, 'result': result, 'detail': detail})
|
||||
db.add_audit(app_id, job['id'], item, result, detail)
|
||||
|
||||
company = (job.get('company') or '').strip()
|
||||
title = (job.get('title') or '').strip()
|
||||
|
||||
# 1. Employer name present in email (personalization signal, NOT a safety gate — the LLM
|
||||
# often abbreviates ("PSI" for "Physical Superintelligence") or says "your team", and the
|
||||
# subject line already carries the company, so a missing exact match is only a WARN).
|
||||
if company:
|
||||
if company.lower() in (email_body or '').lower():
|
||||
add('Employer name in email', 'PASS', f'"{company}" appears in body')
|
||||
else:
|
||||
add('Employer name in email', 'WARN', f'"{company}" not verbatim in body (may be abbreviated)')
|
||||
else:
|
||||
add('Employer name in email', 'WARN', 'No company recorded — verify before send')
|
||||
|
||||
# 2. No leftover placeholder / other-company names
|
||||
placeholders = ['[company]', '[name]', '{{', '}}', 'lorem', 'TODO', 'xxx']
|
||||
found = [p for p in placeholders if p.lower() in (email_body or '').lower()]
|
||||
add('No placeholder tokens', 'FAIL' if found else 'PASS',
|
||||
f'placeholders: {found}' if found else 'clean')
|
||||
|
||||
# 3. Contact info present
|
||||
phone = db.get_setting('resume_phone', '')
|
||||
email = db.get_setting('resume_email', '')
|
||||
missing = []
|
||||
if phone and phone not in (email_body or ''):
|
||||
missing.append('phone')
|
||||
if email and email not in (email_body or ''):
|
||||
missing.append('email')
|
||||
add('Contact info in email', 'FAIL' if missing else 'PASS',
|
||||
f'missing: {missing}' if missing else 'phone + email present')
|
||||
|
||||
# 4. Subject present and sane length
|
||||
if not email_subject:
|
||||
add('Email subject', 'FAIL', 'missing')
|
||||
elif len(email_subject) > 120:
|
||||
add('Email subject', 'FAIL', f'too long ({len(email_subject)} chars)')
|
||||
else:
|
||||
add('Email subject', 'PASS', email_subject)
|
||||
|
||||
# 5. Tailored resume non-empty and no fabrication markers
|
||||
if not tailored_resume or len(tailored_resume) < 100:
|
||||
add('Tailored resume', 'FAIL', 'too short / empty')
|
||||
else:
|
||||
add('Tailored resume', 'PASS', f'{len(tailored_resume)} chars')
|
||||
|
||||
# 6. Resume is a real deterministic resume (was "targeting header matches job" — removed:
|
||||
# the resume is now deterministic and no longer carries a job-name header, so that check
|
||||
# always false-warned).
|
||||
if tailored_resume and len(tailored_resume) > 100:
|
||||
add('Resume present', 'PASS', f'{len(tailored_resume)} chars, deterministic facts')
|
||||
else:
|
||||
add('Resume present', 'FAIL', 'resume missing')
|
||||
|
||||
# 7. ATS keyword coverage — quality signal, never blocks sending (informational only).
|
||||
try:
|
||||
kws = tailor.extract_job_keywords(job.get('description') or '', top_n=12)
|
||||
cov, _kws, missing = tailor.ats_coverage(tailored_resume, kws)
|
||||
if cov >= 0.5:
|
||||
add('ATS keyword coverage', 'PASS', f'{int(cov*100)}% of top keywords present')
|
||||
else:
|
||||
add('ATS keyword coverage', 'WARN',
|
||||
f'{int(cov*100)}% — resume may not strongly echo this role ({", ".join(missing[:5])})')
|
||||
except Exception:
|
||||
add('ATS keyword coverage', 'WARN', 'could not compute')
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
def all_pass(checks):
|
||||
return all(c['result'] != 'FAIL' for c in checks)
|
||||
80
autopilot.py
Normal file
80
autopilot.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# Procyon — autopilot. Scheduled discover -> enrich -> score -> auto-prepare top N.
|
||||
# Everything up to the human approval/send gate. Never sends without approval.
|
||||
|
||||
import os
|
||||
|
||||
import db
|
||||
import scraper
|
||||
import tailor
|
||||
import pipeline
|
||||
import emailer
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def run():
|
||||
db.init_db()
|
||||
if db.get_setting('autopilot_enabled', '1') != '1':
|
||||
print('autopilot disabled')
|
||||
return
|
||||
|
||||
# 1. discover
|
||||
try:
|
||||
added = scraper.discover()
|
||||
except Exception as e:
|
||||
added = 0
|
||||
print(f'discover error: {e}')
|
||||
print(f'discovered {added} new job(s)')
|
||||
|
||||
# 2. enrich thin descriptions (firecrawl scrape / http fetch)
|
||||
new_jobs = db.list_jobs(status='new', limit=200)
|
||||
enriched = 0
|
||||
for j in new_jobs:
|
||||
desc = j.get('description') or ''
|
||||
if len(desc) < 200 and j.get('url'):
|
||||
try:
|
||||
full = scraper.enrich_description(j['url'])
|
||||
if full and len(full) > len(desc):
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
conn.execute('UPDATE jobs SET description = ? WHERE id = ?',
|
||||
(full[:5000], j['id']))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
enriched += 1
|
||||
except Exception:
|
||||
pass
|
||||
print(f'enriched {enriched} job description(s)')
|
||||
|
||||
# 3. score all 'new' jobs
|
||||
new_jobs = db.list_jobs(status='new', limit=200)
|
||||
scored = []
|
||||
for j in new_jobs:
|
||||
s, o = tailor.score_job(j)
|
||||
db.update_job_score(j['id'], s, o)
|
||||
scored.append((j['id'], s))
|
||||
print(f'scored {len(scored)} job(s)')
|
||||
|
||||
# 4. auto-prepare the top N (tailor + audit + PDF) — queued for your approval
|
||||
max_prepare = int(db.get_setting('autopilot_max_prepare', '3'))
|
||||
top = sorted(scored, key=lambda x: -x[1])[:max_prepare]
|
||||
prepared = 0
|
||||
for jid, s in top:
|
||||
job = db.get_job(jid)
|
||||
if not job:
|
||||
continue
|
||||
try:
|
||||
aid = pipeline.prepare(job)
|
||||
print(f'prepared app #{aid} for "{job["title"]}" (fit {s})')
|
||||
prepared += 1
|
||||
if db.get_setting('auto_send', '0') == '1':
|
||||
sent, msg = emailer.auto_send(aid)
|
||||
print(f' auto-send: {msg}' if sent else f' auto-send skipped: {msg}')
|
||||
except Exception as e:
|
||||
print(f'prepare failed for {job["title"]}: {e}')
|
||||
print(f'AUTOPILOT_DONE: {added} discovered, {len(scored)} scored, {prepared} prepared')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
288
db.py
Normal file
288
db.py
Normal file
@@ -0,0 +1,288 @@
|
||||
# Procyon — Job Hunt Workflow Engine
|
||||
# Multi-step pipeline: discover -> score -> tailor -> audit -> approve -> send -> track
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
DB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
|
||||
DB = os.path.join(DB_DIR, 'procyon.db')
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def get_conn():
|
||||
os.makedirs(DB_DIR, exist_ok=True)
|
||||
conn = sqlite3.connect(DB, timeout=15)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
company TEXT,
|
||||
location TEXT,
|
||||
url TEXT UNIQUE,
|
||||
source TEXT,
|
||||
description TEXT,
|
||||
posted_at TEXT,
|
||||
discovered_at TEXT,
|
||||
fit_score REAL,
|
||||
opinion TEXT,
|
||||
contact_email TEXT,
|
||||
status TEXT DEFAULT 'new'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
resume_path TEXT,
|
||||
tailored_pdf TEXT,
|
||||
email_subject TEXT,
|
||||
email_body TEXT,
|
||||
audit_json TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
outcome TEXT,
|
||||
sent_at TEXT,
|
||||
created_at TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outcomes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL,
|
||||
stage TEXT,
|
||||
happened_at TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER,
|
||||
job_id INTEGER,
|
||||
item TEXT,
|
||||
result TEXT,
|
||||
detail TEXT,
|
||||
checked_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_apps_job ON applications(job_id);
|
||||
"""
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
'llm_base_url': 'http://10.30.20.69:11434',
|
||||
'llm_model': 'qwen3.5:9b',
|
||||
'llm_timeout': '120',
|
||||
'smtp_host': 'smtp.mail.me.com',
|
||||
'smtp_port': '587',
|
||||
'smtp_user': '',
|
||||
'smtp_pass': '',
|
||||
'from_email': 'indianaholmes1@icloud.com',
|
||||
'from_name': 'Indiana Holmes',
|
||||
'resume_phone': '(425) 280-0023',
|
||||
'resume_email': 'indianaholmes1@icloud.com',
|
||||
'website': 'https://thetempleofdoom.com',
|
||||
'proxy_url': '',
|
||||
'master_resume': '',
|
||||
'rss_feeds': '',
|
||||
'headless_browser': '0',
|
||||
'firecrawl_url': 'http://10.30.20.182:3002',
|
||||
'firecrawl_enabled': '1',
|
||||
'firecrawl_queries': 'remote software engineer\nremote devops engineer\nremote platform engineer\nremote linux administrator\nwork from home software engineer seattle\nremote infrastructure engineer',
|
||||
'autopilot_enabled': '1',
|
||||
'autopilot_interval_hours': '6',
|
||||
'autopilot_max_prepare': '3',
|
||||
'auto_send': '0',
|
||||
}
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
for k, v in DEFAULTS.items():
|
||||
conn.execute('INSERT OR IGNORE INTO settings(key, value) VALUES (?, ?)', (k, v))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_settings():
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = conn.execute('SELECT key, value FROM settings').fetchall()
|
||||
return {r['key']: r['value'] for r in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_setting(key, default=None):
|
||||
conn = get_conn()
|
||||
try:
|
||||
r = conn.execute('SELECT value FROM settings WHERE key = ?', (key,)).fetchone()
|
||||
return r['value'] if r else default
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
'INSERT INTO settings(key, value) VALUES (?, ?) '
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
(key, str(value)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---- jobs ----
|
||||
def upsert_job(title, company, location, url, source, description, posted_at=None):
|
||||
conn = get_conn()
|
||||
try:
|
||||
if url:
|
||||
existing = conn.execute('SELECT id FROM jobs WHERE url = ?', (url,)).fetchone()
|
||||
if existing:
|
||||
return existing['id']
|
||||
cur = conn.execute(
|
||||
'INSERT INTO jobs(title, company, location, url, source, description, posted_at, discovered_at) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(title, company, location, url, source, description, posted_at, now()),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_jobs(status=None, limit=100, offset=0):
|
||||
conn = get_conn()
|
||||
try:
|
||||
q = 'SELECT * FROM jobs'
|
||||
args = []
|
||||
if status:
|
||||
q += ' WHERE status = ?'
|
||||
args.append(status)
|
||||
q += ' ORDER BY COALESCE(fit_score, -1) DESC, discovered_at DESC LIMIT ? OFFSET ?'
|
||||
args += [limit, offset]
|
||||
return [dict(r) for r in conn.execute(q, args).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_job(job_id):
|
||||
conn = get_conn()
|
||||
try:
|
||||
r = conn.execute('SELECT * FROM jobs WHERE id = ?', (job_id,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_job_status(job_id, status):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute('UPDATE jobs SET status = ? WHERE id = ?', (status, job_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_job_score(job_id, score, opinion):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute('UPDATE jobs SET fit_score = ?, opinion = ?, status = ? WHERE id = ?',
|
||||
(score, opinion, 'scored', job_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_job_contact_email(job_id, email):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute('UPDATE jobs SET contact_email = ? WHERE id = ?', (email or '', job_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---- applications ----
|
||||
def create_application(job_id, subject='', body=''):
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
'INSERT INTO applications(job_id, email_subject, email_body, created_at) VALUES (?, ?, ?, ?)',
|
||||
(job_id, subject, body, now()),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_application(app_id, **fields):
|
||||
conn = get_conn()
|
||||
try:
|
||||
cols = ', '.join(f'{k} = ?' for k in fields)
|
||||
conn.execute(f'UPDATE applications SET {cols} WHERE id = ?', (*fields.values(), app_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_application(app_id):
|
||||
conn = get_conn()
|
||||
try:
|
||||
r = conn.execute('SELECT * FROM applications WHERE id = ?', (app_id,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_applications(limit=100):
|
||||
conn = get_conn()
|
||||
try:
|
||||
q = ('SELECT a.*, j.title, j.company, j.url FROM applications a '
|
||||
'LEFT JOIN jobs j ON j.id = a.job_id ORDER BY a.id DESC LIMIT ?')
|
||||
return [dict(r) for r in conn.execute(q, (limit,)).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_outcome(app_id, stage, notes=''):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute('INSERT INTO outcomes(application_id, stage, happened_at, notes) VALUES (?, ?, ?, ?)',
|
||||
(app_id, stage, now(), notes))
|
||||
conn.execute('UPDATE applications SET outcome = ? WHERE id = ?', (stage, app_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_audit(app_id, job_id, item, result, detail):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute('INSERT INTO audit_log(application_id, job_id, item, result, detail, checked_at) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?)', (app_id, job_id, item, result, detail, now()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_audit(app_id):
|
||||
conn = get_conn()
|
||||
try:
|
||||
return [dict(r) for r in conn.execute(
|
||||
'SELECT * FROM audit_log WHERE application_id = ? ORDER BY id', (app_id,)).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
8
deploy/check_net.py
Normal file
8
deploy/check_net.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import urllib.request, socket
|
||||
socket.setdefaulttimeout(8)
|
||||
for url, name in [("https://remoteok.com/api", "remoteok"), ("https://www.google.com", "google")]:
|
||||
try:
|
||||
urllib.request.urlopen(url)
|
||||
print(name, "OK")
|
||||
except Exception as e:
|
||||
print(name, "FAIL ->", type(e).__name__)
|
||||
21
deploy/cleanup.py
Normal file
21
deploy/cleanup.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os, glob
|
||||
import db
|
||||
db.init_db()
|
||||
db.set_setting('llm_timeout', '45')
|
||||
# remove test artifacts
|
||||
for p in glob.glob('/opt/procyon/test_*.py') + glob.glob('/opt/procyon/data/resumes/test_*.pdf'):
|
||||
try:
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
pass
|
||||
# clean the synthetic test job/application from earlier verification
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM applications WHERE job_id IN (SELECT id FROM jobs WHERE url LIKE 'https://example.com%')")
|
||||
conn.execute("DELETE FROM jobs WHERE url LIKE 'https://example.com%'")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print('llm_timeout =', db.get_setting('llm_timeout'))
|
||||
print('jobs remaining =', len(db.list_jobs(limit=1000)))
|
||||
print('CLEANUP_DONE')
|
||||
4
deploy/fix_timeout.py
Normal file
4
deploy/fix_timeout.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import db
|
||||
db.init_db()
|
||||
db.set_setting('llm_timeout', '45')
|
||||
print('llm_timeout now =', db.get_setting('llm_timeout'))
|
||||
12
deploy/nginx-procyon.conf
Normal file
12
deploy/nginx-procyon.conf
Normal file
@@ -0,0 +1,12 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
10
deploy/procyon-autopilot.service
Normal file
10
deploy/procyon-autopilot.service
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Procyon autopilot (discover + enrich + score + prepare)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/opt/procyon
|
||||
ExecStart=/usr/bin/python3 /opt/procyon/autopilot.py
|
||||
StandardOutput=append:/var/log/procyon-autopilot.log
|
||||
StandardError=append:/var/log/procyon-autopilot.log
|
||||
10
deploy/procyon-autopilot.timer
Normal file
10
deploy/procyon-autopilot.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run Procyon autopilot every 6 hours
|
||||
|
||||
[Timer]
|
||||
OnBootSec=10min
|
||||
OnUnitActiveSec=6h
|
||||
RandomizedDelaySec=15min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
15
deploy/procyon.service
Normal file
15
deploy/procyon.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Procyon Job Hunt Workflow Engine
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/procyon
|
||||
Environment=PROCYON_SECRET=procyon-local-dev-key
|
||||
ExecStart=/usr/local/bin/gunicorn -w 2 -b 127.0.0.1:5000 app:app --timeout 300
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
21
deploy/setup.sh
Normal file
21
deploy/setup.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
mkdir -p /opt/procyon
|
||||
cd /opt/procyon
|
||||
tar xzf /tmp/procyon.tar.gz
|
||||
mkdir -p data
|
||||
python3 -c "import db; db.init_db()" && echo "DB_INIT_OK"
|
||||
cp deploy/procyon.service /etc/systemd/system/procyon.service
|
||||
cp deploy/procyon-autopilot.service /etc/systemd/system/procyon-autopilot.service
|
||||
cp deploy/procyon-autopilot.timer /etc/systemd/system/procyon-autopilot.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable procyon >/dev/null 2>&1
|
||||
systemctl restart procyon
|
||||
systemctl enable procyon-autopilot.timer >/dev/null 2>&1
|
||||
systemctl restart procyon-autopilot.timer
|
||||
cp deploy/nginx-procyon.conf /etc/nginx/sites-available/procyon
|
||||
ln -sf /etc/nginx/sites-available/procyon /etc/nginx/sites-enabled/procyon
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t
|
||||
systemctl restart nginx
|
||||
echo "SETUP_DONE"
|
||||
43
deploy/test_core.py
Normal file
43
deploy/test_core.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import db, tailor, audit, improve
|
||||
|
||||
db.init_db()
|
||||
# force fast fallback so this test doesn't wait on the cold-loading/saturated LLM
|
||||
db.set_setting('llm_timeout', '5')
|
||||
import llm
|
||||
llm.COOLDOWN = 5
|
||||
print("DB init: OK (llm_timeout forced to 5s to exercise fallback)")
|
||||
|
||||
# classification
|
||||
print("classify tech:", tailor.classify_track({"title": "DevOps Engineer", "description": "Linux Docker Python automation networking"}))
|
||||
print("classify hydro:", tailor.classify_track({"title": "Head Grower", "description": "hydroponic cultivation IPM greenhouse nutrition"}))
|
||||
print("classify driving:", tailor.classify_track({"title": "Delivery Driver", "description": "route fleet logistics warehouse forklift"}))
|
||||
|
||||
# build_master + combining
|
||||
tracks, master = tailor.build_master({"title": "Head Grower", "description": "hydroponic IPM greenhouse"})
|
||||
print("hydro tracks:", tracks, "| tech differentiator present:", "Technical Differentiator" in master)
|
||||
|
||||
tracks2, master2 = tailor.build_master({"title": "Backend Engineer", "description": "Python Flask Docker Linux"})
|
||||
print("tech tracks:", tracks2, "| master chars:", len(master2))
|
||||
|
||||
# keyword score (deterministic)
|
||||
s, op = tailor.score_job({"title": "x", "description": "proxmox docker python linux networking automation security"})
|
||||
print("keyword score:", s, "|", op[:50])
|
||||
|
||||
# audit round-trip
|
||||
job = db.upsert_job("DevOps Engineer", "Acme", "Seattle", "https://example.com/j1", "test",
|
||||
"Linux Docker Python Proxmox networking")
|
||||
aid = db.create_application(job, subject="Application: DevOps Engineer — Acme",
|
||||
body="Hello,\n\nI'm applying to Acme.\n\nThanks,\nIndiana Holmes\n(425) 280-0023\nindianaholmes1@icloud.com")
|
||||
checks = audit.run_audit(aid, db.get_job(job),
|
||||
"Application: DevOps Engineer — Acme",
|
||||
"Hello,\n\nI'm applying to Acme.\n\nThanks,\nIndiana Holmes\n(425) 280-0023\nindianaholmes1@icloud.com",
|
||||
"Targeting: DevOps Engineer at Acme\n\n" + master2)
|
||||
print("audit results:")
|
||||
for c in checks:
|
||||
print(" ", c["item"], "->", c["result"])
|
||||
print("all_pass:", audit.all_pass(checks))
|
||||
|
||||
# library
|
||||
print("library:", [(r["track"], r["exists"]) for r in tailor.list_library()])
|
||||
print("insights:", improve.insights())
|
||||
print("ALL_CORE_TESTS_PASSED")
|
||||
24
deploy/test_pdf.py
Normal file
24
deploy/test_pdf.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import re
|
||||
import db, tailor, resume_pdf
|
||||
|
||||
db.init_db()
|
||||
db.set_setting('llm_timeout', '5') # force fallback so test is fast
|
||||
import llm; llm.COOLDOWN = 5
|
||||
|
||||
for title, desc, track_expect in [
|
||||
("DevOps Engineer", "Linux Docker Python Proxmox automation networking infrastructure", 'tech'),
|
||||
("Head Grower", "hydroponic cultivation IPM greenhouse plant nutrition", 'hydro'),
|
||||
("Delivery Driver", "route fleet logistics warehouse forklift", 'driving'),
|
||||
]:
|
||||
job = {"title": title, "company": "TestCo", "description": desc}
|
||||
track = tailor.classify_track(job)
|
||||
structured = tailor.structured_resume(job)
|
||||
text = tailor.structured_to_text(structured)
|
||||
pdf_path = resume_pdf.render(structured, out_path=f"/opt/procyon/data/resumes/test_{track}.pdf")
|
||||
raw = open(pdf_path, 'rb').read()
|
||||
m = re.search(rb'/Count (\d+)', raw)
|
||||
pages = int(m.group(1)) if m else -1
|
||||
print(f"[{track}] title={title!r} -> pages={pages}, skills={len(structured['skills'])}, "
|
||||
f"exp={len(structured.get('experience',[]))}, projects={'projects' in structured}, "
|
||||
f"pdf={len(raw)} bytes")
|
||||
print("PDF_TEST_DONE")
|
||||
38
deploy/test_pipeline.py
Normal file
38
deploy/test_pipeline.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import db, tailor, scraper, audit
|
||||
|
||||
db.init_db()
|
||||
|
||||
# 1. internet check via urllib
|
||||
try:
|
||||
import urllib.request
|
||||
urllib.request.urlopen("https://remoteok.com/api", timeout=10)
|
||||
print("INTERNET: reachable")
|
||||
except Exception as e:
|
||||
print("INTERNET: blocked ->", type(e).__name__)
|
||||
|
||||
# 2. synthetic job -> full pipeline
|
||||
job = {
|
||||
"title": "Senior DevOps Engineer",
|
||||
"company": "Acme",
|
||||
"description": "Seeking a Linux systems engineer with Proxmox, Docker, Python automation, and networking experience.",
|
||||
}
|
||||
print("TRACK:", tailor.classify_track(job))
|
||||
tracks, master = tailor.build_master(job)
|
||||
print("TRACKS USED:", tracks, "| master chars:", len(master))
|
||||
score, opinion = tailor.score_job(job)
|
||||
print("SCORE:", score, "| OPINION:", opinion[:80])
|
||||
t = tailor.tailor_resume(job)
|
||||
print("TAILORED chars:", len(t), "| header:", t.split("\n")[0][:60])
|
||||
subj = tailor.make_subject(job)
|
||||
body = tailor.draft_email(job, t)
|
||||
jid = db.upsert_job(job["title"], job["company"], "", "https://example.com/j1", "test", job["description"])
|
||||
aid = db.create_application(jid, subject=subj, body=body)
|
||||
checks = audit.run_audit(aid, db.get_job(jid), subj, body, t)
|
||||
print("AUDIT:", [(c["item"], c["result"]) for c in checks])
|
||||
print("ALL_PASS:", audit.all_pass(checks))
|
||||
|
||||
# 3. hydro classification test
|
||||
hydro_job = {"title": "Head Grower", "description": "Hydroponic cultivation, IPM, greenhouse environment control, plant nutrition."}
|
||||
print("HYDRO TRACK:", tailor.classify_track(hydro_job))
|
||||
h_tracks, h_master = tailor.build_master(hydro_job)
|
||||
print("HYDRO TRACKS USED:", h_tracks, "| has tech diff:", "Technical Differentiator" in h_master)
|
||||
71
emailer.py
Normal file
71
emailer.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# Procyon — email sending via SMTP. Configurable; safe when unconfigured.
|
||||
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def configured():
|
||||
return bool(db.get_setting('smtp_user', '')) and bool(db.get_setting('smtp_pass', ''))
|
||||
|
||||
|
||||
def send(subject, body, to_email, attachment_path=None):
|
||||
host = db.get_setting('smtp_host', 'smtp.mail.me.com')
|
||||
port = int(db.get_setting('smtp_port', '587'))
|
||||
user = db.get_setting('smtp_user', '')
|
||||
pw = db.get_setting('smtp_pass', '')
|
||||
from_email = db.get_setting('from_email', 'indianaholmes1@icloud.com')
|
||||
from_name = db.get_setting('from_name', 'Indiana Holmes')
|
||||
|
||||
if not (user and pw):
|
||||
raise RuntimeError('SMTP not configured — add iCloud app-specific password in Settings')
|
||||
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f'{from_name} <{from_email}>'
|
||||
msg['To'] = to_email
|
||||
msg['Reply-To'] = from_email
|
||||
msg.set_content(body)
|
||||
|
||||
if attachment_path:
|
||||
import os
|
||||
with open(attachment_path, 'rb') as f:
|
||||
data = f.read()
|
||||
msg.add_attachment(data, maintype='application', subtype='pdf',
|
||||
filename=os.path.basename(attachment_path))
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
with smtplib.SMTP(host, port, timeout=30) as s:
|
||||
s.starttls(context=ctx)
|
||||
s.login(user, pw)
|
||||
s.send_message(msg)
|
||||
return True
|
||||
|
||||
|
||||
def auto_send(app_id):
|
||||
"""Auto-approve + send a prepared application to its scraped contact email — no human
|
||||
gate. Guarded: only sends when a contact email was scraped, SMTP is configured, and the
|
||||
self-audit passes (no fabrication/garbage). Returns (sent: bool, message: str)."""
|
||||
import audit
|
||||
app = db.get_application(app_id)
|
||||
if not app:
|
||||
return False, 'app not found'
|
||||
job = db.get_job(app['job_id'])
|
||||
to_email = (job.get('contact_email') or '').strip() if job else ''
|
||||
if not to_email:
|
||||
return False, 'no contact email scraped'
|
||||
if not configured():
|
||||
return False, 'SMTP not configured'
|
||||
checks = db.get_audit(app_id)
|
||||
if not audit.all_pass(checks):
|
||||
return False, 'audit has FAIL items'
|
||||
try:
|
||||
send(app['email_subject'], app['email_body'], to_email,
|
||||
attachment_path=app.get('tailored_pdf') or app.get('resume_path'))
|
||||
db.update_application(app_id, status='sent', notes=f'auto-sent to {to_email}')
|
||||
db.set_job_status(app['job_id'], 'sent')
|
||||
return True, f'sent to {to_email}'
|
||||
except Exception as e:
|
||||
return False, f'send error: {e}'
|
||||
32
improve.py
Normal file
32
improve.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Procyon — self-improvement loop. Logs outcomes, surfaces what works.
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def log_outcome(app_id, stage, notes=''):
|
||||
db.add_outcome(app_id, stage, notes)
|
||||
db.update_application(app_id, status=stage)
|
||||
|
||||
|
||||
def insights():
|
||||
"""Aggregate outcomes -> simple actionable insights."""
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
'SELECT stage, COUNT(*) c FROM outcomes GROUP BY stage').fetchall()
|
||||
sent = conn.execute(
|
||||
"SELECT COUNT(*) c FROM applications WHERE outcome IN ('response','interview','offer')"
|
||||
).fetchone()['c']
|
||||
total = conn.execute('SELECT COUNT(*) c FROM applications').fetchone()['c']
|
||||
by_stage = {r['stage']: r['c'] for r in rows}
|
||||
resp_rate = round(sent / total, 3) if total else 0.0
|
||||
return {
|
||||
'total_applications': total,
|
||||
'by_stage': by_stage,
|
||||
'positive_outcomes': sent,
|
||||
'response_rate': resp_rate,
|
||||
'note': ('Response rate is positive-outcome / total applications. '
|
||||
'Higher = your tailoring and targeting are landing.'),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
116
llm.py
Normal file
116
llm.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# Procyon — LLM client (Ollama) with graceful keyword fallback.
|
||||
# NEVER runs inference on the CT: it calls the 24/7 LAN Ollama host over HTTP.
|
||||
# Under saturation, a circuit breaker fast-fails so the pipeline never blocks.
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import db
|
||||
|
||||
SYSTEM = ("You are an expert technical recruiter and resume writer. You help a candidate "
|
||||
"present their REAL experience in the strongest honest light. HARD RULE: never "
|
||||
"invent, exaggerate, or fabricate any employer, job title, date, skill, credential, "
|
||||
"or metric that is not present in the source resume. You may reorder, re-emphasize, "
|
||||
"and rephrase only.")
|
||||
|
||||
# circuit breaker: if the last LLM call failed, skip LLM for COOLDOWN seconds
|
||||
_last_failure = 0.0
|
||||
COOLDOWN = 90
|
||||
|
||||
|
||||
def _post(url, payload, timeout):
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode('utf-8'))
|
||||
|
||||
|
||||
def _timeout():
|
||||
return int(db.get_setting('llm_timeout', '45'))
|
||||
|
||||
|
||||
def llm_available():
|
||||
"""Quick preflight: is the model host answering at all?"""
|
||||
global _last_failure
|
||||
if time.time() - _last_failure < COOLDOWN:
|
||||
return False
|
||||
base = db.get_setting('llm_base_url', 'http://10.30.20.29:11434').rstrip('/')
|
||||
try:
|
||||
_post(f'{base}/api/tags', {}, 4)
|
||||
return True
|
||||
except Exception:
|
||||
_last_failure = time.time()
|
||||
return False
|
||||
|
||||
|
||||
def llm_chat(prompt, system=SYSTEM, model=None):
|
||||
"""Return text, or None if the model is unavailable/saturated."""
|
||||
global _last_failure
|
||||
if time.time() - _last_failure < COOLDOWN:
|
||||
return None # circuit open — fall back immediately
|
||||
base = db.get_setting('llm_base_url', 'http://10.30.20.29:11434').rstrip('/')
|
||||
model = model or db.get_setting('llm_model', 'qwen3.8fast:latest')
|
||||
full = f"{system}\n\n{prompt}" if system else prompt
|
||||
try:
|
||||
data = _post(f'{base}/api/generate', {
|
||||
'model': model,
|
||||
'prompt': full,
|
||||
'stream': False,
|
||||
'think': False,
|
||||
}, _timeout())
|
||||
txt = (data.get('response') or '').strip()
|
||||
if not txt:
|
||||
raise ValueError('empty response')
|
||||
_last_failure = 0.0
|
||||
return txt
|
||||
except Exception:
|
||||
_last_failure = time.time()
|
||||
return None
|
||||
|
||||
|
||||
def llm_json(prompt, model=None):
|
||||
txt = llm_chat(prompt + '\n\nRespond with ONLY valid JSON, no markdown fences.', model=model)
|
||||
if not txt:
|
||||
return None
|
||||
txt = txt.strip().lstrip('```json').lstrip('```').rstrip('```').strip()
|
||||
try:
|
||||
return json.loads(txt)
|
||||
except Exception:
|
||||
import re
|
||||
m = re.search(r'\{.*\}', txt, re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group(0))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---- keyword fallback (deterministic, always works) ----
|
||||
|
||||
def _skill_keywords():
|
||||
return [
|
||||
'python', 'flask', 'sqlite', 'postgres', 'mysql', 'docker', 'proxmox', 'lxc',
|
||||
'linux', 'bash', 'nginx', 'systemd', 'networking', 'tcp/ip', 'dns', 'vpn',
|
||||
'automation', 'ci/cd', 'git', 'gitea', 'rest api', 'api', 'json', 'llm',
|
||||
'ollama', 'gpu', 'machine learning', 'ai', 'agent', 'mcp', 'javascript',
|
||||
'html', 'css', 'react', 'node', 'full-stack', 'devops', 'sysadmin',
|
||||
'monitoring', 'grafana', 'prometheus', 'backup', 'security', 'homelab',
|
||||
'esp32', 'sensor', 'hardware', 'raspberry pi', 'cryptocurrency', 'bitcoin',
|
||||
'btcpay', 'payments', 'cloudflare', 'tailscale', 'virtualization', 'kvm',
|
||||
]
|
||||
|
||||
|
||||
def keyword_score(description):
|
||||
"""Deterministic fit score 0..1 based on skill overlap."""
|
||||
if not description:
|
||||
return 0.0, 'No description available.'
|
||||
d = description.lower()
|
||||
hits = [k for k in _skill_keywords() if k in d]
|
||||
score = min(1.0, len(hits) / 14.0)
|
||||
matched = ', '.join(hits[:8]) or 'no direct keyword matches'
|
||||
return round(score, 3), f'Keyword overlap: {len(hits)} skills ({matched}).'
|
||||
64
pipeline.py
Normal file
64
pipeline.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Procyon — shared pipeline. One prepare path for both the web UI and the autopilot.
|
||||
|
||||
import os
|
||||
|
||||
import db
|
||||
import tailor
|
||||
import audit
|
||||
import scraper
|
||||
import resume_pdf
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def prepare(job):
|
||||
"""Score -> tailor -> draft -> PDF -> self-audit. Returns app_id. NO sending."""
|
||||
score, opinion = tailor.score_job(job)
|
||||
db.update_job_score(job['id'], score, opinion)
|
||||
|
||||
tracks, _master = tailor.build_master(job)
|
||||
structured = tailor.structured_resume(job)
|
||||
text = tailor.structured_to_text(structured)
|
||||
subject = tailor.make_subject(job)
|
||||
research = scraper.research_company(job)
|
||||
body = tailor.draft_email(job, text, research=research)
|
||||
|
||||
# scrape a contact email (best-effort, for auto-send on approve) — only runs for
|
||||
# the handful of jobs that get prepared, not the bulk discover.
|
||||
if not (job.get('contact_email') or '').strip():
|
||||
try:
|
||||
contact = scraper.scrape_contact_email(job)
|
||||
if contact:
|
||||
db.set_job_contact_email(job['id'], contact)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
aid = db.create_application(job['id'], subject=subject, body=body)
|
||||
|
||||
# one-page PDF
|
||||
pdf = ''
|
||||
try:
|
||||
out = os.path.join(BASE, 'data', 'resumes', f'app_{aid}.pdf')
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
resume_pdf.render(structured, out_path=out)
|
||||
if os.path.exists(out):
|
||||
pdf = out
|
||||
except Exception as e:
|
||||
print(f'pdf render failed: {e}')
|
||||
|
||||
# text version for display
|
||||
rdir = os.path.join(BASE, 'data', 'tailored')
|
||||
os.makedirs(rdir, exist_ok=True)
|
||||
tpath = os.path.join(rdir, f'app_{aid}_resume.txt')
|
||||
with open(tpath, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
|
||||
db.update_application(aid, resume_path=tpath, tailored_pdf=pdf, status='tailored',
|
||||
notes=f'tracks: {",".join(tracks)}')
|
||||
|
||||
# self-audit (now includes ATS keyword coverage)
|
||||
checks = audit.run_audit(aid, job, subject, body, text)
|
||||
ok = audit.all_pass(checks)
|
||||
db.update_application(aid, status='audited', audit_json=str(checks))
|
||||
db.set_job_status(job['id'], 'audited')
|
||||
return aid
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
gunicorn>=21.2
|
||||
fpdf2>=2.7
|
||||
120
resume_pdf.py
Normal file
120
resume_pdf.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# Procyon — one-page PDF resume renderer (fpdf2).
|
||||
# Takes a structured resume dict and lays it out on a single letter page.
|
||||
|
||||
import os
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'resumes')
|
||||
|
||||
DEJAVU = {
|
||||
'': '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
'B': '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
'I': '/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf',
|
||||
'BI': '/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf',
|
||||
}
|
||||
|
||||
|
||||
def _register_fonts(pdf):
|
||||
if all(os.path.exists(p) for p in DEJAVU.values()):
|
||||
pdf.add_font('DejaVu', '', DEJAVU[''])
|
||||
pdf.add_font('DejaVu', 'B', DEJAVU['B'])
|
||||
pdf.add_font('DejaVu', 'I', DEJAVU['I'])
|
||||
pdf.add_font('DejaVu', 'BI', DEJAVU['BI'])
|
||||
return 'DejaVu'
|
||||
return 'Helvetica'
|
||||
|
||||
|
||||
def _sanitize(text):
|
||||
if not text:
|
||||
return ''
|
||||
repl = {'\u2014': '-', '\u2013': '-', '\u2192': '->', '\u2022': '\u00b7',
|
||||
'\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"', '\u2026': '...'}
|
||||
for k, v in repl.items():
|
||||
text = text.replace(k, v)
|
||||
return text.encode('latin-1', 'replace').decode('latin-1')
|
||||
|
||||
|
||||
def render(resume, out_path=None, name='Indiana Holmes'):
|
||||
"""resume: dict with header/summary/skills/experience/education. Returns PDF path."""
|
||||
from fpdf import FPDF
|
||||
|
||||
pdf = FPDF(format='letter')
|
||||
pdf.set_margins(13, 13, 13)
|
||||
pdf.set_auto_page_break(True, margin=11)
|
||||
pdf.add_page()
|
||||
font = _register_fonts(pdf)
|
||||
|
||||
def clean(s):
|
||||
return s if font == 'DejaVu' else _sanitize(s)
|
||||
|
||||
def cell(w, h, txt, style='', size=9):
|
||||
pdf.set_font(font, style, size)
|
||||
pdf.cell(w, h, clean(txt), new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
def mc(w, h, txt, style='', size=9, indent=None):
|
||||
pdf.set_font(font, style, size)
|
||||
if indent is not None:
|
||||
pdf.set_x(indent)
|
||||
pdf.multi_cell(w, h, clean(txt), new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
def colored(r, g, b):
|
||||
pdf.set_text_color(r, g, b)
|
||||
|
||||
# --- Header ---
|
||||
cell(0, 8, name, 'B', 17)
|
||||
if resume.get('contact'):
|
||||
colored(90, 100, 120)
|
||||
mc(0, 4.2, resume['contact'], '', 8.5)
|
||||
colored(20, 20, 20)
|
||||
if resume.get('headline'):
|
||||
pdf.ln(0.5)
|
||||
colored(0, 90, 120)
|
||||
mc(0, 4.6, resume['headline'], 'BI', 9.5)
|
||||
colored(20, 20, 20)
|
||||
|
||||
def section(title):
|
||||
pdf.ln(2)
|
||||
colored(0, 70, 100)
|
||||
cell(0, 5, title.upper(), 'B', 10)
|
||||
colored(20, 20, 20)
|
||||
pdf.set_draw_color(0, 70, 100)
|
||||
pdf.set_line_width(0.3)
|
||||
pdf.line(13, pdf.get_y(), 202.9, pdf.get_y())
|
||||
pdf.ln(1.2)
|
||||
|
||||
if resume.get('summary'):
|
||||
mc(0, 4.4, resume['summary'], '', 9)
|
||||
pdf.ln(1)
|
||||
|
||||
if resume.get('skills'):
|
||||
section('Core Skills')
|
||||
mc(0, 4.4, ' \u00b7 '.join(resume['skills']), '', 8.5)
|
||||
|
||||
if resume.get('experience'):
|
||||
section('Experience')
|
||||
for e in resume['experience']:
|
||||
cell(0, 4.6, e.get('title', ''), 'B', 9.5)
|
||||
meta = ' \u2014 '.join(x for x in [e.get('company', ''), e.get('dates', '')] if x)
|
||||
colored(80, 90, 110)
|
||||
mc(0, 4.2, meta, 'I', 8.5)
|
||||
colored(20, 20, 20)
|
||||
for b in e.get('bullets', []):
|
||||
mc(0, 4.2, '\u00b7 ' + b, '', 8.5, indent=16)
|
||||
pdf.ln(0.8)
|
||||
|
||||
if resume.get('projects'):
|
||||
section('Key Projects')
|
||||
for p in resume['projects']:
|
||||
cell(0, 4.6, p.get('title', ''), 'B', 9)
|
||||
for b in p.get('bullets', []):
|
||||
mc(0, 4.2, '\u00b7 ' + b, '', 8.5, indent=16)
|
||||
pdf.ln(0.8)
|
||||
|
||||
if resume.get('education'):
|
||||
section('Education')
|
||||
mc(0, 4.4, resume['education'], '', 9)
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
if not out_path:
|
||||
out_path = os.path.join(DATA_DIR, 'resume.pdf')
|
||||
pdf.output(out_path)
|
||||
return out_path
|
||||
47
resumes/driving.txt
Normal file
47
resumes/driving.txt
Normal file
@@ -0,0 +1,47 @@
|
||||
# Indiana Holmes Sr.
|
||||
|
||||
Seattle, WA | 425-280-0023 | indianaholmes1@icloud.com
|
||||
|
||||
## Professional Summary
|
||||
|
||||
Reliable logistics and field operations professional with extensive experience in high-volume driving routes, fleet operations, and service logistics. Skilled at coordinating field teams, optimizing route efficiency, and maintaining clear communication with dispatch and operations staff. Known for reliability, safety awareness, and strong problem-solving in fast-paced environments.
|
||||
|
||||
## Core Skills
|
||||
|
||||
- Route driving and delivery logistics
|
||||
- Forklift operation (certified)
|
||||
- Fleet tracking and vehicle recovery
|
||||
- Warehouse inventory management
|
||||
- Field operations coordination
|
||||
- GPS route optimization
|
||||
- Customer service and client relations
|
||||
- Mobile reporting systems and handheld tools
|
||||
|
||||
## Professional Experience
|
||||
|
||||
Lime — Lead Field Operations Specialist, Seattle, WA | 2023 – Present
|
||||
- Manage daily field operations to maintain scooter and bike fleet availability.
|
||||
- Drive urban and suburban service routes to recover, redistribute, and service vehicles.
|
||||
- Coordinate logistics with dispatch to resolve urgent operational needs.
|
||||
- Assign tasks to field technicians to improve operational efficiency.
|
||||
- Use GPS tracking and real-time data to locate and retrieve fleet assets.
|
||||
|
||||
LabCorp — Lead Service Representative, Seattle, WA | 2022 – 2023
|
||||
- Executed daily medical specimen pickup routes across hospitals and clinics.
|
||||
- Operated company vehicles safely while maintaining regulatory compliance.
|
||||
- Used handheld electronic systems to track and log specimen pickups.
|
||||
- Coordinated emergency pickups and route adjustments with dispatch.
|
||||
|
||||
Aboda — Field Operations / Warehouse Specialist, Woodinville, WA | 2016 – 2020
|
||||
- Managed logistics and setup for corporate housing units across Seattle and Bellevue.
|
||||
- Transported furniture and supplies for apartment installations.
|
||||
- Performed quality inspections to ensure guest-ready units.
|
||||
- Operated forklifts and maintained warehouse inventory.
|
||||
|
||||
Mission Motors — Sales Manager, Stanwood, WA | 2015 – 2016
|
||||
- Managed vehicle inventory and sales operations.
|
||||
- Delivered high-quality customer service throughout the vehicle purchasing process.
|
||||
|
||||
## Education
|
||||
|
||||
Lake Stevens High School — High School Diploma (Computer Applications & Technology)
|
||||
38
resumes/hydro-clean.txt
Normal file
38
resumes/hydro-clean.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
# Indiana Holmes
|
||||
|
||||
Lynnwood, WA | 425-280-0023 | indianaholmes1@icloud.com | hydro.thetempleofdoom.com
|
||||
|
||||
## PROFESSIONAL SUMMARY
|
||||
|
||||
Self-taught horticulture specialist with over 14 years of hands-on experience in hydroponics, controlled-environment agriculture, and plant cultivation. Skilled in designing custom growing systems, optimizing plant nutrition, and maintaining environmental conditions that support healthy, high-yield plant production. Experienced in troubleshooting plant health issues, training growers, and building efficient indoor growing operations.
|
||||
|
||||
## CORE COMPETENCIES
|
||||
|
||||
- Hydroponic system design and setup
|
||||
- Plant propagation and lifecycle management
|
||||
- Nutrient program development and plant nutrition
|
||||
- Integrated pest and disease management (IPM)
|
||||
- Environmental control: lighting, humidity, airflow, temperature
|
||||
- Indoor grow facility design and optimization
|
||||
- Troubleshooting plant stress and deficiencies
|
||||
- Training and mentoring growers
|
||||
|
||||
## PROFESSIONAL EXPERIENCE
|
||||
|
||||
Master Grower / Cultivation Manager, 2014 – 2023 | Private controlled-environment agriculture operation, Los Angeles, CA
|
||||
- Managed full plant cultivation cycles from propagation through harvest.
|
||||
- Designed and implemented custom hydroponic systems for indoor production environments.
|
||||
- Developed plant nutrient programs tailored to different growth stages.
|
||||
- Maintained environmental systems including lighting, humidity, airflow, and temperature.
|
||||
- Implemented integrated pest management strategies to protect plant health.
|
||||
- Trained and supervised assistant growers and cultivation staff.
|
||||
|
||||
Hydroponics & Cultivation Consultant, 2014 – Present | Independent consulting practice, Seattle, WA
|
||||
- Consult with clients on designing and building efficient indoor gardening and hydroponic systems.
|
||||
- Advise growers on equipment selection, grow room layout, and environmental management.
|
||||
- Provide training on hydroponic growing techniques, plant nutrition, and pest control.
|
||||
- Diagnose cultivation issues and improve plant performance through system optimization.
|
||||
|
||||
## ADDITIONAL EXPERTISE
|
||||
|
||||
Controlled environment agriculture (CEA) · Hydroponic infrastructure design · Plant propagation techniques · Grow room engineering · Nutrient chemistry and mineral feeding strategies · Experimental horticulture and system innovation
|
||||
41
resumes/tech-costco.txt
Normal file
41
resumes/tech-costco.txt
Normal file
@@ -0,0 +1,41 @@
|
||||
# Indiana Holmes
|
||||
|
||||
Seattle, WA | 425-280-0023 | indianaholmes1@icloud.com
|
||||
|
||||
## Technical Profile
|
||||
|
||||
Independent systems engineer specializing in homelab architecture, AI automation systems, cybersecurity-focused infrastructure, embedded device engineering, and scalable self-hosted platforms. Experienced operating Proxmox virtualization clusters, Linux infrastructure, AI orchestration systems, RF communication platforms, telemetry hardware, and production-style service environments. Strong hands-on background combining systems engineering, infrastructure operations, automation pipelines, API integrations, advanced AI workflows, custom MCP development, local LLM infrastructure, and distributed services from concept through deployment.
|
||||
|
||||
## Core Engineering Expertise
|
||||
|
||||
- Proxmox virtualization clusters, VM / CT orchestration, Linux administration
|
||||
- AI infrastructure, local LLM deployment, inference pipelines, and AI acceleration
|
||||
- Advanced AI workflows, agent systems, custom MCP integrations, and API orchestration
|
||||
- Cybersecurity-focused infrastructure hardening, segmentation, and remote access design
|
||||
- Docker containers, automation systems, and self-hosted SaaS platforms
|
||||
- Embedded firmware engineering across ESP32 device families
|
||||
- RF systems including LoRa, Meshtastic, and CC1101 wireless experimentation
|
||||
- Infrastructure monitoring, diagnostics, troubleshooting, and operations
|
||||
|
||||
## Major Systems & Projects
|
||||
|
||||
- Designed and operate a multi-node homelab infrastructure hosting AI services, automation platforms, databases, development environments, and websites using Proxmox hypervisors and Linux containers.
|
||||
- Built advanced AI automation systems integrating local LLMs, APIs, orchestration logic, custom MCP tooling, and distributed workflows for infrastructure operations and research.
|
||||
- Engineered ESP32-based telemetry devices, wireless monitoring systems, custom firmware platforms, and RF communication systems for connected hardware applications.
|
||||
- Developed long-range RF and mesh networking projects using LoRa radios, Meshtastic networking, CC1101 modules, and distributed sensing platforms.
|
||||
- Implemented secure network infrastructure including SSH administration, VPN tunnels, segmentation strategies, reverse proxy routing, secure service exposure, and infrastructure diagnostics.
|
||||
|
||||
## Infrastructure Engineering & Operations
|
||||
|
||||
- Developed infrastructure automation workflows using Linux tooling, Bash, Python, Node.js, APIs, and containerized services to simplify deployment, monitoring, and operational management.
|
||||
- Architected and maintained self-hosted SaaS ecosystems spanning Git services, automation platforms, cloud storage, AI infrastructure, telemetry systems, and development tooling.
|
||||
- Experienced troubleshooting complex multi-system environments involving networking, virtualization, containers, AI infrastructure, embedded systems, Linux administration, and distributed services.
|
||||
- Built scalable development and infrastructure environments integrating databases, AI inference stacks, automation services, monitoring systems, and secure remote access tooling.
|
||||
|
||||
## Technologies & Platforms
|
||||
|
||||
Linux • Proxmox • Docker • Virtualization • Python • Bash • Node.js • REST APIs • Git • ESP32 • Arduino • Firmware Development • LoRa • Meshtastic • AI / LLM Infrastructure • Advanced AI Workflows • Agentic AI Systems • MCP Development • API Integrations • Automation Pipelines • n8n • Self-Hosted Services • Networking • SSH • VPN • Reverse Proxies • Cybersecurity • Infrastructure Hardening • Linux Networking • Infrastructure Monitoring • Cloudflare Tunnels • GitLab • Gitea • MongoDB • Redis • RF Communications • Automation Engineering • Homelab Architecture • Service Orchestration
|
||||
|
||||
## Professional Focus
|
||||
|
||||
Seeking an engineering and infrastructure-focused role where strong hands-on experience with systems administration, infrastructure operations, automation engineering, AI tooling, cybersecurity-oriented networking, and scalable technical problem solving can contribute to large-scale operational reliability and technical innovation.
|
||||
31
resumes/tech-polished.txt
Normal file
31
resumes/tech-polished.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
# Indiana Holmes
|
||||
|
||||
Seattle, WA | 425-280-0023 | indianaholmes1@icloud.com | hydro.thetempleofdoom.com
|
||||
|
||||
## Technical Profile
|
||||
|
||||
Independent systems engineer and technical builder specializing in homelab infrastructure, self-hosted platforms, AI automation systems, and embedded device engineering. Experienced designing complete technology ecosystems including Proxmox virtualization clusters, network infrastructure, local AI acceleration stacks, distributed microcontroller systems, RF communication platforms, and production-style self-hosted services. Combines software engineering, hardware prototyping, and infrastructure design to build integrated systems from concept through deployment.
|
||||
|
||||
## Core Engineering Expertise
|
||||
|
||||
- Proxmox hypervisors, virtualization clusters, and VM / CT orchestration
|
||||
- Embedded firmware development across ESP32 device families
|
||||
- Linux server engineering, containers, automation, and service administration
|
||||
- RF systems: LoRa, Meshtastic mesh networking, and CC1101 experimentation
|
||||
- Self-hosted SaaS platforms, website hosting, personal cloud, and dev tooling
|
||||
- IoT sensors, telemetry systems, monitoring devices, and field hardware
|
||||
- AI infrastructure, local LLM deployment, inference workflows, and acceleration
|
||||
- Network infrastructure, SSH, VPN, reverse proxies, and remote access design
|
||||
|
||||
## Major Systems & Projects
|
||||
|
||||
- Designed and operate a multi-node homelab running Proxmox hypervisors with virtual machines and containers hosting AI services, automation platforms, databases, development environments, and websites.
|
||||
- Architected a self-hosted SaaS ecosystem spanning Git services, workflow automation, cloud storage, development tooling, and web infrastructure with secure remote access.
|
||||
- Built AI automation pipelines integrating local LLM models, APIs, orchestration logic, and agent-style workflows for research, monitoring, and data processing tasks.
|
||||
- Engineered ESP32-based embedded systems including wireless monitoring devices, telemetry nodes, sensor platforms, and custom firmware for connected hardware tools.
|
||||
- Developed long-range RF and mesh communication projects using LoRa radios, Meshtastic, and related wireless modules for distributed sensing and field networking.
|
||||
- Implemented secure network infrastructure including SSH administration, VPN tunnels, reverse proxy routing, internal service exposure, and homelab segmentation strategies.
|
||||
|
||||
## Technologies & Platforms
|
||||
|
||||
Linux • Proxmox • Docker • Hypervisors • Python • Bash • Node.js • REST APIs • Git • ESP32 • Arduino • Firmware Development • Sensors • LoRa • Meshtastic • AI / LLM Infrastructure • n8n • Website Hosting • Self-Hosted Services • Networking • SSH • VPN • Reverse Proxies
|
||||
49
resumes/work-history.txt
Normal file
49
resumes/work-history.txt
Normal file
@@ -0,0 +1,49 @@
|
||||
# Canonical Work History — Indiana Holmes
|
||||
# Source: "Typed Work History Holmes (1).pdf" (JOI vocational document). VERIFIED facts only.
|
||||
# Most recent first. Dates as recorded on the form.
|
||||
|
||||
## Water System Technician — Northwest Water Systems Inc
|
||||
2025-01 → 2025-10 (full-time, ~$5,542/mo, 40 hr/wk, supervised 9)
|
||||
Field technician maintaining 45 pump houses across Washington State. Install/repair/maintain water treatment & distribution systems (pipes, pumps, valves, meters). Troubleshoot leaks/blockages/malfunctions. Equipment operation (pumps, tanks, filtration, chemical dosing). Emergency response. Regulatory compliance (water quality/distribution). Field inspections. Recordkeeping + inventory/ordering. Water sampling to EPA standards. Transport samples on ice within 24-48h. Safety protocols. Heavy work (50-100+ lbs).
|
||||
Equipment: pipe wrenches, water meter wrench, pliers, t-handles, utility knife, tape measure, hammers, hand tools, battery tools, ladders, leak detector. PPE: harness, safety glasses, closed-toe shoes.
|
||||
Requirements: valid WA driver's license, HS diploma/GED, time management, on-the-job training provided.
|
||||
|
||||
## Lead Field Operations Specialist — Lime
|
||||
2023 → 2025 (40 hr/wk, 24 months, supervised 5)
|
||||
Managed daily field operations to maintain scooter/bike fleet availability. Drove urban/suburban routes to recover, redistribute, service vehicles. Repaired/replaced bike & scooter parts. Coordinated logistics with dispatch. Assigned tasks to field agents. GPS tracking + real-time data for asset recovery. Directed drivers via two-way radio. Heavy work.
|
||||
Equipment: car, scooters, computer, phone, hand tools, ladders, GPS, 2-way radio.
|
||||
|
||||
## Master Grower / Cultivation Manager — (Los Angeles, CA)
|
||||
2014 → 2023 (40 hr/wk, 108 months)
|
||||
Managed full plant cultivation cycles propagation→harvest. Designed/implemented custom hydroponic systems. Developed nutrient programs per growth stage. Maintained environmental systems (lighting, humidity, airflow, temperature). Integrated pest management (IPM). Trained/supervised assistant growers. Medium work (20-50 lbs).
|
||||
Equipment: gardening tools, shovel, rake, pick, wheelbarrow, hand/power tools, hedge shears, aerator, edgers, pruners, ladders, water reservoirs & pumps, grow mediums, air pumps, LED grow lights.
|
||||
|
||||
## Lead Service Representative — LabCorp
|
||||
2022 → 2023 (40 hr/wk, 12 months)
|
||||
Executed daily medical specimen pickup routes across hospitals/clinics. Operated vehicles safely, regulatory compliance. Handheld electronic tracking/logging of pickups. Coordinated emergency pickups + route adjustments with dispatch. Light work.
|
||||
Equipment: company vehicles, specimen lockbox, GPS, phone.
|
||||
|
||||
## Field Operations / Warehouse Specialist — Aboda
|
||||
2019 → 2022 (40 hr/wk, 36 months)
|
||||
Managed logistics & setup for corporate housing units (Seattle/Bellevue). Transported furniture/supplies (~3 installs/day). Quality inspections (move-in-ready). Forklift operation + warehouse inventory. Heavy work.
|
||||
Equipment: forklift, company vehicle, box truck, hand truck, hand tools, phone, GPS.
|
||||
|
||||
## Sales Manager — Mission Motors
|
||||
2017 → 2019 (40 hr/wk, 24 months)
|
||||
Managed vehicle inventory & sales at dealership. Customer service through full purchase process. Test drives. Light work.
|
||||
|
||||
## Pizza Assembler — Pizza Hut
|
||||
2014 → 2015 (and 2008 → 2012; 40 hr/wk)
|
||||
Assembled pizzas to order, cut toppings, made/kneaded/pressed dough from scratch. Cleaned equipment/station. Delivered pizzas. Loaded/unloaded box truck. Medium work.
|
||||
Equipment: kitchen appliances, dough press, industrial can opener/dishwasher, hand tools.
|
||||
|
||||
## Logistics Coordinator — Salt Works
|
||||
2012 → 2012 (~12 months)
|
||||
Loaded pallets into shipping containers & box trucks. Forklift + cherry-picker operation. Logged merchandise in/out. Hand-picked/packed orders. Medium work.
|
||||
Equipment: forklift, cherry picker, scissor lift, handheld, pallet jack, box truck.
|
||||
|
||||
## Employment Gap
|
||||
2012 → 2013 — moved out of state, cared for child.
|
||||
|
||||
## Education
|
||||
Lake Stevens High School — High School Diploma (Computer Applications & Technology)
|
||||
424
scraper.py
Normal file
424
scraper.py
Normal file
@@ -0,0 +1,424 @@
|
||||
# Procyon — job discovery. RSS/JSON feeds + optional proxy + manual add.
|
||||
# Uses his OWN proxy infra (optional), not an anti-bot evasion rig.
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import db
|
||||
|
||||
UA = 'Procyon/1.0 (personal job-search assistant)'
|
||||
|
||||
|
||||
def _open(url, timeout=20):
|
||||
req = urllib.request.Request(url, headers={'User-Agent': UA})
|
||||
proxy = db.get_setting('proxy_url', '')
|
||||
if proxy:
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({'http': proxy, 'https': proxy}))
|
||||
else:
|
||||
opener = urllib.request.build_opener()
|
||||
with opener.open(req, timeout=timeout) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def fetch_rss(url):
|
||||
"""Parse an RSS/Atom feed into job dicts."""
|
||||
data = _open(url)
|
||||
root = ET.fromstring(data)
|
||||
items = []
|
||||
for item in root.iter('item'):
|
||||
def _t(tag):
|
||||
el = item.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else ''
|
||||
title = _t('title')
|
||||
link = _t('link')
|
||||
desc = _t('description')
|
||||
items.append({'title': title, 'url': link, 'description': desc,
|
||||
'company': '', 'location': '', 'source': url})
|
||||
return items
|
||||
|
||||
|
||||
def fetch_remoteok():
|
||||
"""RemoteOK JSON API — no key required. Filters to fresh listings: RemoteOK's job
|
||||
pages 302-redirect to the homepage once a posting expires (~30 days), so stale
|
||||
entries would give broken 'Open Application' links."""
|
||||
data = json.loads(_open('https://remoteok.com/api').decode('utf-8'))
|
||||
out = []
|
||||
now = time.time()
|
||||
STALE_AFTER = 30 * 86400 # 30 days
|
||||
for j in data[1:]:
|
||||
if not isinstance(j, dict) or not j.get('position'):
|
||||
continue
|
||||
epoch = j.get('epoch')
|
||||
if epoch:
|
||||
try:
|
||||
if now - float(epoch) > STALE_AFTER:
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out.append({
|
||||
'title': j.get('position'),
|
||||
'company': j.get('company'),
|
||||
'location': j.get('location', 'Remote'),
|
||||
'url': j.get('url'),
|
||||
'description': (j.get('description') or '')[:4000],
|
||||
'source': 'remoteok',
|
||||
'posted_at': j.get('date'),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def fetch_remotive():
|
||||
"""Remotive API — remote jobs JSON, no key. https://remotive.com/api/remote-jobs"""
|
||||
data = json.loads(_open('https://remotive.com/api/remote-jobs').decode('utf-8'))
|
||||
out = []
|
||||
for j in data.get('jobs', []):
|
||||
out.append({
|
||||
'title': j.get('title'),
|
||||
'company': j.get('company_name'),
|
||||
'location': j.get('candidate_required_location') or 'Remote',
|
||||
'url': j.get('url'),
|
||||
'description': (j.get('description') or '')[:4000],
|
||||
'source': 'remotive',
|
||||
'posted_at': j.get('publication_date'),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def fetch_wwr():
|
||||
"""We Work Remotely — remote programming jobs RSS (no key)."""
|
||||
data = _open('https://weworkremotely.com/categories/remote-programming-jobs.rss')
|
||||
root = ET.fromstring(data)
|
||||
items = []
|
||||
for item in root.iter('item'):
|
||||
def _t(tag):
|
||||
el = item.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else ''
|
||||
items.append({'title': _t('title'), 'url': _t('link'),
|
||||
'description': _t('description'), 'company': '',
|
||||
'location': 'Remote', 'source': 'wwr'})
|
||||
return items
|
||||
|
||||
|
||||
# ---- ATS board APIs (Greenhouse / Lever) ----
|
||||
# The headless-agent discovery path: most tech companies (Seattle + remote) post through
|
||||
# Greenhouse/Lever, which expose clean JSON with no bot wall. Sweep their boards directly.
|
||||
|
||||
ATS_COMPANIES = [
|
||||
# Seattle / PNW
|
||||
'zillow', 'redfin', 'remitly', 'outreach', 'smartsheet', 'expedia', 'offerup',
|
||||
'highspot', 'rover', 'allenai', 'f5', 'convoy',
|
||||
# Remote-friendly tech
|
||||
'stripe', 'airbnb', 'doordash', 'datadog', 'snowflake', 'gitlab', 'figma',
|
||||
'notion', 'openai', 'anthropic', 'discord', 'reddit', 'robinhood', 'coinbase',
|
||||
'block', 'vercel', 'cloudflare', 'mongodb', 'elastic', 'confluent', 'twilio',
|
||||
'dropbox', 'asana', 'atlassian', 'postman', 'github', 'hashicorp',
|
||||
]
|
||||
|
||||
|
||||
def _strip_html(s):
|
||||
return re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', ' ', s or '')).strip()
|
||||
|
||||
|
||||
def fetch_greenhouse(slug):
|
||||
"""Greenhouse board JSON (no key). Returns list of job dicts."""
|
||||
url = f'https://boards-api.greenhouse.io/v1/boards/{slug}/jobs'
|
||||
data = json.loads(_open(url).decode('utf-8'))
|
||||
out = []
|
||||
for j in data.get('jobs', []):
|
||||
loc = j.get('location') or {}
|
||||
out.append({
|
||||
'title': j.get('title'),
|
||||
'company': j.get('company_name') or slug,
|
||||
'location': loc.get('name') if isinstance(loc, dict) else str(loc or 'Remote'),
|
||||
'url': j.get('absolute_url'),
|
||||
'description': _strip_html(j.get('content') or '')[:4000],
|
||||
'source': 'greenhouse',
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def fetch_lever(slug):
|
||||
"""Lever board JSON (no key). Returns list of job dicts."""
|
||||
url = f'https://api.lever.co/v0/postings/{slug}?mode=json'
|
||||
data = json.loads(_open(url).decode('utf-8'))
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out = []
|
||||
for j in data:
|
||||
cats = j.get('categories') or {}
|
||||
out.append({
|
||||
'title': j.get('text'),
|
||||
'company': slug,
|
||||
'location': cats.get('location') or 'Remote',
|
||||
'url': j.get('hostedUrl'),
|
||||
'description': _strip_html(j.get('descriptionPlain') or j.get('description') or '')[:4000],
|
||||
'source': 'lever',
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# Locations worth keeping: WFH (remote) + Seattle-area. Everything else (onsite SF/NY/etc) is noise.
|
||||
_REMOTE_TERMS = ('remote', 'seattle', 'anywhere', 'worldwide', 'work from home', 'wfh',
|
||||
'united states', 'north america', 'americas')
|
||||
|
||||
|
||||
def _is_relevant_location(loc):
|
||||
l = (loc or '').lower()
|
||||
return any(t in l for t in _REMOTE_TERMS)
|
||||
|
||||
|
||||
def fetch_ats():
|
||||
"""Sweep the ATS_COMPANIES boards across Greenhouse + Lever. Returns list of job dicts,
|
||||
filtered to remote / Seattle / US-remote locations."""
|
||||
out = []
|
||||
for slug in ATS_COMPANIES:
|
||||
for fn, label in ((fetch_greenhouse, 'greenhouse'), (fetch_lever, 'lever')):
|
||||
try:
|
||||
jobs = fn(slug)
|
||||
for j in jobs:
|
||||
if _is_relevant_location(j.get('location')):
|
||||
out.append(j)
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def indeed_feed(query, location):
|
||||
"""Indeed RSS endpoint (public, returns XML)."""
|
||||
q = urllib.parse.quote_plus(query)
|
||||
l = urllib.parse.quote_plus(location)
|
||||
return f'https://www.indeed.com/rss?q={q}&l={l}'
|
||||
|
||||
|
||||
def ingest(items):
|
||||
added = 0
|
||||
for it in items:
|
||||
if not it.get('title') or not it.get('url'):
|
||||
continue
|
||||
jid = db.upsert_job(
|
||||
it.get('title'), it.get('company') or '', it.get('location') or '',
|
||||
it.get('url'), it.get('source', 'manual'), it.get('description') or '',
|
||||
it.get('posted_at'),
|
||||
)
|
||||
if jid:
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def discover(query='software engineer', location='seattle wa', sources=None):
|
||||
"""Pull from configured sources. Returns count added."""
|
||||
added = 0
|
||||
# RemoteOK (remote)
|
||||
try:
|
||||
added += ingest(fetch_remoteok())
|
||||
except Exception as e:
|
||||
print(f'remoteok failed: {e}')
|
||||
# Remotive (remote)
|
||||
try:
|
||||
added += ingest(fetch_remotive())
|
||||
except Exception as e:
|
||||
print(f'remotive failed: {e}')
|
||||
# We Work Remotely (remote)
|
||||
try:
|
||||
added += ingest(fetch_wwr())
|
||||
except Exception as e:
|
||||
print(f'wwr failed: {e}')
|
||||
# ATS boards (Greenhouse + Lever) — Seattle + remote tech companies
|
||||
try:
|
||||
added += ingest(fetch_ats())
|
||||
except Exception as e:
|
||||
print(f'ats failed: {e}')
|
||||
# Indeed RSS (dead — left in try/except, non-functional)
|
||||
try:
|
||||
added += ingest(fetch_rss(indeed_feed(query, location)))
|
||||
except Exception as e:
|
||||
print(f'indeed rss failed: {e}')
|
||||
# Firecrawl search (self-hosted, /v1/search works; scrape is bot-walled)
|
||||
if db.get_setting('firecrawl_enabled', '1') == '1':
|
||||
added += firecrawl_discover()
|
||||
# configured extra feeds
|
||||
feeds = db.get_setting('rss_feeds', '')
|
||||
for f in [x.strip() for x in feeds.splitlines() if x.strip()]:
|
||||
try:
|
||||
added += ingest(fetch_rss(f))
|
||||
except Exception as e:
|
||||
print(f'feed {f} failed: {e}')
|
||||
return added
|
||||
|
||||
|
||||
# ---- Firecrawl (self-hosted) ----
|
||||
|
||||
def firecrawl_search(query, limit=5):
|
||||
"""Self-hosted Firecrawl /v1/search. Returns list of {url, title, description}."""
|
||||
base = db.get_setting('firecrawl_url', 'http://10.30.20.182:3002').rstrip('/')
|
||||
payload = json.dumps({'query': query, 'limit': limit}).encode('utf-8')
|
||||
req = urllib.request.Request(f'{base}/v1/search', data=payload,
|
||||
headers={'Content-Type': 'application/json', 'User-Agent': UA})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read().decode('utf-8'))
|
||||
return data.get('data', []) if data.get('success') else []
|
||||
|
||||
|
||||
def research_company(job):
|
||||
"""Best-effort company research: a short factual blurb to weave into the email.
|
||||
|
||||
Uses self-hosted Firecrawl search for the company name. Returns a string of
|
||||
1-2 snippet(s), or '' if nothing relevant is found. Never raises."""
|
||||
company = (job.get('company') or '').strip()
|
||||
if not company:
|
||||
return ''
|
||||
try:
|
||||
results = firecrawl_search(f'{company} about', limit=3)
|
||||
except Exception:
|
||||
return ''
|
||||
facts = []
|
||||
for r in results:
|
||||
title = (r.get('title') or '').strip()
|
||||
desc = (r.get('description') or '').strip()
|
||||
blob = f'{title} {desc}'.lower()
|
||||
if desc and company.lower() in blob:
|
||||
facts.append(f'{title}: {desc}'[:500])
|
||||
return '\n'.join(facts[:2])
|
||||
|
||||
|
||||
# ---- contact email scraping (for auto-send on approve) ----
|
||||
|
||||
_EMAIL_RE = re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}')
|
||||
_HARD_REJECT = ('noreply', 'no-reply', 'donotreply', 'privacy', 'abuse', 'legal', 'arb',
|
||||
'demand', 'press', 'media', 'security@', 'support', 'example', 'yourdomain',
|
||||
'email.com', 'sentry', 'wixpress', 'domain.com', 'squarespace', 'cloudflare',
|
||||
'compliance', 'trust', 'notice', 'copyright', 'dpo', 'gdpr', 'unsubscribe')
|
||||
_RECRUITING_HINT = ('career', 'recruit', 'hiring', 'jobs', 'talent', 'people', 'hr@',
|
||||
'work', 'join', 'apply', 'hello', 'team', 'contact', 'info')
|
||||
|
||||
|
||||
def _email_rank(e):
|
||||
"""Score an email: -1 = reject (legal/abuse/footer junk), higher = more recruiting-ish."""
|
||||
el = e.lower()
|
||||
if any(w in el for w in _HARD_REJECT):
|
||||
return -1
|
||||
score = 0
|
||||
for h in _RECRUITING_HINT:
|
||||
if h in el:
|
||||
score += 1
|
||||
return score
|
||||
|
||||
|
||||
def _emails_from_url(url):
|
||||
try:
|
||||
raw = _open(url, timeout=12)
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
found = {}
|
||||
for e in _EMAIL_RE.findall(text):
|
||||
e = e.rstrip('.,;:>').lower()
|
||||
rank = _email_rank(e)
|
||||
if rank > 0 and (e not in found or rank > found[e]):
|
||||
found[e] = rank
|
||||
return sorted(found, key=lambda x: -found[x])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _company_domains(job_url, company):
|
||||
"""Derive candidate company domains to check for a contact email."""
|
||||
doms = []
|
||||
slug = None
|
||||
m = re.match(r'https?://(?:job-boards|boards)\.greenhouse\.io/([a-z0-9-]+)', job_url or '')
|
||||
if not m:
|
||||
m = re.match(r'https?://jobs\.lever\.co/([a-z0-9-]+)', job_url or '')
|
||||
if m:
|
||||
slug = m.group(1)
|
||||
if slug:
|
||||
doms += [f'https://{slug}.com', f'https://www.{slug}.com']
|
||||
if company:
|
||||
cslug = re.sub(r'[^a-z0-9]', '', (company or '').lower())
|
||||
if cslug and cslug not in (slug or ''):
|
||||
doms += [f'https://{cslug}.com', f'https://www.{cslug}.com']
|
||||
return doms
|
||||
|
||||
|
||||
def scrape_contact_email(job):
|
||||
"""Best-effort: find a real contact email for the job/company (for auto-send).
|
||||
Checks the posting first, then the company's /contact, /careers, /about, /jobs, and
|
||||
homepage. Returns an email string or ''. Never raises."""
|
||||
url = job.get('url') or ''
|
||||
company = job.get('company') or ''
|
||||
if not url and not company:
|
||||
return ''
|
||||
if url:
|
||||
emails = _emails_from_url(url)
|
||||
if emails:
|
||||
return emails[0]
|
||||
for base in _company_domains(url, company):
|
||||
for path in ('/contact', '/careers', '/about', '/jobs', '/contact-us', '/'):
|
||||
emails = _emails_from_url(f'{base}{path}')
|
||||
if emails:
|
||||
return emails[0]
|
||||
return ''
|
||||
|
||||
|
||||
def firecrawl_discover():
|
||||
"""Run configured search queries -> job leads. Returns count added."""
|
||||
added = 0
|
||||
queries = [q.strip() for q in db.get_setting('firecrawl_queries', '').splitlines() if q.strip()]
|
||||
for q in queries:
|
||||
try:
|
||||
for res in firecrawl_search(q, limit=5):
|
||||
if not res.get('url') or not res.get('title'):
|
||||
continue
|
||||
if _is_garbage_title(res.get('title')):
|
||||
continue
|
||||
# only keep plausible individual postings / listings
|
||||
db.upsert_job(
|
||||
res.get('title'), '', 'Remote/Web', res.get('url'), 'firecrawl',
|
||||
res.get('description', '') or '', None,
|
||||
)
|
||||
added += 1
|
||||
except Exception as e:
|
||||
print(f'firecrawl query {q!r} failed: {e}')
|
||||
return added
|
||||
|
||||
|
||||
_GARBAGE_TITLE_MARKERS = ('jobsradar', 'linkedin', '1,000+', 'open roles', 'greater ',
|
||||
'remote jobs', 'jobs in', 'job board', 'indeed', 'glassdoor',
|
||||
'ziprecruiter', 'simplyhired', ' careers', 'search jobs')
|
||||
|
||||
|
||||
def _is_garbage_title(t):
|
||||
"""Firecrawl search returns search-result / aggregator pages, not individual postings.
|
||||
Skip titles that look like a results page rather than a single role."""
|
||||
tl = (t or '').lower()
|
||||
return any(m in tl for m in _GARBAGE_TITLE_MARKERS)
|
||||
|
||||
|
||||
def firecrawl_scrape(url):
|
||||
"""Scrape a URL to markdown via self-hosted Firecrawl. Returns text or None."""
|
||||
base = db.get_setting('firecrawl_url', 'http://10.30.20.182:3002').rstrip('/')
|
||||
payload = json.dumps({'url': url, 'formats': ['markdown']}).encode('utf-8')
|
||||
req = urllib.request.Request(f'{base}/v1/scrape', data=payload,
|
||||
headers={'Content-Type': 'application/json', 'User-Agent': UA})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
data = json.loads(r.read().decode('utf-8'))
|
||||
return (data.get('data') or {}).get('markdown') or None
|
||||
|
||||
|
||||
def enrich_description(url):
|
||||
"""Get a fuller job description for a URL. Firecrawl first, HTTP fallback."""
|
||||
try:
|
||||
md = firecrawl_scrape(url)
|
||||
if md:
|
||||
return md
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
raw = _open(url, timeout=15)
|
||||
# strip tags crudely
|
||||
text = re.sub(r'<[^>]+>', ' ', raw.decode('utf-8', errors='replace'))
|
||||
return re.sub(r'\s+', ' ', text)[:4000]
|
||||
except Exception:
|
||||
return None
|
||||
442
tailor.py
Normal file
442
tailor.py
Normal file
@@ -0,0 +1,442 @@
|
||||
# Procyon — resume tailoring + fit scoring + cover email drafting.
|
||||
# LLM-first, deterministic keyword fallback. Honest-only: never fabricates.
|
||||
# Holds a resume LIBRARY (tech / hydro / driving) and combines the right
|
||||
# resumes per job ("the grower who also builds" differentiator).
|
||||
|
||||
import os
|
||||
|
||||
import db
|
||||
import llm
|
||||
|
||||
RESUME_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'resumes')
|
||||
|
||||
# library: track -> ordered list of resume files (first = primary)
|
||||
TRACKS = {
|
||||
'tech': ['tech-costco.txt', 'tech-polished.txt'],
|
||||
'hydro': ['hydro-clean.txt'],
|
||||
'driving': ['driving.txt'],
|
||||
}
|
||||
|
||||
HYDRO_KW = ['hydroponic', 'horticulture', 'grower', 'cultivat', 'greenhouse', 'agricultur',
|
||||
'nursery', 'botan', 'plant', 'ipm', 'irrigation', 'nutrient', 'cannabis', 'crop']
|
||||
DRIVE_KW = ['delivery', 'driver', 'logistics', 'warehouse', 'forklift', 'route', 'fleet',
|
||||
'dispatch', 'courier', 'cdl', 'field operations', 'shipping', 'transport']
|
||||
TECH_KW = ['software', 'engineer', 'developer', 'devops', 'sysadmin', 'system admin',
|
||||
'infrastructure', 'cloud', 'python', 'linux', 'it ', 'network', 'ai',
|
||||
'automation', 'full-stack', 'full stack', 'backend', 'frontend', 'sre',
|
||||
'platform', 'data ', 'security', 'mcp', 'llm']
|
||||
|
||||
|
||||
def _load(name):
|
||||
p = os.path.join(RESUME_DIR, name)
|
||||
if os.path.exists(p):
|
||||
with open(p, 'r', encoding='utf-8', errors='replace') as f:
|
||||
return f.read()
|
||||
return ''
|
||||
|
||||
|
||||
def list_library():
|
||||
out = []
|
||||
for track, files in TRACKS.items():
|
||||
for fn in files:
|
||||
p = os.path.join(RESUME_DIR, fn)
|
||||
out.append({'track': track, 'file': fn, 'exists': os.path.exists(p),
|
||||
'chars': os.path.getsize(p) if os.path.exists(p) else 0})
|
||||
return out
|
||||
|
||||
|
||||
def classify_track(job):
|
||||
"""Return the primary track for a job based on keyword density."""
|
||||
text = ((job.get('title') or '') + ' ' + (job.get('description') or '')).lower()
|
||||
hydro = sum(1 for k in HYDRO_KW if k in text)
|
||||
drive = sum(1 for k in DRIVE_KW if k in text)
|
||||
tech = sum(1 for k in TECH_KW if k in text)
|
||||
# default tech when ambiguous (his primary target)
|
||||
if hydro > drive and hydro >= tech and hydro > 0:
|
||||
return 'hydro'
|
||||
if drive > hydro and drive >= tech and drive > 0:
|
||||
return 'driving'
|
||||
return 'tech'
|
||||
|
||||
|
||||
def _tech_differentiator():
|
||||
"""Compact 'also a builder' block extracted from the tech resume."""
|
||||
tech = _load('tech-costco.txt')
|
||||
profile = ''
|
||||
if '## Technical Profile' in tech:
|
||||
profile = tech.split('## Technical Profile', 1)[1].split('## Core Engineering', 1)[0]
|
||||
return ('\n\n## Technical Differentiator\n\n'
|
||||
f"Beyond cultivation, I'm a self-taught full-stack developer and infrastructure "
|
||||
f"engineer who builds the software and sensor hardware that modern growing "
|
||||
f"operations run on (see hydro.thetempleofdoom.com).\n{profile.strip()}")
|
||||
|
||||
|
||||
def build_master(job):
|
||||
"""Select + combine the right resumes. Returns (tracks_used, combined_text)."""
|
||||
track = classify_track(job)
|
||||
tracks = [track]
|
||||
if track == 'hydro':
|
||||
text = _load('hydro-clean.txt') + _tech_differentiator()
|
||||
tracks.append('tech')
|
||||
elif track == 'driving':
|
||||
text = _load('driving.txt')
|
||||
# if the role has a meaningful tech/ops slant, fold in the tech profile
|
||||
jt = ((job.get('title') or '') + ' ' + (job.get('description') or '')).lower()
|
||||
if sum(1 for k in TECH_KW if k in jt) >= 2:
|
||||
text += _tech_differentiator()
|
||||
tracks.append('tech')
|
||||
else:
|
||||
text = _load('tech-costco.txt')
|
||||
if not text.strip():
|
||||
text = _load('tech-polished.txt')
|
||||
# single-resume override (Settings) takes precedence
|
||||
override = db.get_setting('master_resume', '')
|
||||
if override and os.path.exists(override):
|
||||
with open(override, 'r', encoding='utf-8', errors='replace') as f:
|
||||
return ['custom'], f.read()
|
||||
# always append the canonical work history so tailoring has the full verified record
|
||||
wh = _load('work-history.txt')
|
||||
if wh:
|
||||
text += '\n\n## WORK HISTORY (verified facts)\n' + wh
|
||||
return tracks, text
|
||||
|
||||
|
||||
def score_job(job):
|
||||
"""Return (score 0..1, opinion string)."""
|
||||
desc = job.get('description') or ''
|
||||
title = job.get('title') or ''
|
||||
company = job.get('company') or ''
|
||||
track = classify_track(job)
|
||||
prompt = (
|
||||
f"Score how well this job fits a self-taught full-stack developer and self-hosted "
|
||||
f"infrastructure engineer (Python, Flask, Linux, Proxmox, Docker, networking, "
|
||||
f"automation, LLM/agent tooling). Candidate track: {track}. Return JSON: "
|
||||
f"{{\"score\": <0.0 to 1.0>, \"opinion\": \"<one sentence why>\", "
|
||||
f"\"top_skills\": [\"..\"]}}\n\n"
|
||||
f"Job title: {title}\nCompany: {company}\n\n{desc[:3000]}"
|
||||
)
|
||||
data = llm.llm_json(prompt)
|
||||
if data and isinstance(data, dict) and 'score' in data:
|
||||
try:
|
||||
score = float(data['score'])
|
||||
except (TypeError, ValueError):
|
||||
score = 0.5
|
||||
return round(max(0.0, min(1.0, score)), 3), str(data.get('opinion', ''))
|
||||
score, opinion = llm.keyword_score(desc)
|
||||
return score, opinion
|
||||
|
||||
|
||||
def tailor_resume(job):
|
||||
"""Tailor the combined master resume for a specific job. Returns tailored text."""
|
||||
tracks, master = build_master(job)
|
||||
title = job.get('title') or ''
|
||||
company = job.get('company') or ''
|
||||
desc = (job.get('description') or '')[:3000]
|
||||
prompt = (
|
||||
f"Rewrite this resume to target the role '{title}' at {company}. "
|
||||
"Keep every fact identical — same employers, titles, dates, skills, credentials. "
|
||||
"Only reorder sections to surface the most relevant experience first and rephrase bullet "
|
||||
"points to echo the job description's own terminology WITHOUT inventing anything. "
|
||||
"Do not add skills the candidate doesn't have. Do NOT add a 'Targeting' header or any "
|
||||
"meta-commentary — it should read like a normal resume.\n\n"
|
||||
f"JOB DESCRIPTION:\n{desc}\n\nMASTER RESUME (combined tracks: {', '.join(tracks)}):\n{master}"
|
||||
)
|
||||
tailored = llm.llm_chat(prompt)
|
||||
if tailored:
|
||||
return tailored
|
||||
# keyword fallback: keep master intact (no fabricated targeting header)
|
||||
return master
|
||||
|
||||
|
||||
def draft_email(job, tailored_resume, research=''):
|
||||
"""Draft a tailored outreach email. No fabrication; human, specific, natural voice."""
|
||||
title = job.get('title') or 'the role'
|
||||
company = job.get('company') or 'your team'
|
||||
from_name = db.get_setting('from_name', 'Indiana Holmes')
|
||||
phone = db.get_setting('resume_phone', '')
|
||||
email = db.get_setting('resume_email', '')
|
||||
website = db.get_setting('website', 'https://thetempleofdoom.com').strip()
|
||||
|
||||
research_hint = ''
|
||||
if research:
|
||||
research_hint = (
|
||||
f"\n\nCOMPANY CONTEXT (weave ONE natural, specific reference into the body — show "
|
||||
f"you actually looked at what they do; do NOT lead with it or recite it like a "
|
||||
f"fact sheet):\n{research[:1200]}\n"
|
||||
)
|
||||
|
||||
prompt = (
|
||||
f"You are {from_name}, a self-taught full-stack developer and infrastructure engineer "
|
||||
f"based in Seattle, WA. You are applying to {company} for the '{title}' role as an "
|
||||
f"EXTERNAL candidate — you have NEVER worked at {company}, and you do NOT currently "
|
||||
f"hold this role or any role there. Your only real experience is what is in the "
|
||||
f"TAILORED RESUME below.\n\n"
|
||||
f"Write a short, warm, plain-spoken cold-application email for that role. It should read "
|
||||
f"like a real person wrote it quickly and confidently — not a template and not "
|
||||
f"AI-sounding. Rules:\n"
|
||||
"- 2-3 short paragraphs, varied sentence length, concrete and specific.\n"
|
||||
"- NO buzzwords and NO cliche phrases: 'I am writing to', 'I hope this email finds you "
|
||||
"well', 'I would welcome', 'I am excited to', 'passionate about', 'I believe', "
|
||||
"'leverage', 'delve', 'synergy'. Avoid em-dashes and semicolons.\n"
|
||||
"- Open naturally with a specific reason this role caught your eye, then name the TWO "
|
||||
"strongest RELEVANT qualifications from the resume in plain concrete terms, then a "
|
||||
"short direct close.\n"
|
||||
"- If a COMPANY CONTEXT block is present, reference ONE specific fact about the company "
|
||||
"as something you've noticed about THEM (e.g. 'I've been following FreedomPay's work in "
|
||||
"payments'), NOT as your own employment. NEVER claim or imply you currently work at "
|
||||
f"{company}, have ever worked at {company}, or currently hold the '{title}' role.\n"
|
||||
"- Do NOT invent any employer, job title, date, skill, or metric that is not in the "
|
||||
"resume. If the resume does not support a claim, do not make it.\n"
|
||||
"- End with a short direct closing line. Do NOT include your name, phone number, email, "
|
||||
"or website — the signature is appended automatically.\n"
|
||||
"- Return ONLY the email body text (no subject line, no preamble).\n\n"
|
||||
f"TAILORED RESUME:\n{tailored_resume[:4000]}"
|
||||
f"{research_hint}"
|
||||
)
|
||||
body = llm.llm_chat(prompt)
|
||||
body = _clean(body)
|
||||
if body and not _looks_fabricated(body, company, title):
|
||||
return _append_signature(body, from_name, phone, email, website)
|
||||
return (
|
||||
f"Hello,\n\n"
|
||||
f"I came across the {title} opening at {company} and it lines up well with what I do. "
|
||||
f"I'm a self-taught full-stack developer and infrastructure engineer — I build and run "
|
||||
f"Python/Flask services, Linux servers, Proxmox virtualization, Docker, and agent/LLM "
|
||||
f"tooling on hardware I operate myself.\n\n"
|
||||
f"If you're open to it, I'd like to talk about how that hands-on background fits the "
|
||||
f"team.\n\n"
|
||||
f"Thanks,\n{from_name}\n{phone}\n{email}\n{website}"
|
||||
)
|
||||
|
||||
|
||||
def _append_signature(body, from_name, phone, email, website):
|
||||
"""Deterministically append the signature block. The model reliably drops it, so
|
||||
never trust it to sign its own output. Skips if a signature is already present."""
|
||||
if (website and website in body) or (email and email in body):
|
||||
return body
|
||||
sig = f"{from_name}\n{phone}\n{email}\n{website}"
|
||||
return (body.rstrip() + f"\n\n{sig}").strip()
|
||||
|
||||
|
||||
def _clean(body):
|
||||
"""Strip broken-tokenizer artifacts and a leading 'Subject:' line from LLM output.
|
||||
The local models intermittently leak `<unusedNN>` / `????` tokens; if nothing
|
||||
usable remains, return '' so the caller falls back to the safe template."""
|
||||
import re
|
||||
if not body:
|
||||
return ''
|
||||
body = re.sub(r'<unused\d+>', '', body)
|
||||
body = re.sub(r'^[\s?]+', '', body)
|
||||
lines = body.splitlines()
|
||||
while lines and lines[0].strip().lower().startswith('subject:'):
|
||||
lines = lines[1:]
|
||||
cleaned = '\n'.join(lines).strip()
|
||||
# if after cleanup it's essentially empty or pure punctuation, treat as garbage
|
||||
if not cleaned or not any(ch.isalnum() for ch in cleaned):
|
||||
return ''
|
||||
return cleaned
|
||||
|
||||
|
||||
def _looks_fabricated(body, company, title):
|
||||
"""Deterministic guard: reject LLM output that claims the candidate currently
|
||||
works at the target company or already holds the applied-for role (weak-model
|
||||
hallucination). Returns True if the body looks fabricated."""
|
||||
if not body:
|
||||
return True
|
||||
low = body.lower()
|
||||
c = (company or '').lower().strip()
|
||||
t = (title or '').lower().strip()
|
||||
markers = ['right now i ', 'i currently ', 'currently work', "i'm currently",
|
||||
'i am currently', 'i joined ', 'my role at', 'i work at', 'i own the ',
|
||||
"i've been at ", 'i lead the ', 'i manage the ']
|
||||
hits = [m for m in markers if m in low]
|
||||
# present-tense claim tying the candidate to the company/title
|
||||
if c and c in low:
|
||||
for phrase in (f'at {c} where i', f'at {c}, where i', f'working at {c}',
|
||||
f'i am a {t} at {c}', f"i'm a {t} at {c}"):
|
||||
if phrase in low:
|
||||
hits.append(phrase)
|
||||
return bool(hits)
|
||||
|
||||
|
||||
def make_subject(job):
|
||||
title = job.get('title') or 'Open Role'
|
||||
company = job.get('company') or ''
|
||||
return f"Application: {title}" + (f" — {company}" if company else "")
|
||||
|
||||
|
||||
# ===================== structured one-page resume =====================
|
||||
# Verified facts only (from work-history.txt). Renders to a one-page PDF.
|
||||
|
||||
EXPERIENCE = [
|
||||
{'title': 'Water System Technician', 'company': 'Northwest Water Systems Inc',
|
||||
'dates': '2025', 'tracks': ['tech', 'driving'],
|
||||
'bullets': [
|
||||
'Maintained 45 pump houses across WA — install, repair, and troubleshoot water treatment & distribution infrastructure.',
|
||||
'Field inspections, EPA-standard water sampling, emergency response, and compliance documentation.',
|
||||
'Operated pumps, filtration, and chemical-dosing equipment.']},
|
||||
{'title': 'Lead Field Operations Specialist', 'company': 'Lime',
|
||||
'dates': '2023 – 2025', 'tracks': ['tech', 'driving'],
|
||||
'bullets': [
|
||||
'Led daily field operations to keep the scooter/bike fleet available; supervised 5 field agents.',
|
||||
'Coordinated dispatch logistics, GPS-based asset recovery, and route optimization.',
|
||||
'Repaired and replaced vehicle parts; directed drivers via two-way radio.']},
|
||||
{'title': 'Master Grower / Cultivation Manager', 'company': 'Los Angeles, CA (controlled-environment agriculture)',
|
||||
'dates': '2014 – 2023', 'tracks': ['hydro'],
|
||||
'bullets': [
|
||||
'Managed full cultivation cycles (propagation → harvest) and designed custom hydroponic systems.',
|
||||
'Developed nutrient programs; maintained lighting, humidity, airflow, and temperature control.',
|
||||
'Implemented integrated pest management; trained and supervised growers.']},
|
||||
{'title': 'Lead Service Representative', 'company': 'LabCorp',
|
||||
'dates': '2022 – 2023', 'tracks': ['driving', 'tech'],
|
||||
'bullets': [
|
||||
'Ran daily medical specimen pickup routes across hospitals and clinics with strict regulatory compliance.',
|
||||
'Coordinated emergency pickups and route adjustments with dispatch.']},
|
||||
{'title': 'Field Operations / Warehouse Specialist', 'company': 'Aboda',
|
||||
'dates': '2019 – 2022', 'tracks': ['driving', 'tech'],
|
||||
'bullets': [
|
||||
'Managed logistics and setup for corporate housing units across Seattle and Bellevue.',
|
||||
'Operated forklifts; maintained warehouse inventory and move-in-ready quality inspections.']},
|
||||
{'title': 'Sales Manager', 'company': 'Mission Motors',
|
||||
'dates': '2017 – 2019', 'tracks': ['driving'],
|
||||
'bullets': ['Managed vehicle inventory and sales; guided customers through the full purchase process.']},
|
||||
{'title': 'Logistics Coordinator', 'company': 'Salt Works',
|
||||
'dates': '2012', 'tracks': ['driving'],
|
||||
'bullets': ['Loaded pallets into shipping containers and box trucks; operated forklift and cherry picker.']},
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
{'title': 'Self-Hosted Infrastructure (Proxmox homelab)',
|
||||
'bullets': [
|
||||
'Operate a multi-node Proxmox cluster running AI services, databases, websites, and automation.',
|
||||
'Built a self-hosted SaaS ecosystem (Git, automation, cloud storage) with secure remote access.']},
|
||||
{'title': 'AI Automation & Local LLM Pipelines',
|
||||
'bullets': [
|
||||
'Built local-LLM agent workflows and MCP integrations for research, monitoring, and data processing.']},
|
||||
{'title': 'Embedded & RF Systems (ESP32, LoRa / Meshtastic)',
|
||||
'bullets': [
|
||||
'Engineered ESP32 telemetry and sensor devices with custom firmware; built LoRa/Meshtastic mesh networks.']},
|
||||
]
|
||||
|
||||
SUMMARIES = {
|
||||
'tech': 'Self-taught full-stack and embedded systems engineer who builds across the entire stack — '
|
||||
'Python/Flask backends, Linux/Docker/Proxmox infrastructure, ESP32 firmware for embedded devices, '
|
||||
'LoRa/Meshtastic mesh networks, and local AI/LLM agent tooling — on hardware I design and operate myself.',
|
||||
'hydro': 'Self-taught horticulture specialist with 14+ years in hydroponics and controlled-environment '
|
||||
'agriculture — custom grow systems, plant nutrition, environmental control, and high-yield production.',
|
||||
'driving': 'Reliable logistics and field-operations professional with extensive route-driving, fleet, and '
|
||||
'warehouse experience — safety-focused with strong dispatch coordination and problem-solving.',
|
||||
}
|
||||
|
||||
SKILLS = {
|
||||
'tech': ['Embedded firmware (ESP32)', 'LoRa / Meshtastic mesh networks', 'Embedded systems design',
|
||||
'Python', 'Bash', 'Proxmox / virtualization', 'Linux administration', 'Docker',
|
||||
'Networking / SSH / VPN', 'REST APIs', 'Git', 'AI / LLM infrastructure',
|
||||
'Agent & MCP development', 'Monitoring / Grafana', 'Automation pipelines'],
|
||||
'hydro': ['Hydroponic system design', 'Plant propagation', 'Nutrient programs', 'IPM',
|
||||
'Environmental control', 'Grow-facility design', 'Grower training',
|
||||
'Plant-health troubleshooting'],
|
||||
'driving': ['Route driving & delivery', 'Forklift (certified)', 'Fleet tracking & recovery',
|
||||
'Warehouse inventory', 'Field operations', 'GPS route optimization',
|
||||
'Dispatch coordination', 'Customer service'],
|
||||
}
|
||||
|
||||
EDUCATION = 'Lake Stevens High School — Diploma (Computer Applications & Technology)'
|
||||
|
||||
TRACK_HEADLINES = {
|
||||
'tech': 'Full-Stack & Embedded Systems Engineer',
|
||||
'hydro': 'Hydroponics & Controlled-Environment Agriculture Specialist',
|
||||
'driving': 'Logistics & Field Operations Professional',
|
||||
}
|
||||
|
||||
|
||||
def _deterministic_structured(track, job):
|
||||
"""Deterministic one-page structured resume from verified facts (no LLM)."""
|
||||
headline = TRACK_HEADLINES.get(track, TRACK_HEADLINES['tech'])
|
||||
exp = [e for e in EXPERIENCE if track in e['tracks']]
|
||||
resume = {
|
||||
'contact': 'Seattle, WA | 425-280-0023 | indianaholmes1@icloud.com',
|
||||
'headline': headline,
|
||||
'summary': SUMMARIES.get(track, SUMMARIES['tech']),
|
||||
'skills': SKILLS.get(track, SKILLS['tech']),
|
||||
'experience': exp,
|
||||
'education': EDUCATION,
|
||||
}
|
||||
if track == 'tech':
|
||||
resume['projects'] = PROJECTS
|
||||
return resume
|
||||
|
||||
|
||||
def structured_resume(job):
|
||||
"""Deterministic one-page resume from verified facts. (Was LLM-first, but the JSON
|
||||
generation call was slow under MacBook memory pressure and its failure tripped the
|
||||
circuit breaker — blocking the email draft, which is the call that actually matters.
|
||||
The deterministic path is honest and clean, so use it exclusively.)"""
|
||||
track = classify_track(job)
|
||||
return _deterministic_structured(track, job)
|
||||
|
||||
|
||||
def structured_to_text(resume):
|
||||
"""Render a structured resume dict to readable plain text (for display + audit)."""
|
||||
lines = []
|
||||
if resume.get('headline'):
|
||||
lines.append(resume['headline'])
|
||||
if resume.get('summary'):
|
||||
lines.append(resume['summary'])
|
||||
if resume.get('skills'):
|
||||
lines.append('\nSKILLS: ' + ' • '.join(resume['skills']))
|
||||
if resume.get('experience'):
|
||||
lines.append('\nEXPERIENCE:')
|
||||
for e in resume['experience']:
|
||||
lines.append(f" {e.get('title')} — {e.get('company')} ({e.get('dates')})")
|
||||
for b in e.get('bullets', []):
|
||||
lines.append(f" - {b}")
|
||||
if resume.get('projects'):
|
||||
lines.append('\nPROJECTS:')
|
||||
for p in resume['projects']:
|
||||
lines.append(f" {p.get('title')}")
|
||||
for b in p.get('bullets', []):
|
||||
lines.append(f" - {b}")
|
||||
if resume.get('education'):
|
||||
lines.append('\nEDUCATION: ' + resume['education'])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ---- ATS optimization & gap analysis (quality steps) ----
|
||||
|
||||
def extract_job_keywords(description, top_n=15):
|
||||
"""Extract the most signal-bearing keywords from a job description."""
|
||||
import re as _re
|
||||
if not description:
|
||||
return []
|
||||
d = description.lower()
|
||||
freq = {}
|
||||
for kw in llm._skill_keywords():
|
||||
if kw in d:
|
||||
freq[kw] = d.count(kw)
|
||||
# capitalized technical terms (e.g. "Kubernetes", "AWS", "Terraform")
|
||||
for m in _re.findall(r'\b[A-Z][A-Za-z0-9+#./]{2,}\b', description):
|
||||
k = m.lower()
|
||||
if k not in freq and len(k) >= 3:
|
||||
freq[k] = description.lower().count(k)
|
||||
ranked = sorted(freq.items(), key=lambda x: -x[1])
|
||||
return [k for k, _ in ranked[:top_n]]
|
||||
|
||||
|
||||
def ats_coverage(text, keywords):
|
||||
"""Return (coverage 0..1, keywords, missing_keywords) for ATS matching."""
|
||||
if not keywords:
|
||||
return 1.0, [], []
|
||||
t = (text or '').lower()
|
||||
missing = [k for k in keywords if k.lower() not in t]
|
||||
hit = len(keywords) - len(missing)
|
||||
return round(hit / len(keywords), 3), keywords, missing
|
||||
|
||||
|
||||
def candidate_gaps(job):
|
||||
"""Job keywords absent from the candidate's skill surface -> honest fit gaps."""
|
||||
desc = (job.get('description') or '') + ' ' + (job.get('title') or '')
|
||||
kws = extract_job_keywords(desc, top_n=12)
|
||||
_tracks, master = build_master(job)
|
||||
cov, _kws, missing = ats_coverage(master, kws)
|
||||
return {'keywords': kws, 'missing': missing, 'coverage': cov}
|
||||
93
templates/application.html
Normal file
93
templates/application.html
Normal 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">↓ 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 & 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 & 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 ↗</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
76
templates/base.html
Normal 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
58
templates/dashboard.html
Normal 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
26
templates/insights.html
Normal 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
37
templates/job.html
Normal 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
19
templates/library.html
Normal 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
68
templates/settings.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user