Recruit Assistant · Engineering Reference

Architecture diagrams

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.

01

System topology

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.

02

Data model

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
    }
      
Cascade-delete relationship (ORM 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).

03

Ingest flow — one resume, folder or email

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).

04

Matching flow — two-stage + judge

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).

05

Backend module dependencies

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
06

Auth & per-candidate deletion lifecycle

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.