5.2 KiB
Backend Guide
Lean, local-first FastAPI app for resume tailoring.
Tech Stack
| Component | Technology |
|---|---|
| Framework | FastAPI |
| Database | SQLite (SQLAlchemy 2.0 async + aiosqlite) |
| AI | LiteLLM (100+ providers) |
| Doc Parsing | markitdown |
| Validation | Pydantic |
| Key encryption | Fernet (cryptography) |
Directory Structure
apps/backend/app/
├── main.py # Entry point (lifespan: TinyDB→SQLite import, legacy-key fold-in)
├── config.py # Settings from env/file; encrypted API-key read/write
├── crypto.py # Fernet encrypt/decrypt for API keys at rest
├── database.py # Async SQLAlchemy/SQLite facade (returns plain dicts)
├── models.py # SQLAlchemy declarative Base + ORM models
├── db_engine.py # SQLite engine/session factories (async + sync) + PRAGMAs
├── llm.py # Multi-provider LLM
├── routers/ # health, config, resumes, jobs, applications, enrichment
├── services/ # parser, improver, cover_letter
├── schemas/ # Pydantic models (models.py, applications.py)
├── scripts/ # migrate_tinydb_to_sqlite.py (one-time importer)
└── prompts/ # templates.py
Database Operations
database.py is an async Database facade (global db singleton). Methods keep the
same names/signatures as the old TinyDB wrapper but return plain dicts (never ORM
rows). ORM models are in models.py; engine plumbing is in db_engine.py.
await db.create_resume(content, content_type, filename, is_master, processed_data)
await db.get_resume(resume_id) → dict | None
await db.list_resumes() → list[dict]
await db.update_resume(resume_id, updates)
await db.delete_resume(resume_id) → bool
await db.set_master_resume(resume_id) # Exactly one master allowed
await db.create_job(content, resume_id)
await db.create_application(...) / list_applications / bulk_update_applications
get_api_key_ciphertexts() / replace_api_keys(...) # sync; encrypted api_keys table
Tables: resumes, jobs, improvements, applications, api_keys (encrypted).
DB file: data/resume_matcher.db.
Two engines, one file: a module-level async engine serves the document tables +
applications; a sync engine serves the encrypted api_keys table (read on the
synchronous LLM hot path). Both apply PRAGMAs journal_mode=WAL, foreign_keys=ON,
busy_timeout. The single-master invariant is held by an asyncio.Lock plus a partial
unique index. Jobs' dynamic pipeline fields (preview_hash(es), job_keywords,
company/role) live in a metadata_json JSON column, flattened on read.
Encrypted API keys & migration
- Keys (
crypto.py): Fernet-encrypted, per-provider, in theapi_keystable. Secret atdata/.secret_key(chmod 600, gitignored, atomic write; plaintext only in memory).config.pyinjects decrypted keys at read time and strips them on save, so secrets never reachconfig.json. Set viaPOST /config/api-keys;PUT /config/llm-api-keyno longer persists a key. - Migration (
scripts/migrate_tinydb_to_sqlite.py): runs on lifespan startup. Imports a legacydata/database.json(TinyDB) into SQLite if present, then renames itdatabase.json.migrated. Idempotent.migrate_legacy_keys()likewise folds legacy plaintext keys into the encrypted store.
LLM Features
| Feature | Description |
|---|---|
| API Key Passing | Direct to litellm (avoids race conditions) |
| JSON Mode | Auto-enabled for supported providers |
| Retry Logic | 2 retries, temperature 0.1→0.0 |
| Timeouts | 30s (health), 120s (completion), 180s (JSON) |
Prompt Guidelines
- Use
{variable}for substitution (single braces) - Include JSON schema examples
- End with "Output ONLY the JSON object"
API Endpoints Quick Ref
GET /api/v1/health # liveness probe (no LLM call)
GET /api/v1/status # Full status (LLM + DB isolated; 200 on partial failure)
GET/PUT /api/v1/config/llm-api-key # no longer persists a key
GET/POST/DELETE /api/v1/config/api-keys # per-provider encrypted keys
POST /api/v1/resumes/upload # PDF/DOCX
POST /api/v1/resumes/improve # Tailor (LLM)
GET /api/v1/resumes/{id}/pdf
DELETE /api/v1/resumes/{id}
GET /api/v1/applications # Kanban tracker: grouped list (+ POST/PATCH/DELETE/bulk)
Data Flow
Upload: File → markitdown → Markdown → LLM parse → JSON → SQLite (via db)
Improve: Resume + Job → Extract keywords (LLM) → Tailor (LLM) → Store. Routers call
services; services call app/llm.py; persistence goes through the async db facade.
/improve/confirm also best-effort auto-creates an applied card in the tracker.
Error Handling
Log details server-side, generic messages to clients:
except Exception as e:
logger.error(f"Failed: {e}")
raise HTTPException(500, "Operation failed.")
Running
cd apps/backend
cp .env.example .env
uv run uvicorn app.main:app --reload --port 8000
Adding New Endpoints
- Create router in
app/routers/ - Add Pydantic models to
app/schemas/models.py - Register router in
app/main.py