The system in one paragraph
A mobile client sends a resume, and later a single job description, to a backend that keeps a continuously refreshed index of postings pulled from licensed job-board and ATS APIs. Every posting gets a cheap, instant score against the resume. Opening a specific role can trigger one on-demand call to Claude for a deeper written analysis and resume rewrite. Contact details and applicant-comparison stats come from the same licensed portal APIs - never scraped. Nothing is sent to an employer or a recruiter without the person tapping send.
Architecture
Components
Resume Service
Parses pasted or uploaded resumes into structured fields (skills, titles, dates). Scoring runs against this structured form, not raw string search.
Matching Engine
Computes the instant, free score shown in the list - skill overlap, seniority, location/work-style fit, freshness. No LLM involved.
AI Analysis Service
On-demand only. Calls Claude with the resume + one full job description, returns a strict-JSON gap analysis and rewrite suggestions. The only component with LLM cost.
Contact & Applicant-Stats Service
Thin adapter over each portal's official API. Returns "unavailable" honestly when a portal doesn't expose contact or applicant data - never backfills with a guess.
Job Ingestion Pipeline
Scheduled polling of job-board/ATS APIs. Normalizes, dedupes cross-posted roles, and stamps first_seen_at once, honestly, at true ingestion time.
Data layer
Postgres for relational data, Redis for score caching and rate limits, object storage for raw resume files.
Core data model
| Entity | Key fields |
|---|---|
| User | preferences: locations[], work_style[] |
| Resume | raw_text, structured_json, updated_at |
| JobPosting | source_portal, title, company_id, location, work_style, skills[], first_seen_at, source_url |
| Company | name, summary, size_band, site_url |
| Match | score, matched_skills[], missing_skills[], computed_at |
| DeepAnalysis | match_percent, call_likelihood, resume_suggestions[], model_version |
| Contact | name, role, source_portal, retrieved_at |
| ApplicantStats | applicant_count, avg_experience_years, source_portal |
Key flows
Find matches
- Client sends resume text + preferences to the Matching Engine.
- Engine scores every active posting, applies preference adjustments, returns a ranked list.
- Client renders the list - no LLM call has happened yet.
AI deep-dive (per role, on demand)
- User taps "Run live AI analysis" on one job.
- Client requests a DeepAnalysis for (resume, job).
- Service checks cache; on miss, calls Claude, parses the strict-JSON reply, stores and returns it.
- Client renders the written analysis and rewrite suggestions.
Job ingestion (continuous, background)
- Scheduler polls each connected portal/ATS API on its own cadence.
- New or changed postings are normalized and deduplicated against existing listings.
- Postings unseen for N polls are marked stale rather than deleted, so existing Match records stay valid.
Non-functional requirements
Cost control
The heuristic score does the bulk of ranking; the LLM is reserved for explicit deep-dives, keeping spend proportional to real engagement.
Latency
Matches render from cached scores in well under a second; a deep-dive is allowed a few seconds with a clear loading state.
Privacy
Resumes are PII. Encrypted at rest/in transit, full deletion supported, never shared beyond the LLM call itself.
ToS compliance
Enforced architecturally - the Contact & Applicant-Stats Service only has adapters for licensed APIs, so no code path can scrape.
Suggested stack
What I'd reconsider at real scale
Cross-portal dedup (the same role posted on LinkedIn and a company's own site) needs fuzzy matching in practice - easy to either merge distinct roles or miss real duplicates.
Applicant-comparison stats are only as good as what each portal exposes; the honest empty state needs to be a well-designed common path, not an edge case.
Ingestion polling cadence is a real cost/staleness trade-off per portal, worth tuning against actual rate-limit data rather than an illustrative range.