You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ETL pipeline that pulls articles from NewsAPI, validates and normalizes them, then loads clean records into PostgreSQL. Two execution modes are supported: a **worker queue** that processes search requests for web users (using each user's own encrypted NewsAPI key), and a **debug script** that dumps raw/clean payloads to disk and writes to a separate inspection table.
3
+
ETL pipeline that pulls articles from NewsAPI, validates and normalizes them, then loads clean records into PostgreSQL. Two execution modes: a **worker queue** that processes search requests for web users (using each user's own encrypted NewsAPI key or the app owner's key in trial mode), and a **debug script** that dumps raw/clean payloads to disk and writes to a separate inspection table.
4
4
5
5
## What this project does
6
+
6
7
1.**extract** (`src/extract.py`) — fetches paginated articles from NewsAPI with timeout, retries, exponential backoff, and `429`/`5xx` handling.
7
8
2.**transform** (`src/transform.py`) — validates each article, normalizes text and timestamps, classifies rejection reasons and accumulates per-page statistics.
8
9
3.**load** (`src/load.py`) — upserts articles by URL into `articles`, links them to a `search_request` via `user_news`, and persists per-request stats in `request_stats`.
9
10
4.**AI summary** (`src/ai/`) — after each web/worker run, a Mistral call summarizes the loaded pocket and stores the result in `request_ai_report` (one row per `search_request_id`).
10
-
5.**worker** (`src/worker.py`) — atomically claims `queued` rows from `search_requests` (`FOR UPDATE SKIP LOCKED`), decrypts the user's NewsAPI key from `users_keys`, runs the pipeline, and marks the request as `success` or `failed`.
11
-
6.**per-user keys** (`src/user_news_api_key.py`) — AES-GCM encryption/decryption of NewsAPI keys stored in the `users_keys` table.
11
+
5.**worker** (`src/worker.py`) — single loop that handles two job types in priority order:
12
+
-**Key validation** (`one_key_validation`) — atomically claims a `pending_validation` row from `users_keys`, decrypts and pings NewsAPI, then marks the key as `valid`, `invalid`, or `exhausted`.
13
+
-**Search requests** (`one_request`) — atomically claims a `queued` row from `search_requests` (`FOR UPDATE SKIP LOCKED`), runs the pipeline, and marks the request as `success` or `failed`.
14
+
6.**per-user keys** (`src/user_news_api_key.py`) — AES-GCM encryption/decryption of NewsAPI keys stored in `users_keys`, plus live key validation against the NewsAPI `/top-headlines` endpoint.
12
15
13
16
## Repository structure
17
+
14
18
```text
15
19
project/
16
20
├── config/
17
21
│ └── config.py # Settings dataclass loaded from .env
18
-
├── data/
19
-
│ ├── raw/ # Debug-mode raw NewsAPI payloads (JSON per page)
-`NEWSAPI_SORT_BY` (default `publishedAt`; one of `relevancy`, `popularity`, `publishedAt`)
69
72
-`NEWS_API_KEY_ENCRYPTION_SECRET` — secret used to derive the AES-GCM key for encrypting user-supplied NewsAPI keys
73
+
-`TRIAL_REQUEST_LIMIT` — max number of trial searches allowed per user (enforced at the web layer)
70
74
71
75
**AI summary (Mistral)**
72
76
-`MISTRAL_API_KEY` — required when `AI_SUMMARY_ENABLED=true`
@@ -86,41 +90,65 @@ cp .env.example .env
86
90
## Local run
87
91
88
92
### 1. Install
89
-
```powershell
93
+
94
+
```bash
90
95
python -m venv .venv
91
-
.venv\Scripts\activate
96
+
source .venv/bin/activate # macOS/Linux
97
+
# .venv\Scripts\activate # Windows
92
98
pip install -r requirements.txt
93
99
```
94
100
95
101
### 2. Initialize the database and tables
96
-
```powershell
102
+
103
+
```bash
97
104
python main.py --init-only
98
105
```
99
-
This creates the `news_db` database and all production tables (`app_users`, `search_requests`, `articles`, `user_news`, `request_stats`, `users_keys`). Add `--debug` to also create the debug table `bad_news_bears`. Alternatively, append `--bootstrap` to any run command to initialize on startup.
106
+
107
+
Creates the `news_db` database and all production tables. Add `--debug` to also create the debug table `bad_news_bears`. Alternatively, append `--bootstrap` to any run command to initialize on startup.
100
108
101
109
### 3. Web mode (single run, requires existing `app_users` and `search_requests` rows)
In this mode the CLI uses the global `NEWSAPI_KEY` from `.env`. Articles go into `articles` and are linked to the user via `user_news`; per-request stats land in `request_stats`.
114
+
115
+
Uses the global `NEWSAPI_KEY` from `.env`. Articles go into `articles` and are linked to the user via `user_news`; per-request stats land in `request_stats`.
106
116
107
117
### 4. Worker loop (production mode)
108
-
```powershell
118
+
119
+
```bash
109
120
python main.py --worker --poll_interval 3
110
121
```
111
-
The worker pops the next `queued` request, looks up the user's encrypted NewsAPI key in `users_keys`, decrypts it, runs the pipeline, and updates `search_requests.status`. Multiple workers can run concurrently — `FOR UPDATE SKIP LOCKED` prevents duplicate processing.
122
+
123
+
The worker processes two job types in a tight loop:
2.**Search requests** — picks a `queued` request, runs the full ETL pipeline, writes `success`/`failed`.
126
+
127
+
Multiple workers can run concurrently — `FOR UPDATE SKIP LOCKED` prevents duplicate processing.
112
128
113
129
### 5. Debug mode
130
+
114
131
See the **Debug mode** section at the bottom of this README.
115
132
116
133
## Docker
134
+
135
+
The project expects an external Docker network named `news-stack-net` (shared with the web app / Postgres stack). Create it once if it doesn't exist:
136
+
137
+
```bash
138
+
docker network create news-stack-net
139
+
```
140
+
141
+
Then build and start the worker:
142
+
117
143
```bash
118
144
docker compose up --build
119
145
```
120
-
The `app` container runs the worker (`python main.py --worker --bootstrap --poll_interval 3`) against the `db` Postgres service. Postgres data is persisted in the `postgres_data` named volume.
146
+
147
+
The `app` container runs `python main.py --worker --bootstrap --poll_interval 3`. Postgres must be reachable on the `news-stack-net` network as `news-postgres` (configure `DB_HOST=news-postgres` in `.env`).
121
148
122
149
## Quality checks
123
-
```powershell
150
+
151
+
```bash
124
152
pip install -r requirements-dev.txt
125
153
python -m compileall .
126
154
ruff check .
@@ -133,86 +161,107 @@ pip-audit
133
161
134
162
| Table | Purpose |
135
163
|---|---|
136
-
|`app_users`| Web users (Google OAuth identity). |
|`request_ai_report`| One Mistral-generated summary per `search_request_id` (counts, conclusions, sentiment, topics, highlight, warnings, token usage). |
143
-
|`bad_news_bears`|**Debug only** — populated by `--debug` runs from `data/clean/*.json`. Not touched by web/worker mode. |
171
+
|`bad_news_bears`|**Debug only** — populated by `--debug` runs. Not touched by web/worker mode. |
172
+
173
+
## Trial mode
174
+
175
+
When `search_requests.is_trial = true`, the worker uses the app owner's `NEWSAPI_KEY` from config instead of looking up the user's personal key from `users_keys`. The `trial_uses` counter on `app_users` is managed by the web layer; the ETL worker only checks the flag.
176
+
177
+
## Key validation flow
178
+
179
+
When a user registers a NewsAPI key via the web app, a row is inserted into `users_keys` with `status = 'pending_validation'`. On each worker loop tick, `one_key_validation()` picks one such row and:
180
+
181
+
1. Decrypts the key from AES-GCM ciphertext.
182
+
2. Sends a minimal request to `https://newsapi.org/v2/top-headlines`.
183
+
3. Sets the status:
184
+
-`valid` — HTTP 200
185
+
-`invalid` — HTTP 401 with any code other than `apiKeyExhausted`
186
+
-`exhausted` — HTTP 401 with `code = "apiKeyExhausted"`
187
+
4. On network/crypto error the status is reset to `pending_validation` for retry.
188
+
189
+
Only keys with `status = 'valid'` are used by search requests.
144
190
145
191
## AI summary flow
146
192
147
193
After `run_pipeline_for_web_user` finishes loading and persists `request_stats`, it calls `_generate_and_store_ai_summary`, which:
148
194
149
-
1. Reads the loaded pocket from DB: `articles JOIN user_news WHERE search_request_id = ?` — the canonical, deduplicated set the user actually got.
195
+
1. Reads the loaded pocket from DB: `articles JOIN user_news WHERE search_request_id = ?`.
150
196
2. Truncates the article list to `AI_REPORT_MAX_ARTICLES` (default 50) before calling the model.
151
197
3. Calls Mistral (`response_format: json_object`) with the system prompt in `src/ai/prompts.py`.
152
-
4. Validates and normalizes the JSON response (sentiment percentages re-normalized to sum to 100; `sentiment_label` forced to match the dominant key in the distribution; `highlight.url` replaced with a real article URL if the model invented one).
198
+
4. Validates and normalizes the JSON response (sentiment percentages re-normalized to sum to 100; `sentiment_label` forced to match the dominant key; `highlight.url` replaced with a real article URL if the model invented one).
153
199
5. Upserts a row in `request_ai_report` keyed by `search_request_id` with `status='success'`.
154
200
155
-
The website reads the result by querying `request_ai_report` once `search_requests.status = 'success'`.
156
-
157
-
The AI step is **best-effort**: any Mistral or DB error is logged but does not fail the search request — articles are still loaded and the request is still marked `success`. On AI failure (Mistral error, validation error, or empty article pocket) the pipeline upserts a `request_ai_report` row with `status='failed'` and the error message in `error_text`, so the frontend can distinguish "AI failed" from "AI not yet generated." To skip AI entirely, set `AI_SUMMARY_ENABLED=false`.
201
+
The AI step is **best-effort**: any Mistral or DB error is logged but does not fail the search request. On failure, a `request_ai_report` row with `status='failed'` is written so the frontend can distinguish "AI failed" from "not yet generated." To skip AI entirely, set `AI_SUMMARY_ENABLED=false`.
158
202
159
203
Stored summary fields:
160
-
-`status` (`success` | `failed`) and `error_text` (populated only when `status='failed'`)
204
+
-`status` (`success` | `failed`) and `error_text`
161
205
-`news_count` — articles linked to this request (capped at `AI_REPORT_MAX_ARTICLES`)
162
206
-`summary` — 1-2 sentence neutral overview
163
207
-`main_conclusions` (JSONB) — exactly 3 short conclusions
164
-
-`sentiment_label` (`positive` | `negative` | `neutral`) and `sentiment_score` — dominant sentiment and its percentage
208
+
-`sentiment_label` (`positive` | `negative` | `neutral`) and `sentiment_score`
165
209
-`sentiment_distribution` (JSONB) — `{positive, negative, neutral}` summing to 100
166
210
-`main_topics` (JSONB) — 1 to 3 topics in order of importance
- DB connect and statement timeouts are configurable.
177
-
- NewsAPI keys are stored encrypted (AES-GCM) and only decrypted in-memory for the duration of a single pipeline run.
222
+
- NewsAPI keys are stored encrypted (AES-GCM) and only decrypted in-memory for the duration of a single operation.
178
223
179
224
## Notes
225
+
180
226
-`.env`, `.venv`, `__pycache__`, and generated files under `data/raw` and `data/clean` are gitignored.
181
227
- Never commit real keys — only `.env.example` should be in git.
182
228
183
229
## Troubleshooting
230
+
184
231
-**`Database 'news_db' does not exist`** — run `python main.py --init-only` once, or add `--bootstrap` to your run command.
185
-
-**`password authentication failed`** — verify `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and test the same credentials with `psql`/pgAdmin. On localized Windows, `db.py` decodes non-UTF8 libpq errors and prints a hint.
186
-
-**`User has no NEWSAPI key yet`** (worker mode) — the user has no row in `users_keys` for `service = 'news_api'`. Use the web flow to upload their key, or insert one manually.
187
-
-**`NEWSAPI_KEY contains a placeholder value`** — replace `YOUR_REAL_NEWSAPI_KEY` in `.env` with a real key (only needed for CLI/debug mode).
-**`User has no NEWSAPI key yet`** (worker mode, non-trial) — the user has no row in `users_keys` with `service = 'news_api'`. Use the web flow to upload their key.
234
+
-**`User has no NEWSAPI key yet`** (worker mode, trial) — `NEWSAPI_KEY` is empty in `.env`.
235
+
-**`NEWSAPI_KEY contains a placeholder value`** — replace `YOUR_REAL_NEWSAPI_KEY` in `.env` (needed for CLI/debug mode and trial requests).
236
+
-**Key stuck in `pending_validation`** — check that `NEWS_API_KEY_ENCRYPTION_SECRET` matches the value used when the key was encrypted; a mismatch causes a crypto error and the status is reset to `pending_validation`.
188
237
189
238
---
190
239
191
240
## Debug mode (`--debug`)
192
241
193
-
The `--debug` flag turns `main.py` into a pipeline-inspection script. Use it whenever you need to look at exactly what NewsAPI returned, what the transform layer kept or rejected, and why — without touching any of the production tables (`articles`, `user_news`, `request_stats`).
242
+
The `--debug` flag turns `main.py` into a pipeline-inspection script. Use it to examine exactly what NewsAPI returned, what the transform layer kept or rejected, and why — without touching any production tables.
3.**Load** — `load_news`inserts the cleaned articles into the **`bad_news_bears`** table using `ON CONFLICT (url) DO NOTHING`. This table exists only for debug inspection; it is not part of the web/worker data model and is created on demand (either by `--init-only --debug` or automatically when a `--debug` run notices it is missing).
259
+
3.**Load** — inserts cleaned articles into `bad_news_bears` with `ON CONFLICT (url) DO NOTHING`.
211
260
212
-
Pagination follows the same `--limit` / `--page_size` / `MAX_PAGES_PER_REQUEST` rules as the production pipeline, so the debug run is a faithful replay of what the worker would do — just with files on disk and an isolated table you can drop and recreate freely.
261
+
Pagination follows the same `--limit` / `--page_size` / `MAX_PAGES_PER_REQUEST` rules as the production pipeline.
213
262
214
263
Use it to:
215
264
- Verify NewsAPI responses for a new keyword/language combination.
216
-
- Reproduce a transform-layer rejection without re-hitting the API (re-run `transform_article_debug` against the saved `data/raw/*.json`).
265
+
- Reproduce a transform-layer rejection without re-hitting the API (re-run transform against saved `data/raw/*.json`).
217
266
- Compare `stats_*.json` across runs when tuning validation rules.
218
-
- Sanity-check the SQL load path on a throwaway table before it touches`articles`.
267
+
- Sanity-check the SQL load path on a throwaway table before touching`articles`.
0 commit comments