Snapshot: full project state
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user