readme file
parent
4d340fd6d5
commit
1d8b167606
|
|
@ -0,0 +1,676 @@
|
|||
# HR-ATS-Portal — Backend
|
||||
|
||||
FastAPI service behind **TalentFlow**, an HR / Applicant-Tracking portal. It pulls candidate
|
||||
applications out of a mailbox, decodes CV attachments, runs an LLM agent to match each CV
|
||||
against live job posts, publishes job ads to social channels through Buffer, and serves the
|
||||
whole thing to the frontend behind JWT auth and a database-driven RBAC model.
|
||||
|
||||
Everything in this document describes `backend/` only.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Directory layout](#directory-layout)
|
||||
- [House style — what lives in which file](#house-style--what-lives-in-which-file)
|
||||
- [Domains](#domains)
|
||||
- [Data model](#data-model)
|
||||
- [API reference](#api-reference)
|
||||
- [Authentication and RBAC](#authentication-and-rbac)
|
||||
- [Background jobs](#background-jobs)
|
||||
- [The matching agent](#the-matching-agent)
|
||||
- [External integrations](#external-integrations)
|
||||
- [Configuration](#configuration)
|
||||
- [Running locally](#running-locally)
|
||||
- [Database migrations](#database-migrations)
|
||||
- [Docker](#docker)
|
||||
- [Response conventions](#response-conventions)
|
||||
- [Known gaps and gotchas](#known-gaps-and-gotchas)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
FE["Frontend (Vite, :5173)"] -->|JWT Bearer| API
|
||||
|
||||
subgraph API["FastAPI — main.py :8000"]
|
||||
R1["users / role / forget_password / notifications"]
|
||||
R2["inbox"]
|
||||
R3["job (job_post + candidate)"]
|
||||
end
|
||||
|
||||
API --> PG[("PostgreSQL — schema app")]
|
||||
API -->|enqueue| REDIS[("Redis Streams")]
|
||||
|
||||
REDIS --> W["Taskiq worker\ninbox.tasks + inbox.sync_tasks"]
|
||||
SCHED["Taskiq scheduler\ncron"] --> REDIS
|
||||
W --> PG
|
||||
W --> AGENT["LangGraph agent\nagent/"]
|
||||
AGENT --> OAI["OpenAI"]
|
||||
|
||||
API -->|GET /emails, /sync/read-status| MAILAPI["Email API (MS Graph proxy)"]
|
||||
W --> MAILAPI
|
||||
API -->|multipart send| TEAMS["Teams Mail API"]
|
||||
API -->|GraphQL| BUF["Buffer"]
|
||||
```
|
||||
|
||||
**The application flow, end to end:**
|
||||
|
||||
1. `GET /email/fetch` pulls messages from the external Email API, decodes PDF/DOC/DOCX
|
||||
attachments to `inbox/decoded_attachments/`, and upserts them into `inbox_messages`.
|
||||
2. When a message carries an attachment, the sender is linked to a `users` row — created with
|
||||
the `candidate` role if new — through the `inbox` join table.
|
||||
3. Any message with a stored attachment and no match result yet is enqueued onto Redis as an
|
||||
`inbox.match_message` task.
|
||||
4. The worker extracts the résumé text, hands it plus the active job posts to the LangGraph
|
||||
agent, and writes `suggested_job_post_ids`, `match_summary`, `match_reasoning` and
|
||||
`experience` back onto the row.
|
||||
5. New candidate accounts land inactive and are mailed a confirmation link; the link is what
|
||||
flips `is_active`.
|
||||
6. A cron task sweeps Outlook read-status deltas back onto `inbox_messages.message_read`.
|
||||
7. Recruiters read all of it through `/inbox/all-applications` and publish new roles with
|
||||
`POST /job/post-job`, which renders the ad copy and pushes it to Buffer.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Concern | Choice |
|
||||
|---|---|
|
||||
| Web framework | FastAPI 0.136 + Uvicorn |
|
||||
| ORM / models | SQLModel 0.0.38 on SQLAlchemy 2.0 (async, `asyncpg`) |
|
||||
| Database | PostgreSQL, application objects in the `app` schema |
|
||||
| Migrations | Alembic 1.18, driven by `alembic_setup.py` |
|
||||
| Auth | PyJWT (HS256) access / refresh / reset tokens, `bcrypt` hashing |
|
||||
| Task queue | Taskiq on Redis Streams, with a smart-retry + dead-letter middleware |
|
||||
| LLM | OpenAI async client, orchestrated by LangGraph |
|
||||
| PDF extraction | `pypdf` |
|
||||
| HTTP client | `httpx` |
|
||||
| Python | 3.12 (see `Dockerfile`) |
|
||||
|
||||
---
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
backend/
|
||||
├── main.py # FastAPI app, lifespan, CORS, router mounting
|
||||
├── db_setup.py # Settings, async engine, sessions, init_db/lifespan
|
||||
├── alembic_setup.py # Alembic scaffolding, autogenerate, migrate-on-boot
|
||||
├── llm_setup.py # AsyncOpenAI client + llm_call helper
|
||||
├── requirements.txt
|
||||
├── Dockerfile # image for the Taskiq worker / scheduler
|
||||
├── alembic.ini # generated by alembic_setup.py, not hand-written
|
||||
├── migrations/ # generated env.py + versions/
|
||||
├── LLM_CONTEXT_PROMPT.md # house-style prompt to paste into an LLM before editing
|
||||
│
|
||||
├── users/ # accounts, login, signup, RBAC enforcement
|
||||
├── role/ # roles, permission bundles, permission tags
|
||||
├── forget_password/ # reset-code request → verify → new password
|
||||
├── notifications/ # email-confirmation tokens and mail
|
||||
├── inbox/ # mailbox sync, attachments, applications
|
||||
├── job/
|
||||
│ ├── app.py # routes for both sub-domains
|
||||
│ ├── job_post/ # job ads + Buffer publishing
|
||||
│ └── candidate/ # CV reading, candidate profile
|
||||
├── agent/ # LangGraph CV → job-post matching agent
|
||||
└── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task
|
||||
```
|
||||
|
||||
There are **no `__init__.py` files**. The service is run from `backend/`, so imports are
|
||||
top-level (`from users.app import router`, `from db_setup import get_session`).
|
||||
|
||||
---
|
||||
|
||||
## House style — what lives in which file
|
||||
|
||||
Every domain package follows the same six-file shape. This is enforced by convention, and
|
||||
`LLM_CONTEXT_PROMPT.md` is the canonical statement of it.
|
||||
|
||||
| File | Owns | Must not do |
|
||||
|---|---|---|
|
||||
| `app.py` | Routes, inline request models, `JSONResponse`, dependency injection | Business rules, SQL |
|
||||
| `views.py` | Business checks, calls models, raises `HTTPException` | Build login token envelopes |
|
||||
| `models.py` | SQLModel table + `@classmethod async def` accessors | Import FastAPI, raise `HTTPException` |
|
||||
| `serializers.py` | Hand-built `dict` builders (`serialize_*`) | Touch the DB or `Depends` |
|
||||
| `plugins.py` | Pure helpers — hashing, JWT, HTTP calls to third parties | Import FastAPI |
|
||||
| `permissions.py` | Bearer schemes and `Depends` aliases (auth domains only) | Hold route handlers |
|
||||
|
||||
Additional rules that matter when you edit this code:
|
||||
|
||||
- Request bodies are Pydantic models declared **inline in `app.py`**, never in `serializers.py`.
|
||||
- There are **no Pydantic response models** — responses are hand-built dicts.
|
||||
- Route paths are verb-in-path (`/users/create`, `/users/fetch`), not REST-resource-only, and
|
||||
there is no `/api/v1` prefix.
|
||||
- Non-DB config is module-level `load_dotenv()` + `os.getenv(...)`. Only database settings go
|
||||
through `db_setup.Settings`.
|
||||
|
||||
---
|
||||
|
||||
## Domains
|
||||
|
||||
### `users/`
|
||||
Signup, login, refresh, CRUD, role assignment, and the RBAC machinery every other domain
|
||||
depends on. `users/permissions.py` defines the full `PermissionTag` vocabulary (13 modules ×
|
||||
8 actions = 104 tags) and the `require_permission(...)` dependency. A startup assertion
|
||||
(`_assert_vocabulary_complete`) fails loudly if the tag list ever drifts from
|
||||
`PermissionModule × PermissionAction`.
|
||||
|
||||
Signup always assigns the `candidate` role, creates the account **inactive**, and sends a
|
||||
confirmation email. Login rejects unconfirmed accounts.
|
||||
|
||||
### `role/`
|
||||
Three-level permission model: `permission_tags` (atomic `module.action` rows) →
|
||||
`permissions` (named bundles holding a JSONB array of tag ids) → `roles` (holding a JSONB
|
||||
array of bundle ids). `Roles.resolve_tags()` walks that chain and returns a flat tuple of tag
|
||||
names; dangling or inactive ids simply contribute nothing rather than erroring.
|
||||
|
||||
Eight system roles are seeded: `system_administrator`, `hr_administrator`, `recruiter`,
|
||||
`hiring_manager`, `department_head`, `interviewer`, `ceo`, `candidate`.
|
||||
|
||||
### `inbox/`
|
||||
The heart of the ingestion pipeline.
|
||||
|
||||
- `views.py::Email` talks to the external Email API, decodes attachments, upserts messages,
|
||||
and enqueues matching work.
|
||||
- `models.py` holds `Inbox_Messages` (the mail rows plus all agent output columns),
|
||||
`Inbox_Alerts`, and `Inbox` — the join table linking a message to the candidate `Users` row
|
||||
it came from. `_link_sender` creates the candidate account on first contact, skipping
|
||||
`noreply@`-style senders.
|
||||
- `file_decoder.py` turns Graph `contentBytes` into real PDF / DOCX / DOC files, validating
|
||||
magic bytes for each format and stripping path traversal from filenames.
|
||||
- `plugins.py` resolves attachment paths (handling Windows paths written by the host API but
|
||||
read from a Linux worker), extracts résumé text, and calls the read-status sync endpoints.
|
||||
- `tasks.py` / `sync_tasks.py` are the two Taskiq tasks.
|
||||
|
||||
### `job/`
|
||||
Two sub-domains behind one router:
|
||||
|
||||
- **`job_post/`** — renders LinkedIn-shaped ad copy from a structured payload, resolves the
|
||||
Buffer channel (by explicit id, by platform alias, or by the configured default), creates
|
||||
the post over Buffer's GraphQL API, and records the mapped status. A queued post is recorded
|
||||
as `scheduled`, not `published`; only Buffer reporting `sent` promotes it.
|
||||
- **`candidate/`** — `FileRead` extracts text from an uploaded PDF (`pypdf`), and
|
||||
`match_inbox_cv` force-requeues an existing inbox message for matching. `CandidateView`
|
||||
reads the candidate profile through the `inbox` join.
|
||||
|
||||
### `notifications/` and `forget_password/`
|
||||
Two parallel token flows, deliberately kept separate so each owns its own mail copy and env
|
||||
reads:
|
||||
|
||||
- **Confirmation** — a 32-byte url-safe secret, bcrypt-hashed in
|
||||
`email_confirmation_tokens`; the link carries `<row_id>.<secret>` because a bcrypt hash
|
||||
cannot be looked up. Replays (mail scanners, back button) are handled idempotently.
|
||||
- **Password reset** — a short code mailed to the user, bcrypt-hashed in
|
||||
`password_reset_codes`, with a resend cooldown and a max-attempts cap. Verifying the code
|
||||
mints a `type=reset` JWT carrying the code row id (`crid`), which is the only thing that
|
||||
authorises the new-password call.
|
||||
|
||||
### `agent/`
|
||||
LangGraph state machine — see [The matching agent](#the-matching-agent).
|
||||
|
||||
### `taskiq_management/`
|
||||
Broker, scheduler, DLQ middleware, and a `ping` smoke task.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
All tables live in the `app` schema (`DB_DEFAULT_SCHEMA`), with a shared naming convention for
|
||||
indexes, constraints and foreign keys. `SQLModel.metadata` is pointed at `Base.metadata` in
|
||||
`db_setup.py` so SQLModel and DeclarativeBase share one registry and Alembic sees everything.
|
||||
|
||||
| Table | Key columns | Notes |
|
||||
|---|---|---|
|
||||
| `users` | `id` (uuid PK), `email` (unique), `role_id` → `roles.id`, `password`, `is_active`, `is_deleted` | Soft delete. `role` is `selectin`-loaded; lazy loads would raise `MissingGreenlet` under asyncio |
|
||||
| `roles` | `id`, `role_name` (unique), `permissions` (JSONB int[]), `is_system` | |
|
||||
| `permissions` | `id`, `name` (unique), `permission_tags` (JSONB int[]) | Named bundles |
|
||||
| `permission_tags` | `id`, `tag_name` (unique), `module`, `action` | Unique on (`module`, `action`) |
|
||||
| `inbox_messages` | `id` (uuid), `message_id` (upstream id, unique), `full_email_response` (JSONB), subject/body/from/to/cc/bcc, `message_read`, `attachment`, `file_name`, `file_path`, `application_status`, `resume_text`, `experience`, `suggested_job_post_ids` (JSONB), `match_summary`, `match_reasoning`, `match_status`, `match_error`, `matched_at` | One row per mail; agent output lands here |
|
||||
| `inbox` | `id`, `user_id` → `users.id`, `message_id` → `inbox_messages.id`, `alert_id` | Join table linking a candidate to a message |
|
||||
| `inbox_alerts` | `id`, `alert_sender_name`, `alert_sender_email`, `is_read` | |
|
||||
| `job_posts` | `id` (uuid), `title`, `platform`, `channel_id`, `post_text`, `requirements`/`optional_skills` (JSON), `status`, `buffer_post_id`, `buffer_external_link`, `buffer_sent_at`, `buffer_error`, `created_by` → `users.id` | |
|
||||
| `password_reset_codes` | `id`, `email`, `code_hash`, `expires_at`, `attempts`, `is_used`, `verified_at` | |
|
||||
| `email_confirmation_tokens` | `id`, `user_id`, `email`, `token_hash`, `expires_at`, `is_used`, `confirmed_at` | |
|
||||
|
||||
`application_status` is a `str` enum: `PROCESS`, `PENDING`, `APPROVED`, `REJECTED`, `ONHOLD`,
|
||||
`CLOSED`.
|
||||
|
||||
`match_status` is free-form text written by the worker: `processing`, `matched`, `skipped`,
|
||||
`no_text`, `failed`, `dlq`.
|
||||
|
||||
---
|
||||
|
||||
## API reference
|
||||
|
||||
Base URL: `http://localhost:8000`. Interactive docs at `/docs`.
|
||||
|
||||
### Auth — `users/app.py`, `notifications/app.py`, `forget_password/app.py`
|
||||
|
||||
| Method | Path | Guard | Purpose |
|
||||
|---|---|---|---|
|
||||
| POST | `/users/signup` | public | Create a candidate account (inactive) and mail a confirmation link |
|
||||
| POST | `/users/login` | public | Email/username + password → token envelope |
|
||||
| POST | `/users/refresh` | public | Refresh token → new token pair |
|
||||
| GET | `/users/me` | any authenticated user | Current user with resolved permissions |
|
||||
| POST | `/users/confirm-email` | public | Consume a confirmation token, activate the account |
|
||||
| POST | `/users/confirm-email/resend` | public | Re-issue a confirmation link (cooldown enforced) |
|
||||
| POST | `/users/forget-password` | public | Mail a reset code |
|
||||
| POST | `/users/forget-password/verify-code` | public | Verify the code → `type=reset` JWT |
|
||||
| POST | `/users/forget-password/new-password` | reset JWT | Set the new password |
|
||||
|
||||
### Users — `users/app.py`
|
||||
|
||||
| Method | Path | Required tag |
|
||||
|---|---|---|
|
||||
| GET | `/users/fetch` | `rbac_users.view` |
|
||||
| POST | `/users/create` | `rbac_users.create` |
|
||||
| PUT | `/users/update?record_id=` | `rbac_users.edit` |
|
||||
| PUT | `/users/assign-role?record_id=` | `rbac_users.edit` (+ `rbac_users.manage` inside the service) |
|
||||
| PUT | `/users/remove-role?record_id=` | `rbac_users.edit` (+ `rbac_users.manage`) |
|
||||
| DELETE | `/users/delete?record_id=` | `rbac_users.delete` |
|
||||
|
||||
### Roles and permissions — `role/app.py`
|
||||
|
||||
| Method | Path | Required tag |
|
||||
|---|---|---|
|
||||
| GET | `/roles/fetch` | `rbac_users.view` |
|
||||
| POST | `/roles/create` | `rbac_users.create` |
|
||||
| PUT | `/roles/update?record_id=` | `rbac_users.edit` |
|
||||
| DELETE | `/roles/delete?record_id=` | `rbac_users.delete` |
|
||||
| GET | `/permissions/fetch` | `rbac_users.view` |
|
||||
| POST | `/permissions/create` | `rbac_users.manage` |
|
||||
| PUT | `/permissions/update?record_id=` | `rbac_users.manage` |
|
||||
| GET | `/permission-tags/fetch` | `rbac_users.view` |
|
||||
|
||||
### Inbox — `inbox/app.py`
|
||||
|
||||
| Method | Path | Guard | Purpose |
|
||||
|---|---|---|---|
|
||||
| GET | `/email/fetch` | upstream token only | Pull from the Email API, decode attachments, upsert, enqueue matching. `test_on=true` (default) returns raw payloads and skips the account-setup mails |
|
||||
| GET | `/inbox/fetch` | none | Stored messages, with attachments inlined as base64 |
|
||||
| GET | `/inbox/all-applications` | `inbox.view` | The Applications tab. Filters: `application_status`, `isread`, `search`, `record_id`, `top`, `skip` |
|
||||
| POST | `/inbox/{record_id}/match` | `inbox.edit` | Force a re-match of one message |
|
||||
| POST | `/inbox/{record_id}/read` | `inbox.edit` | Mark read locally |
|
||||
| GET | `/inbox/{record_id}/read-status` | `inbox.edit` | Re-pull read status from upstream for one message |
|
||||
|
||||
### Jobs and candidates — `job/app.py`
|
||||
|
||||
| Method | Path | Required tag | Purpose |
|
||||
|---|---|---|---|
|
||||
| GET | `/jobs/alias` | public | Accepted platform shorthands (`fb`, `ig`, `li`, `x`, …) |
|
||||
| POST | `/job/post-job` | `job_board.create` | Render the ad, create the Buffer post, persist the result |
|
||||
| GET | `/job/buffer/channels` | `job_board.view` | Connected Buffer channels across all organizations |
|
||||
| POST | `/candidate/cv_upload` | `candidates.create` | Upload a PDF, get extracted text back |
|
||||
| POST | `/candidate/inbox-match?inbox_message_id=` | `candidates.edit` | Queue a forced re-match for a stored message |
|
||||
| GET | `/candidate/fetch?user_id=` | `candidates.view` | Candidate profile via the `inbox` join |
|
||||
|
||||
`POST /job/post-job` takes `mode` ∈ `addToQueue` | `shareNow` | `customScheduled`; the
|
||||
`customScheduled` mode requires `scheduler_date` (and optionally `scheduler_time`), which the
|
||||
route combines into a UTC `due_at`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication and RBAC
|
||||
|
||||
**Tokens.** PyJWT, HS256, with a `type` claim that `decode_token(..., expected_type=...)`
|
||||
rejects on mismatch. Three types:
|
||||
|
||||
| Type | Lifetime (default) | Extra claims |
|
||||
|---|---|---|
|
||||
| `access` | 30 min | `email`, `role_id` |
|
||||
| `refresh` | 7 days | — |
|
||||
| `reset` | 10 min | `crid` (reset-code row id) |
|
||||
|
||||
`iat` / `exp` are always timezone-aware UTC. Every token carries a `jti`.
|
||||
|
||||
**Login response** puts the OAuth2 fields at the root so Swagger's Authorize button can read
|
||||
them:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 1800,
|
||||
"data": { "id": "...", "email": "...", "role_id": 3, "role_name": "recruiter", "...": "..." },
|
||||
"status_code": 200
|
||||
}
|
||||
```
|
||||
|
||||
**Passwords** use `bcrypt` directly rather than `passlib` — passlib 1.7.4 reads
|
||||
`bcrypt.__about__.__version__`, which bcrypt dropped in 4.1, and the failed probe makes it
|
||||
reject every password as over 72 bytes. Input is truncated to 72 bytes on a character
|
||||
boundary before hashing.
|
||||
|
||||
**Authorization.** `get_current_user` decodes the access token, loads the user by `sub`,
|
||||
rejects missing / deleted / inactive accounts, resolves the role's tags, and returns a
|
||||
serialized user dict with a `permissions` list. `require_permission(*tags, require_all=True)`
|
||||
is the dependency that guards routes; a user with no role assigned gets a 403 before any tag
|
||||
check runs.
|
||||
|
||||
Role assignment is additionally guarded in `users/views.py`: you cannot grant a role holding
|
||||
permissions you do not yourself hold.
|
||||
|
||||
---
|
||||
|
||||
## Background jobs
|
||||
|
||||
Taskiq over **Redis Streams**, with a result backend and a Redis-backed schedule source.
|
||||
|
||||
**Middleware order matters** — `DeadLetterMiddleware` sits before `SmartRetryMiddleware` so a
|
||||
`PermanentTaskError` can set `retry_on_error=False` before retry logic runs.
|
||||
|
||||
| Task | Trigger | What it does |
|
||||
|---|---|---|
|
||||
| `inbox.match_message` | enqueued by `/email/fetch`, `/inbox/{id}/match`, `/candidate/inbox-match` | Extract résumé text → run the agent → write match results |
|
||||
| `inbox.sync_read_status` | cron, `EMAIL_SYNC_CRON` (default every minute) | Pull read-status deltas from the Email API and apply them |
|
||||
| `ping` | manual | Framework smoke test |
|
||||
|
||||
**Retries.** `SmartRetryMiddleware` with jitter and exponential delay, `TASKIQ_MAX_RETRIES`
|
||||
attempts, capped at `TASKIQ_MAX_DELAY`. Raising `PermanentTaskError` (missing record, no
|
||||
attachment, blank `record_id`) skips retries entirely.
|
||||
|
||||
**Dead letter queue.** Exhausted or permanently-failed tasks are written to the
|
||||
`taskiq:dlq` Redis stream as a JSON payload. For `inbox.match_message` specifically, the
|
||||
middleware also stamps `match_status="dlq"` on the row so the failure is visible in the UI
|
||||
rather than only in Redis.
|
||||
|
||||
**The read-status latch.** `apply_read_status` only ever applies `false → true`. Nothing
|
||||
pushes local reads back to Outlook, so upstream keeps reporting `isRead=false`; without the
|
||||
latch the every-minute sweep would un-read a mail the user just opened. The trade-off is that
|
||||
un-reading a mail in Outlook no longer propagates here. `sync_read_status` holds a Redis lock
|
||||
(`inbox:sync_read_status:lock`, 300s TTL) so overlapping cron ticks cannot double-run, and
|
||||
pages at most 10 rounds per tick.
|
||||
|
||||
---
|
||||
|
||||
## The matching agent
|
||||
|
||||
A small LangGraph `StateGraph` over an `AgentState` TypedDict.
|
||||
|
||||
```
|
||||
START → prepare → (conditional) → match_jobs → END
|
||||
└→ END (when resume_text or job_posts is empty)
|
||||
```
|
||||
|
||||
- **`prepare_context`** trims the subject and résumé text and normalizes the job posts down to
|
||||
the fields the model needs. Empty résumé text or no active posts short-circuits to
|
||||
`status="skipped"`.
|
||||
- **`match_jobs`** calls `llm_call(prompt(), user_prompt(state), json_mode=True)` and parses
|
||||
the response.
|
||||
|
||||
The parser (`agent/decorators.py::parse_match_response`) is deliberately strict: a suggested
|
||||
id must be present in the posts that were actually sent, must be a valid UUID, and duplicates
|
||||
are dropped. The model cannot invent a job post. `reasoning` returned as a list is joined into
|
||||
a string; non-string fields fall back to `""`.
|
||||
|
||||
The graph is compiled once per process (`init_agent()` from the FastAPI lifespan, or lazily on
|
||||
first use inside the worker) and the OpenAI client is created lazily and closed on shutdown.
|
||||
|
||||
Startup is **fault-tolerant**: if the broker or the LLM/agent fails to initialize, `main.py`
|
||||
logs a warning and the API still serves. Only the database is a hard requirement.
|
||||
|
||||
---
|
||||
|
||||
## External integrations
|
||||
|
||||
| Service | Used by | Contract |
|
||||
|---|---|---|
|
||||
| **Email API** (a Microsoft Graph proxy) | `inbox/` | `GET {EMAIL_URL}/emails`, `GET {EMAIL_URL}/emails/{id}`, `GET {EMAIL_URL}/sync/read-status`, `GET {EMAIL_URL}/sync/read-status/message/{id}` — Bearer `EMAIL_API_TOKEN` |
|
||||
| **Teams Mail API** | `notifications/`, `forget_password/` | multipart POST to `TEAMS_MAIL_API_URL`; success is HTTP **202**, anything else raises |
|
||||
| **Buffer** | `job/job_post/` | GraphQL against `BUFFER_API_URL` — `createPost` mutation, `account { organizations }` and `channels` queries |
|
||||
| **OpenAI** | `agent/`, `llm_setup.py` | Chat Completions with `response_format: json_object` |
|
||||
|
||||
Attachments are written to `backend/inbox/decoded_attachments/`. In Docker this directory is
|
||||
bind-mounted into the worker so both processes see the same files.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and fill it in. `.env` is git-ignored; `.env.example` is not.
|
||||
`db_setup.Settings` reads `backend/.env` or the repo-root `.env`; every other module reads its
|
||||
own keys with `os.getenv`.
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME` | — | **Required.** `DB_PORT` must be an integer |
|
||||
| `DATABASE_URL` | — | Full DSN; wins over the parts above |
|
||||
| `DB_SSLMODE` | — | e.g. `require` on Azure; translated to asyncpg's `ssl` |
|
||||
| `DB_SCHEMAS` | `app` | Comma-separated; created on startup |
|
||||
| `DB_DEFAULT_SCHEMA` | `app` | Schema for models that declare none |
|
||||
| `DB_ECHO` | `false` | SQL logging |
|
||||
| `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` / `DB_POOL_RECYCLE` | `5` / `10` / `1800` | |
|
||||
| `DB_CONNECT_RETRIES` | `10` | Startup wait-for-Postgres |
|
||||
| `DB_AUTO_MIGRATE` | `true` | Run `upgrade head` on startup |
|
||||
| `DB_AUTOGENERATE` | `true` | Write a revision when models drift |
|
||||
| `APP_NAME` | `hr-ats-portal` | Postgres `application_name` |
|
||||
|
||||
### Auth
|
||||
|
||||
| Variable | Default |
|
||||
|---|---|
|
||||
| `JWT_SECRET_KEY` | — (**required**) |
|
||||
| `JWT_ALGORITHM` | `HS256` |
|
||||
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | `30` |
|
||||
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | `7` |
|
||||
| `JWT_RESET_TOKEN_EXPIRE_MINUTES` | `10` |
|
||||
|
||||
### Email ingestion
|
||||
|
||||
| Variable | Default |
|
||||
|---|---|
|
||||
| `EMAIL_URL`, `EMAIL_API_TOKEN` | — |
|
||||
| `EMAIL_SYNC_FOLDER` | `inbox` |
|
||||
| `EMAIL_SYNC_SINCE` | — |
|
||||
| `EMAIL_SYNC_CRON` | `* * * * *` |
|
||||
| `BACKEND_URL` | `http://localhost:8000` (used for the internal confirmation-resend call) |
|
||||
| `DEFAULT_CANDIDATE_PASSWORD` | `Utopia!@#` — placeholder only; the account is inactive until confirmed |
|
||||
|
||||
### Mail out
|
||||
|
||||
| Variable | Default |
|
||||
|---|---|
|
||||
| `TEAMS_MAIL_API_URL`, `TEAMS_API_TOKEN` | — |
|
||||
| `FRONTEND_URL` | `http://localhost:5173` |
|
||||
| `CONFIRM_EMAIL_PATH` | `/auth/confirm-email` |
|
||||
| `CONFIRM_TOKEN_TTL_SECONDS` | `86400` |
|
||||
| `CONFIRM_TOKEN_RESEND_SECONDS` | `60` |
|
||||
| `RESET_CODE_TTL_SECONDS` | `60` |
|
||||
| `RESET_CODE_RESEND_SECONDS` | `30` |
|
||||
| `RESET_CODE_MAX_ATTEMPTS` | `5` |
|
||||
|
||||
### Buffer
|
||||
|
||||
| Variable | Default |
|
||||
|---|---|
|
||||
| `BUFFER_API` | — (access token) |
|
||||
| `BUFFER_API_URL` | `https://api.buffer.com` |
|
||||
| `BUFFER_CHANNEL_ID` | — (fallback channel) |
|
||||
|
||||
### OpenAI
|
||||
|
||||
| Variable | Default |
|
||||
|---|---|
|
||||
| `OPENAI_API_KEY` | — |
|
||||
| `OPENAI_MODEL` | `gpt-5.4-mini` |
|
||||
| `OPENAI_TEMPERATURE` | `0` — leave blank to omit the parameter for models that reject it |
|
||||
| `OPENAI_MAX_OUTPUT_TOKENS` | `32768` (`.env.example` ships `4096`) |
|
||||
| `OPENAI_TIMEOUT` / `OPENAI_MAX_RETRIES` / `OPENAI_CONNECT_RETRIES` | `60` / `3` / `3` |
|
||||
| `OPENAI_BASE_URL`, `OPENAI_ORGANIZATION`, `OPENAI_PROJECT` | — (set only for Azure or a gateway) |
|
||||
|
||||
### Taskiq / Redis
|
||||
|
||||
| Variable | Default |
|
||||
|---|---|
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` |
|
||||
| `TASKIQ_QUEUE_NAME` | `inbox` |
|
||||
| `TASKIQ_CONSUMER_GROUP` | `taskiq` |
|
||||
| `TASKIQ_MAX_RETRIES` | `3` |
|
||||
| `TASKIQ_RETRY_DELAY` | `5` |
|
||||
| `TASKIQ_MAX_DELAY` | `120` |
|
||||
| `TASKIQ_IDLE_TIMEOUT_MS` | `600000` |
|
||||
| `TASKIQ_DLQ_STREAM` | `taskiq:dlq` |
|
||||
| `TASKIQ_WORKER_NAME` | falls back to `HOSTNAME` |
|
||||
| `APP_VERSION` | `dev` |
|
||||
|
||||
---
|
||||
|
||||
## Running locally
|
||||
|
||||
**Prerequisites:** Python 3.12, PostgreSQL, Redis.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
cp .env.example .env # then fill it in
|
||||
```
|
||||
|
||||
All commands must be run from `backend/` — the import paths depend on it.
|
||||
|
||||
**API:**
|
||||
|
||||
```bash
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
Startup connects to Postgres (retrying with backoff), creates the configured schemas, runs
|
||||
migrations to head, then starts the broker, the OpenAI client and the agent graph. Broker and
|
||||
LLM failures are logged and skipped; the API still comes up.
|
||||
|
||||
**Worker** (needs Redis):
|
||||
|
||||
```bash
|
||||
taskiq worker taskiq_management.broker_setup:broker \
|
||||
inbox.tasks inbox.sync_tasks taskiq_management.tasks
|
||||
```
|
||||
|
||||
**Scheduler** (cron ticks for `inbox.sync_read_status`):
|
||||
|
||||
```bash
|
||||
taskiq scheduler taskiq_management.broker_setup:scheduler inbox.sync_tasks
|
||||
```
|
||||
|
||||
Docs: <http://localhost:8000/docs>
|
||||
|
||||
---
|
||||
|
||||
## Database migrations
|
||||
|
||||
`alembic_setup.py` wraps Alembic so the plain `alembic` CLI and the app's own
|
||||
migrate-on-startup share one configuration. It scaffolds `alembic.ini`, `migrations/env.py`
|
||||
and `script.py.mako` on first use and never overwrites them. Model modules are discovered
|
||||
automatically — every `<package>/models.py` under `backend/` is imported before the metadata is
|
||||
diffed.
|
||||
|
||||
```bash
|
||||
python alembic_setup.py migrate # upgrade to head, then autogenerate any drift
|
||||
python alembic_setup.py revision -m "add x" # write a revision if the models have drifted
|
||||
python alembic_setup.py upgrade -r head
|
||||
python alembic_setup.py downgrade -r -1
|
||||
python alembic_setup.py current
|
||||
python alembic_setup.py head
|
||||
```
|
||||
|
||||
Migrations run under a Postgres advisory lock, so several workers booting at once cannot
|
||||
migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is
|
||||
excluded from autogenerate, as is anything outside the configured schemas.
|
||||
|
||||
The module is named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`
|
||||
and a module called `alembic.py` would shadow the installed package.
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
The repo-root `docker-compose.yml` runs Redis plus the two Taskiq processes; the API itself is
|
||||
expected to run on the host (the compose file points the containers at
|
||||
`host.docker.internal` for the database).
|
||||
|
||||
```bash
|
||||
docker compose up -d # from the repo root
|
||||
docker compose logs -f taskiq-worker
|
||||
```
|
||||
|
||||
`backend/Dockerfile` builds a `python:3.12-slim` image whose default command is the Taskiq
|
||||
worker. `backend/inbox/decoded_attachments` is bind-mounted so the worker can read the
|
||||
attachments the API wrote.
|
||||
|
||||
---
|
||||
|
||||
## Response conventions
|
||||
|
||||
Every handler wraps its body in the same try/except:
|
||||
|
||||
```python
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.some_method(...)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
```
|
||||
|
||||
| Shape | Response |
|
||||
|---|---|
|
||||
| List | `{"data": [...], "total": <n>, "status_code": 200}` |
|
||||
| Single record | `{"data": {...}, "total": 1, "status_code": 200}` |
|
||||
| Login / refresh | OAuth2 fields at the root, user under `data` |
|
||||
| Error | FastAPI's `{"detail": "..."}` with the real status code |
|
||||
|
||||
Serializers always `str()` UUIDs, `.isoformat()` datetimes, and never emit `password`.
|
||||
|
||||
---
|
||||
|
||||
## Known gaps and gotchas
|
||||
|
||||
- **`DB_PORT` must be set.** `db_setup.Settings` evaluates `int(os.getenv("DB_PORT"))` at class
|
||||
definition time, so a missing value raises `TypeError` on import rather than a friendly
|
||||
config error.
|
||||
- **CORS is fully open** (`allow_origins=["*"]` with credentials). Fine for development, needs
|
||||
tightening before production.
|
||||
- **`/email/fetch` and `/inbox/fetch` carry no permission guard.** `/email/fetch` authenticates
|
||||
only against the upstream Email API token.
|
||||
- **`.doc` / `.docx` résumés are decoded and stored but not parsed.** `extract_resume_text`
|
||||
handles PDFs only and reports `no PDF attachment to extract` for the rest.
|
||||
- **`serialize_application` returns `null` for `ats_score`, `phone`, `recruiter` and
|
||||
`duplicate`** — `inbox_messages` has no columns for them yet, and `processing` is derived
|
||||
from `message_read` alone, so it is only ever `"Read"` or `"Unread"`.
|
||||
- **`Inbox.get_candidate_profile` filters on `cls.user.role_id`**, which is a relationship
|
||||
attribute rather than a joined column; the candidate-profile query needs a join before it
|
||||
behaves as intended.
|
||||
- **Attachment paths may be Windows absolutes** written by the host API but read by a Linux
|
||||
worker. `resolve_attachment_path` normalizes separators and falls back to the basename under
|
||||
the mounted `decoded_attachments` directory.
|
||||
- **Read status is a one-way latch** — see [Background jobs](#background-jobs).
|
||||
- **There is no test suite** in `backend/` at present.
|
||||
|
||||
---
|
||||
|
||||
## Editing this codebase
|
||||
|
||||
Before changing anything here, read [`LLM_CONTEXT_PROMPT.md`](LLM_CONTEXT_PROMPT.md). It states
|
||||
the house style in full and is the reference used to keep new code indistinguishable from
|
||||
`users/` and `inbox/`. The short version: mirror the neighbouring file, keep the layer duties
|
||||
intact, add no new layers, and do not reformat code you did not otherwise need to touch.
|
||||
|
||||
Adding an endpoint, in order:
|
||||
|
||||
1. Model accessor in `models.py` (if it touches the DB).
|
||||
2. Service method in `views.py`.
|
||||
3. `serialize_*` in `serializers.py` if the shape is new.
|
||||
4. Route in `app.py` with the standard try/except and `JSONResponse`.
|
||||
5. `CurrentUser` or `Depends(require_permission(...))` if the route is protected.
|
||||
Loading…
Reference in New Issue