The problem
Applying for internships in France means watching France Travail, Welcome to the Jungle, HelloWork, La Bonne Alternance, LinkedIn and Indeed alerts. The same offer shows up four times with different wording, and every application needs its own CV.
JobPilot turns that into a pipeline: pluggable sources, one data model, one review queue, and documents generated per offer.
From 8 source types to one review queue
- 01Sources
- 02Normalize
- 03Deduplicate
- 04Score
- 05Review queue
- 06Tailored CV
Engineering decisions
| DECISION | AREA | WHY |
|---|---|---|
| Idempotent ingestion on content hash and source ID | Data | No double offers |
| One function owns every status change, logged to an audit table | State | Traceability |
| Mail alerts read over IMAP in read-only peek mode | Security | Never alters inbox |
| LLM calls log metadata only, never keys or prompts | Security | No secret leaks |
| Duplicates matched on mission text, not company boilerplate | Matching | Real dedup |
| Every source implements one interface, mockable in tests | Architecture | Pluggable |
Straight from the code
Excerpts from the actual repository and deliverables, trimmed for length.
def transition(
db: sqlite3.Connection,
application_id: int,
to_status: str,
*,
detail: dict | None = None,
) -> str:
"""Move an application to to_status if legal; log a status_change event."""
frm = current_status(db, application_id)
allowed = LEGAL.get(frm, set())
if to_status not in allowed:
raise IllegalTransition(
f"application {application_id}: {frm} -> {to_status} not allowed "
f"(legal: {sorted(allowed) or 'none, terminal'})"
)
now = _utc_now()
db.execute(
"UPDATE applications SET status = ?, last_event_at = ? WHERE id = ?",
(to_status, now, application_id),
)
payload = {"from": frm, "to": to_status, **(detail or {})}
log_event(db, application_id, "status_change", payload, commit=False)
db.commit()
return to_statusThe only function allowed to change an application status: illegal transitions raise, and every change is written to the audit log in the same transaction.
Results
What I'd do differently
Collecting offers is easy, knowing that two different postings are the same job is the real problem. Next: a cleaner web dashboard, and sharing the matching engine as a standalone library.