Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,8 @@ async def execute_tool(name: str, tool_input: dict) -> dict:
"letter": letter,
"archetype": archetype,
"review": review,
# What the letter may say about the company beyond the ad: its fact check needs it.
"research": company_research,
}

elif name == "generate_skill_roadmap":
Expand Down
477 changes: 477 additions & 0 deletions api/app/autopilot.py

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions api/app/browser_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,37 @@ def _with_empty_fields(notes: str, empty: list[str], left_blank: list[str] = ())
return "\n\n".join(blocks + [notes]).strip()


# Unattended runs (autopilot.py): once the form is filled, `decide` is shown what the fill left behind —
# {ready, error, notes, questions, empty} — and says whether to send it. Nobody reviews it in between.
Decider = Callable[[dict], Awaitable[bool]]


async def _finish_unattended(decide: Decider, filled: dict, browser_session, llm, sensitive_data: dict | None,
secret: str) -> AsyncGenerator[dict, None]:
"""
Send the filled form if `decide` says to, else leave it. The last event is done=True and carries the
fill's results plus `form_screenshot` (the filled form, before anything is sent); when the submit agent
ran, also `submit_attempted`, `submitted` (only if the page confirmed it), `submit_detail` and
`confirm_screenshot` (the page after the click).
"""
final = {"action": "Filled in; not sent", "screenshot": None, "blocked": False, "reason": None, "done": True,
"submitted": False, "submit_attempted": False, **filled,
"form_screenshot": await _screenshot_b64(browser_session)}
try:
send = await decide(filled)
except Exception as exc:
logger.warning("autopilot decision failed: %s", exc)
send = False
if send:
yield {"action": "Submitting the application…", "screenshot": None, "blocked": False, "reason": None,
"done": False}
ok, detail = await _submit_with_agent(llm, browser_session, sensitive_data, secret)
final.update(submit_attempted=True, submitted=ok, submit_detail=detail,
confirm_screenshot=await _screenshot_b64(browser_session),
action="Application submitted" if ok else "Couldn't confirm the submission")
yield final


async def apply_with_browser(
job_url: str,
cv_profile: dict,
Expand All @@ -1073,12 +1104,15 @@ async def apply_with_browser(
cv_path: str = "",
cover_letter_path: str = "",
answerer: Answerer | None = None,
decide: Decider | None = None,
) -> AsyncGenerator[dict, None]:
"""
Async generator: the browser-use agent fills the application, then the user reviews it and
decides whether to submit (see module docstring). The CV and cover-letter PDFs are uploaded
under the candidate's name ("Ada_Lovelace_CV.pdf"), not their stored ids. `answerer` answers the
form's questions (answers.answer_questions); without one, every question is left for the user.
With `decide` the run is unattended: there is no review, and `decide` says whether the filled form is
sent (see _finish_unattended for what the last event then carries).
Yields dicts:
{ action, screenshot (base64|None), blocked (bool), reason (str|None), done (bool),
interactive (bool, optional), submitted (bool, only on the final done event),
Expand Down Expand Up @@ -1186,6 +1220,12 @@ async def on_step(browser_state, agent_output, step_number):
left_blank = [q["question"] for q in questions if q["source"] == "needs_you"]
notes = _with_empty_fields(summary, empty, left_blank)[:_MAX_NOTES_CHARS]

if decide is not None:
filled = {"ready": ready, "error": agent_error, "notes": notes, "questions": questions, "empty": empty}
async for step in _finish_unattended(decide, filled, browser_session, llm, sensitive_data, job_password):
yield step
return

async def put_answers_on_form(answers: list[dict]) -> dict:
ok, detail = await _fill_with_agent(llm, browser_session, sensitive_data, answers, job_password)
for a in answers:
Expand Down
37 changes: 37 additions & 0 deletions api/app/jobs_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,40 @@ def store_attributes(job_db_id: str, attributes: dict | None) -> None:
attributes["clearance"], attributes["years_min"], attributes["degree"], ATTRIBUTES_VERSION,
job_db_id))
con.close()


async def ensure_full_description(job: dict) -> dict:
"""
Make sure a job has its real, full description before we analyse it.

Stored descriptions are capped at 500 chars at sync time (and Brave results
are only a web snippet), so anything that short is re-fetched from the live
listing page. The result is persisted back to the DB so re-runs and revisits
are instant.

The jobs table is shared by all users, so the client-supplied job is never
trusted for the write: we only persist when the id matches a stored row, and
then we fetch that row's *stored* URL — never the URL from the request body.
"""
desc = (job.get("description") or "").strip()
if len(desc) > 600: # already a full description (e.g. previously fetched)
return job

from .job_searcher import fetch_full_description
db_id = str(job.get("id") or "")
stored = get_job(db_id) if db_id else None
if stored:
stored_desc = (stored.get("description") or "").strip()
if len(stored_desc) > 600: # another request already fetched it
return {**job, "description": stored_desc}
full = await fetch_full_description(stored.get("url") or "")
if full and len(full) > len(stored_desc):
update_description(db_id, full)
return {**job, "description": full}
return job

# Not a stored job (e.g. pasted via /jobs/from-url): enrich this request only.
full = await fetch_full_description(job.get("url", ""))
if full and len(full) > len(desc):
job = {**job, "description": full}
return job
127 changes: 86 additions & 41 deletions api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .guardrails import check_cv_for_injection, GuardrailViolation
from . import tracker
from . import answers
from . import autopilot
from . import auth
from . import pdf_generator
from . import doc_render
Expand Down Expand Up @@ -50,7 +51,8 @@ async def lifespan(app: FastAPI):
jobs_db.init_db()
pdf_generator.init_pdf_dir(data_dir)
background = [asyncio.create_task(sponsor_register.keep_fresh()),
asyncio.create_task(job_attributes.keep_extracting())]
asyncio.create_task(job_attributes.keep_extracting()),
asyncio.create_task(autopilot.keep_running())]
yield
for task in background:
task.cancel()
Expand Down Expand Up @@ -813,43 +815,6 @@ def _sse_json(event_type: str, data: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"


async def _ensure_full_description(job: dict) -> dict:
"""
Make sure a job has its real, full description before we analyse it.

Stored descriptions are capped at 500 chars at sync time (and Brave results
are only a web snippet), so anything that short is re-fetched from the live
listing page. The result is persisted back to the DB so re-runs and revisits
are instant.

The jobs table is shared by all users, so the client-supplied job is never
trusted for the write: we only persist when the id matches a stored row, and
then we fetch that row's *stored* URL — never the URL from the request body.
"""
desc = (job.get("description") or "").strip()
if len(desc) > 600: # already a full description (e.g. previously fetched)
return job

from .job_searcher import fetch_full_description
db_id = str(job.get("id") or "")
stored = jobs_db.get_job(db_id) if db_id else None
if stored:
stored_desc = (stored.get("description") or "").strip()
if len(stored_desc) > 600: # another request already fetched it
return {**job, "description": stored_desc}
full = await fetch_full_description(stored.get("url") or "")
if full and len(full) > len(stored_desc):
jobs_db.update_description(db_id, full)
return {**job, "description": full}
return job

# Not a stored job (e.g. pasted via /jobs/from-url): enrich this request only.
full = await fetch_full_description(job.get("url", ""))
if full and len(full) > len(desc):
job = {**job, "description": full}
return job


@app.post("/jobs/from-url")
async def job_from_url(
body: dict,
Expand Down Expand Up @@ -884,7 +849,7 @@ async def analyze_job(
current_user: Annotated[auth.User, Depends(auth.get_current_user)],
):
"""SSE: score fit + AI risk for a single job against the user's persona."""
job = await _ensure_full_description(body.get("job", {}))
job = await jobs_db.ensure_full_description(body.get("job", {}))
persona = tracker.get_user_persona(current_user.id)

async def gen():
Expand Down Expand Up @@ -917,7 +882,7 @@ async def generate_cover_letter_endpoint(
current_user: Annotated[auth.User, Depends(auth.get_current_user)],
):
"""SSE: generate a tailored cover letter for a job."""
job = await _ensure_full_description(body.get("job", {}))
job = await jobs_db.ensure_full_description(body.get("job", {}))
tone = body.get("tone", "professional")
persona = tracker.get_user_persona(current_user.id)

Expand All @@ -937,7 +902,7 @@ async def generate_tailored_cv_endpoint(
current_user: Annotated[auth.User, Depends(auth.get_current_user)],
):
"""SSE: generate a tailored CV (Markdown) for a specific job."""
job = await _ensure_full_description(body.get("job", {}))
job = await jobs_db.ensure_full_description(body.get("job", {}))
persona = tracker.get_user_persona(current_user.id)

async def gen():
Expand Down Expand Up @@ -1124,6 +1089,86 @@ async def draft_answers(body: dict, current_user: Annotated[auth.User, Depends(a
from fastapi.responses import FileResponse # noqa: E402


# ── Autopilot ─────────────────────────────────────────────────────────────────

# What the user can still do with a run: retry it (autopilot tries the job again) or clear it from the queue.
_RETRYABLE = {"needs_you", "failed", "skipped", "ready", "dismissed"}
_DISMISSABLE = {"needs_you", "failed", "skipped", "ready", "unconfirmed"}


@app.get("/autopilot")
async def autopilot_status(current_user: Annotated[auth.User, Depends(auth.get_current_user)]):
"""Autopilot's settings, whether a run is going (and what it's doing), and how many went out today."""
return autopilot.status(current_user.id)


@app.put("/autopilot/settings")
async def autopilot_settings(body: dict, current_user: Annotated[auth.User, Depends(auth.get_current_user)]):
"""Change any of {enabled, live, min_fit, daily_cap}; returns them all."""
try:
return autopilot.save_settings(current_user.id, body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))


@app.post("/autopilot/run", status_code=202)
async def autopilot_run(current_user: Annotated[auth.User, Depends(auth.get_current_user)]):
"""Start a run now, in the background (GET /autopilot follows it)."""
if not autopilot.start(current_user.id):
raise HTTPException(status_code=409, detail="Autopilot is already running")
return {"started": True}


@app.get("/autopilot/runs")
async def autopilot_runs(current_user: Annotated[auth.User, Depends(auth.get_current_user)]):
"""Every job autopilot has dealt with, latest first, with which screenshots it kept."""
runs = tracker.list_autopilot_runs(current_user.id)
for run in runs:
run["screenshots"] = [w for w in autopilot.SCREENSHOTS if os.path.exists(autopilot.screenshot_path(run, w))]
run.pop("user_id", None)
return runs


def _own_run(user_id: str, run_id: str) -> dict:
run = tracker.get_autopilot_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail="No such autopilot run")
return run


@app.post("/autopilot/runs/{run_id}/retry", status_code=202)
async def autopilot_retry(run_id: str, current_user: Annotated[auth.User, Depends(auth.get_current_user)]):
"""Have autopilot try this job again now: after you've answered its questions, say."""
run = _own_run(current_user.id, run_id)
if run["outcome"] not in _RETRYABLE:
raise HTTPException(status_code=400, detail=f"A run that is {run['outcome']} can't be retried")
if not autopilot.start(current_user.id, run["job_id"]):
raise HTTPException(status_code=409, detail="Autopilot is already running")
return {"started": True}


@app.post("/autopilot/runs/{run_id}/dismiss")
async def autopilot_dismiss(run_id: str, current_user: Annotated[auth.User, Depends(auth.get_current_user)]):
"""Clear a run from the queue. Autopilot won't try the job again unless you retry it."""
run = _own_run(current_user.id, run_id)
if run["outcome"] not in _DISMISSABLE:
raise HTTPException(status_code=400, detail=f"A run that is {run['outcome']} can't be dismissed")
tracker.update_autopilot_run(run_id, user_id=current_user.id, outcome="dismissed")
return {"dismissed": run_id}


@app.get("/autopilot/runs/{run_id}/screenshots/{which}")
async def autopilot_screenshot(run_id: str, which: str,
current_user: Annotated[auth.User, Depends(auth.get_current_user)]):
"""A run's screenshot: 'form' (the filled form) or 'confirm' (the page after it was sent)."""
if which not in autopilot.SCREENSHOTS:
raise HTTPException(status_code=404, detail="No such screenshot")
path = autopilot.screenshot_path(_own_run(current_user.id, run_id), which)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="No such screenshot")
return FileResponse(path, media_type="image/jpeg")


@app.get("/documents/{doc_id}/download")
async def download_pdf(
doc_id: str,
Expand Down
Loading
Loading