← All projects

Case study / Agentic systems

Job Application Agent

A human-gated workflow that automates discovery, fit analysis, and truthful resume tailoring—then deliberately stops before submission.

Year
2025 — present
Role
Design · architecture · implementation
System
Human-gated agent workflow
Stack
Python · FastAPI · Gemini · Groq · Next.js · PostgreSQL · Playwright

01 / Problem

A high-volume, low-signal pipeline.

Reading job descriptions, judging fit, and rewriting the same resume for the hundredth time is mechanical work. Deciding to apply, and talking to people, is not.

The system automates exactly the first category. Every consequential action ends at a visible review boundary where a person decides what happens next.

02 / Execution model

System architecture

Signal moves left to right. Trust is added at every boundary. The final node does not submit—it waits.

Illustrative execution path · human approval remains terminal
01Discover

Source engineering roles

02Filter

Deterministic fit gates

03Retrieve

Candidate evidence

04Generate

Provider failover

05Validate

Schema + coverage

06Review

Human approval boundary

provider failover / armedschema gate / strictsubmit / human locked

03 / Decisions

Engineering the edges

The model call is the smallest box in the diagram. Reliability lives in everything around it.

01 / resilience

Failover over uptime promises

Provider abstraction falls back from Gemini to Groq on rate limits, timeouts, or malformed responses. A provider outage degrades throughput instead of stopping the run.

implementation.pyvalidated
async def call_llm_with_failover(prompt: str, schema: Type[T]) -> T:
    providers = [GeminiProvider(), GroqProvider()]
    for provider in providers:
        try:
            raw = await provider.generate(prompt, timeout=12.0)
            return schema.model_validate_json(raw)
        except (RateLimitError, ValidationError, TimeoutError) as error:
            logger.warning("%s failed: %s", provider.name, error)
    raise LLMFailoverExhaustedError("All providers failed generation gates")

02 / validation

Schema before trust

Every generation is validated against a known schema. Malformed output is retried against the fallback provider rather than silently accepted into the database.

implementation.pyvalidated
class TailoredResumeSchema(BaseModel):
    match_percentage: float = Field(..., ge=0, le=100)
    tailored_bullet_points: list[str] = Field(..., min_length=3, max_length=6)
    missing_required_skills: list[str]
    adjacent_evidence: list[str]

    @field_validator("tailored_bullet_points")
    def verify_bullets(cls, bullets):
        if any(len(bullet.strip()) < 10 for bullet in bullets):
            raise ValueError("Bullet point too short or uninformative")
        return bullets

03 / guardrails

Guardrails against plausibility

The system forbids invented companies, dates, degrees, or metrics. A missing requirement is surfaced as a gap and matched to truthful adjacent evidence—not rewritten as fictional experience.

implementation.pyvalidated
SYSTEM_RULES = """
- Never invent employers, titles, dates, credentials, or percentages.
- If direct experience is absent, add the skill to missing_required_skills.
- Use adjacent_evidence only when it exists in the candidate profile.
- Every claim must point back to a source profile field.
"""

audit = verify_claims(generation, candidate_profile)
if audit.unsupported_claims:
    raise UnsupportedEvidenceError(audit.unsupported_claims)

04 / efficiency

Cheap filters before expensive ones

Rules remove management roles, staffing firms, and out-of-band seniority before a token is spent, so model cost scales with plausible candidates rather than raw scrape volume.

implementation.pyvalidated
def deterministic_pre_filter(job: JobListing) -> bool:
    excluded_titles = {"director", "vp", "head of", "recruiter"}
    excluded_companies = {"staffing agency", "global talent corp"}

    if any(term in job.title.lower() for term in excluded_titles):
        return False
    if job.company.lower() in excluded_companies:
        return False
    return True  # proceed to model-assisted vetting

04 / Reflection

What changes next

Relational tables alone create contention under concurrent scraping workers. The next iteration moves workflow state into an append-only event log backed by Redis Streams while PostgreSQL remains the system of record for candidate artifacts.

Site-specific Playwright scripts also demand ongoing maintenance. Structured browser tool calling with visual DOM feedback is the more resilient path, while the same explicit human approval boundary stays intact.

Source and next route

View source All projects