Files
procyon/app.py
2026-10-06 23:43:21 -07:00

271 lines
11 KiB
Python

# 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)