Recruit Assistant · Engineering Reference
System topology, data model, and request flows — for reading the codebase, not for a portfolio audience. See how-it-works.html for the narrative version.
One FastAPI process, modular internally by domain — not a microservices split. Binds to 127.0.0.1 by default, not every network interface.
graph TB
Browser([Browser]) -->|":5173 dev"| Frontend[React Frontend]
Frontend -->|"/api/v1/*, /health
Bearer token"| Backend["FastAPI Backend
127.0.0.1:8000"]
Backend --> SQLite[(SQLite)]
Backend --> Mirror[(Local file mirror
data/candidates/)]
Backend --> Keychain[(OS Keychain)]
Backend --> LLM{LLM Client}
Backend --> Gmail[Gmail API]
Backend --> Graph[Microsoft Graph API]
LLM -->|primary| OpenRouter[OpenRouter]
LLM -->|fallback| OpenAI[OpenAI]
LLM -->|USE_MOCK_LLM=true| Mock[MockLLMClient
zero external calls]
subgraph modules["backend/app/ — single process, DI via dependencies.py"]
direction LR
Auth[auth/]
Scanning[scanning/]
Matching[matching/]
Storage[storage/]
Dashboard[dashboard/]
Scheduler[scheduler/]
end
Backend -.-> modules
style Frontend fill:#4338CA,color:#fff
style Backend fill:#1C2333,color:#fff
style Mock fill:#8991A6,color:#fff
style OpenRouter fill:#1E7F4F,color:#fff
Full module inventory: backend-architecture.md. Why one process, not several: design-decisions.md ADR-001.
SQLite via SQLAlchemy. Every column addition on this project auto-migrates on startup via a schema-driven diff against PRAGMA table_info — no manual migration scripts anywhere in this history.
erDiagram
USER ||--o{ MATCH : "single account"
JOB ||--o{ MATCH : scored_against
JOB ||--o{ JOB_CRITERION : has
CANDIDATE ||--o{ MATCH : scored
CANDIDATE ||--o{ RESUME_SOURCE : "sourced from"
CRITERION ||--o{ JOB_CRITERION : selected_as
EMAIL_ACCOUNT ||--o{ SCHEDULED_SOURCE : "opts into"
USER {
string id PK
string email
string password_hash
datetime real_llm_consent_given_at "nullable, one-time"
}
JOB {
string id PK
string title
string company
bool active "soft-delete"
int criteria_version
}
CANDIDATE {
string id PK
string identity_fingerprint UK
json recruiter_notes
json history
json embedding "cached, reused across runs"
}
RESUME_SOURCE {
string id PK
string candidate_id FK
string content_hash "dedup key"
string file_path "mirror on disk"
}
MATCH {
string id PK
string job_id FK
string candidate_id FK
string tier "quality: poor to great"
string pipeline_stage "status: sourced to placed"
json flags
}
CRITERION {
string id PK
bool is_builtin
}
JOB_CRITERION {
string job_id FK
string criterion_id FK
bool enabled
}
delete-orphan)
Match.tier (quality) and Match.pipeline_stage (status) are deliberately separate columns on the same row, not derived from one another — see design-decisions.md ADR-011. Deleting a Candidate cascades to its Match and ResumeSource rows automatically; the on-disk mirror files are cleaned up separately (see §06).
Both sources converge on the same orchestration function and produce the same on-disk shape.
sequenceDiagram
participant Src as Folder or Mailbox
participant Ingestor as ResumeIngestor
participant Svc as ingest_service.run_scan
participant Parser as parser.extract_text
participant ID as identity_resolution
participant Mirror as mirror_writer
participant DB as SQLite
Src->>Ingestor: yield IngestedResume
Ingestor->>Svc: async for ingested in scan()
Svc->>Parser: extract_text(file_bytes)
Parser-->>Svc: raw_text (or OCR fallback)
Svc->>Svc: parse_resume() → CandidateProfile
Svc->>ID: compute_fingerprint(profile)
ID-->>Svc: existing candidate or new
Svc->>Mirror: write_mirror(resume, summary, meta.json)
Mirror-->>Svc: file_path
Svc->>DB: add Candidate/ResumeSource (no flush)
Note over Svc,DB: checkpoint every 500 resumes,
plus once at the end — not per-resume commits
Svc->>DB: commit()
Currently sequential per resume (parse → summarize → mirror-write, one at a time) — the primary lever in reports/scan-match-speed-plan.md. Mailbox fetch itself is already concurrent (bounded_gather, ~15 in flight).
Embedding similarity does the cheap first pass over the whole pool; the LLM only scores the shortlist, and the most expensive model only reviews borderline calls.
flowchart LR
A[Full candidate pool] -->|"embed once,
cached on Candidate row"| B["Cosine-similarity
pre-filter"]
B -->|"top_n × 3
(SHORTLIST_MULTIPLIER)"| C["Deep LLM score
(deep_score, bounded_gather)"]
C -->|"score 40–70
only"| D["Judge re-score
(judge_score)"]
C -->|"score <40 or >70"| F[Final tier]
D --> F
F --> G[(Match row:
tier + pipeline_stage)]
style B fill:#EEECFB,stroke:#4338CA
style C fill:#F7ECE3,stroke:#A34E1F
style D fill:#FBF0DC,stroke:#9A6206
Concurrency at the deep-score and judge stages is capped by max_concurrent_llm_calls (default 8) — see design-decisions.md ADR-006, and the speed plan for concrete levers on this stage (retry/backoff, batching, prompt size).
Routes are thin — they depend on domain modules via dependencies.py's DI getters, never the reverse.
graph TB
Routes["routes/
(one file per resource)"] --> DI[dependencies.py]
DI --> Storage["storage/
BaseStorageBackend"]
DI --> LLMc["matching/llm_client.py"]
DI --> Settings["config.py
Settings"]
Routes --> Scanning[scanning/]
Routes --> Matching[matching/]
Routes --> Criteria[criteria/]
Routes --> Dashboard[dashboard/]
Routes --> Auth[auth/]
Routes --> EmailDraft[email_draft/]
Routes --> Maintenance[maintenance/]
Scanning --> Storage
Matching --> Storage
Matching --> LLMc
Criteria --> Storage
Dashboard --> Storage
Scanning --> EmailAuth[email_auth/]
Runtime[runtime_settings.py
in-memory mock/real flags] -.-> LLMc
Runtime -.-> Scanning
style Routes fill:#1C2333,color:#fff
style Storage fill:#4338CA,color:#fff
Two flows worth diagramming precisely, since both were the subject of a dedicated QA round each.
sequenceDiagram
participant U as Recruiter
participant API as POST /auth/login
participant RL as rate_limit.py
participant DB as User table
U->>API: {email, password}
API->>RL: seconds_until_unlocked(email)
alt locked out
RL-->>API: > 0
API-->>U: 401, identical shape whether
the email exists or not
else not locked
API->>DB: find_user_by_email + verify hash
alt wrong password
API->>RL: record_failure(email)
API-->>U: 401
else correct
API->>RL: record_success(email)
API-->>U: 200, session token
end
end
sequenceDiagram
participant U as Recruiter
participant API as DELETE /candidates/{id}
participant Mirror as delete_candidate_mirror()
participant DB as SQLite
U->>API: confirm typed "DELETE"
API->>DB: get ResumeSource rows for candidate
API->>Mirror: delete_candidate_mirror(id, sources)
Note over Mirror: group sources by shared directory first —
two same-day submissions can share one meta.json
Mirror->>Mirror: read meta.json once per directory,
verify candidate_id matches
Mirror->>Mirror: delete every resume file in that group,
then profile_summary.md + meta.json, then rmdir
API->>DB: session.delete(candidate)
Note over DB: Match + ResumeSource rows cascade
via ORM delete-orphan — no manual per-table deletes
API->>DB: commit()
API-->>U: 204
The grouped-by-directory step exists because of a real bug QA found on the first pass — see testing-report.md, Era 2 Round D, and design-decisions.md ADR-012.