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.
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.
Source engineering roles
→Deterministic fit gates
→Candidate evidence
→Provider failover
→Schema + coverage
→Human approval boundary
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.
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.
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 bullets03 / 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.
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.
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 vetting04 / 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.