Files
srbhr--resume-matcher/docs/agent/architecture/backend-guide.md
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

133 lines
5.2 KiB
Markdown

# 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`.
```python
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 the `api_keys` table. Secret
at `data/.secret_key` (`chmod 600`, gitignored, atomic write; plaintext only in memory).
`config.py` injects decrypted keys at read time and strips them on save, so secrets
never reach `config.json`. Set via `POST /config/api-keys`; `PUT /config/llm-api-key` no
longer persists a key.
- **Migration** (`scripts/migrate_tinydb_to_sqlite.py`): runs on lifespan startup. Imports
a legacy `data/database.json` (TinyDB) into SQLite if present, then renames it
`database.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
1. Use `{variable}` for substitution (single braces)
2. Include JSON schema examples
3. 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:
```python
except Exception as e:
logger.error(f"Failed: {e}")
raise HTTPException(500, "Operation failed.")
```
## Running
```bash
cd apps/backend
cp .env.example .env
uv run uvicorn app.main:app --reload --port 8000
```
## Adding New Endpoints
1. Create router in `app/routers/`
2. Add Pydantic models to `app/schemas/models.py`
3. Register router in `app/main.py`