On-demand Scout service (replaces the nightly aggregator): - FastAPI agent-mesh service; card name "matchmaking-service" (HTTP /a2a/tasks + Redis-stream worker matching the sibling-service pattern) - run_search: parallel multi-board sweep (Naukri/blackfalcondata + Foundit + LinkedIn), city-filtered at the board, cheapest-per-result first - per-board adapters + normalizers -> ScoutJob with rich read-only details and offsite apply links; recall mode + MVQ guard - result cache for dev replay ($0); per-board cost knobs; retry-on-5xx - research/: engine design, board cost economics, India-actor shortlist, POC
234 lines
8.6 KiB
Python
234 lines
8.6 KiB
Python
"""
|
|
Auto-apply POC — proves the "one tap at the end" works end-to-end.
|
|
|
|
What this proves (and what it does NOT):
|
|
✅ One tap -> backend assembles the staged application kit -> apply-router picks the
|
|
right mechanism (hosted ATS submit vs prepared board handoff) -> submits -> confirmation
|
|
-> tracked, with measured latency.
|
|
✅ The apply-router BRANCHING that is the architectural heart of the no-install web app.
|
|
❌ It does NOT submit to a real employer. The "ATS" here is a MOCK we control
|
|
(route: /mock-ats/...). POC-1 swaps ATS_SUBMIT_URL for a real ATS *sandbox* job.
|
|
|
|
HARD RULE: never fire test applications at real employers' live postings.
|
|
|
|
Run:
|
|
pip install fastapi uvicorn httpx
|
|
uvicorn app:app --port 8765
|
|
open http://localhost:8765
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
import uuid
|
|
from typing import Literal
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import HTMLResponse
|
|
from pydantic import BaseModel
|
|
|
|
app = FastAPI(title="Scout auto-apply POC")
|
|
|
|
# Swap this for a real ATS sandbox endpoint in POC-1. Default = our local mock.
|
|
ATS_SUBMIT_URL = os.environ.get(
|
|
"ATS_SUBMIT_URL", "http://localhost:8765/mock-ats/greenhouse/acme-test/eng-42"
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Demo data: two jobs, one routed to an ATS (hosted submit), one to a board
|
|
# (prepared handoff). In production these come from the on-demand listings fetch.
|
|
# ---------------------------------------------------------------------------
|
|
JOBS = {
|
|
"job_ats": {
|
|
"title": "Product Analyst",
|
|
"company": "Acme (Greenhouse)",
|
|
"source": "ats", # -> hosted submit
|
|
"ats_vendor": "greenhouse",
|
|
},
|
|
"job_board": {
|
|
"title": "Associate PM",
|
|
"company": "Globex (Naukri)",
|
|
"source": "board", # -> prepared handoff
|
|
"board": "naukri",
|
|
"apply_url": "https://www.naukri.com/job-listings-associate-pm-globex",
|
|
},
|
|
}
|
|
|
|
# The "application kit" — staged BEFORE the tap. In production: tailored résumé from
|
|
# resume-builder + field answers from the user's profile.
|
|
KIT = {
|
|
"candidate": {
|
|
"first_name": "Asha",
|
|
"last_name": "Rao",
|
|
"email": "asha.rao@example.com",
|
|
"phone": "+91-90000-00000",
|
|
},
|
|
"resume_url": "https://files.growqr.ai/resumes/asha-rao-tailored.pdf",
|
|
"answers": {
|
|
"years_experience": "3",
|
|
"work_authorization": "India - citizen",
|
|
"notice_period_days": "30",
|
|
},
|
|
}
|
|
|
|
# In-memory application tracker (the "pipeline" view).
|
|
TRACKER: list[dict] = []
|
|
|
|
|
|
class ApplyResult(BaseModel):
|
|
status: Literal["submitted", "prepared"]
|
|
mechanism: Literal["hosted_ats", "board_handoff"]
|
|
job_id: str
|
|
confirmation_id: str | None = None
|
|
deeplink: str | None = None
|
|
staged: dict | None = None
|
|
latency_ms: int
|
|
|
|
|
|
@app.get("/api/kit/{job_id}")
|
|
def get_kit(job_id: str):
|
|
"""The frontend shows 'kit ready' before the tap."""
|
|
if job_id not in JOBS:
|
|
raise HTTPException(404, "unknown job")
|
|
job = JOBS[job_id]
|
|
return {
|
|
"job": {"id": job_id, "title": job["title"], "company": job["company"]},
|
|
"ready": {
|
|
"resume": True,
|
|
"cover_letter": True,
|
|
"fields_filled": len(KIT["answers"]),
|
|
},
|
|
"mechanism": "hosted_ats" if job["source"] == "ats" else "board_handoff",
|
|
}
|
|
|
|
|
|
@app.post("/api/apply/{job_id}", response_model=ApplyResult)
|
|
async def apply(job_id: str):
|
|
"""THE ONE TAP. Routes to hosted submit or prepared handoff."""
|
|
if job_id not in JOBS:
|
|
raise HTTPException(404, "unknown job")
|
|
job = JOBS[job_id]
|
|
t0 = time.perf_counter()
|
|
|
|
if job["source"] == "ats":
|
|
# Hosted submit: backend POSTs the full application to the ATS API.
|
|
payload = {
|
|
"first_name": KIT["candidate"]["first_name"],
|
|
"last_name": KIT["candidate"]["last_name"],
|
|
"email": KIT["candidate"]["email"],
|
|
"phone": KIT["candidate"]["phone"],
|
|
"resume_url": KIT["resume_url"],
|
|
"answers": KIT["answers"],
|
|
}
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.post(ATS_SUBMIT_URL, json=payload)
|
|
if resp.status_code >= 400:
|
|
raise HTTPException(502, f"ATS rejected: {resp.status_code} {resp.text}")
|
|
confirmation_id = resp.json().get("confirmation_id")
|
|
latency_ms = int((time.perf_counter() - t0) * 1000)
|
|
result = ApplyResult(
|
|
status="submitted",
|
|
mechanism="hosted_ats",
|
|
job_id=job_id,
|
|
confirmation_id=confirmation_id,
|
|
latency_ms=latency_ms,
|
|
)
|
|
else:
|
|
# Prepared handoff: stage everything, hand the user a deep link to the board.
|
|
latency_ms = int((time.perf_counter() - t0) * 1000)
|
|
result = ApplyResult(
|
|
status="prepared",
|
|
mechanism="board_handoff",
|
|
job_id=job_id,
|
|
deeplink=job["apply_url"],
|
|
staged={"resume_url": KIT["resume_url"], "answers": KIT["answers"]},
|
|
latency_ms=latency_ms,
|
|
)
|
|
|
|
TRACKER.append(
|
|
{
|
|
"job_id": job_id,
|
|
"company": job["company"],
|
|
"status": result.status,
|
|
"confirmation_id": result.confirmation_id,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
@app.get("/api/tracker")
|
|
def tracker():
|
|
return {"applications": TRACKER}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MOCK ATS — stands in for Greenhouse/Lever/Ashby. POC-1 deletes this and points
|
|
# ATS_SUBMIT_URL at a real *sandbox* job. The contract mirrors Greenhouse's job-board
|
|
# application submission shape (first/last/email/phone/resume + question answers).
|
|
# ---------------------------------------------------------------------------
|
|
@app.post("/mock-ats/greenhouse/{board}/{job}")
|
|
def mock_ats_submit(board: str, job: str, payload: dict):
|
|
required = ["first_name", "last_name", "email", "resume_url"]
|
|
missing = [f for f in required if not payload.get(f)]
|
|
if missing:
|
|
raise HTTPException(422, f"missing required fields: {missing}")
|
|
return {
|
|
"accepted": True,
|
|
"board": board,
|
|
"job": job,
|
|
"confirmation_id": f"GH-{uuid.uuid4().hex[:10]}",
|
|
}
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index():
|
|
return INDEX_HTML
|
|
|
|
|
|
INDEX_HTML = """<!doctype html>
|
|
<html><head><meta charset="utf-8"><title>Scout — one-tap apply POC</title>
|
|
<style>
|
|
body{font-family:system-ui,sans-serif;max-width:560px;margin:40px auto;color:#1a1a1a}
|
|
.card{border:1px solid #eee;border-radius:14px;padding:18px;margin:14px 0;box-shadow:0 1px 4px #0001}
|
|
.ready{color:#0a7d34;font-size:14px}
|
|
button{background:#ea580c;color:#fff;border:0;border-radius:10px;padding:12px 18px;font-size:16px;cursor:pointer}
|
|
button:disabled{opacity:.6}
|
|
.out{font-family:ui-monospace,monospace;font-size:13px;background:#faf7f5;border-radius:8px;padding:10px;margin-top:10px;white-space:pre-wrap}
|
|
h2{margin:.2em 0}
|
|
</style></head><body>
|
|
<h1>🕵️ Scout — one-tap apply</h1>
|
|
<p>Everything is staged before the tap. The tap <b>ends</b> the application.</p>
|
|
<div id="cards"></div>
|
|
<script>
|
|
const JOBS = ["job_ats","job_board"];
|
|
async function load(){
|
|
const root = document.getElementById('cards'); root.innerHTML='';
|
|
for(const id of JOBS){
|
|
const kit = await (await fetch('/api/kit/'+id)).json();
|
|
const div = document.createElement('div'); div.className='card';
|
|
div.innerHTML = `<h2>${kit.job.title}</h2><div>${kit.job.company}</div>
|
|
<div class="ready">✓ Résumé ready · ✓ Cover letter ready · ✓ ${kit.ready.fields_filled} fields filled
|
|
· ${kit.mechanism==='hosted_ats'?'🛰️ hosted submit':'🔗 prepared handoff'}</div>
|
|
<p><button onclick="apply('${id}',this)">🚀 Apply (one tap)</button></p>
|
|
<div class="out" id="out-${id}" style="display:none"></div>`;
|
|
root.appendChild(div);
|
|
}
|
|
}
|
|
async function apply(id,btn){
|
|
btn.disabled=true; btn.textContent='…';
|
|
const r = await (await fetch('/api/apply/'+id,{method:'POST'})).json();
|
|
const out = document.getElementById('out-'+id); out.style.display='block';
|
|
if(r.status==='submitted'){
|
|
out.textContent = `✅ APPLIED — confirmation ${r.confirmation_id}\\nmechanism: ${r.mechanism} · ${r.latency_ms}ms`;
|
|
btn.textContent='✓ Applied';
|
|
} else {
|
|
out.textContent = `🔗 PREPARED — tap to finish on the board:\\n${r.deeplink}\\nstaged résumé + ${Object.keys(r.staged.answers).length} answers · ${r.latency_ms}ms`;
|
|
btn.textContent='Open board →'; btn.disabled=false;
|
|
btn.onclick=()=>window.open(r.deeplink,'_blank');
|
|
}
|
|
}
|
|
load();
|
|
</script>
|
|
</body></html>"""
|