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