Skip to content

Commit f893957

Browse files
committed
upd readme
1 parent ca76c65 commit f893957

1 file changed

Lines changed: 100 additions & 51 deletions

File tree

README.md

Lines changed: 100 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
# NEWS_API_ETL_Project
22

3-
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.
44

55
## What this project does
6+
67
1. **extract** (`src/extract.py`) — fetches paginated articles from NewsAPI with timeout, retries, exponential backoff, and `429`/`5xx` handling.
78
2. **transform** (`src/transform.py`) — validates each article, normalizes text and timestamps, classifies rejection reasons and accumulates per-page statistics.
89
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`.
910
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.
1215

1316
## Repository structure
17+
1418
```text
1519
project/
1620
├── config/
1721
│ └── config.py # Settings dataclass loaded from .env
18-
├── data/
19-
│ ├── raw/ # Debug-mode raw NewsAPI payloads (JSON per page)
20-
│ └── clean/
21-
│ ├── cleaned_*.json # Debug-mode normalized articles
22-
│ └── stats/ # Debug-mode per-page transform stats
2322
├── notebooks/
2423
│ └── 01_eda.ipynb
2524
├── src/
@@ -29,8 +28,8 @@ project/
2928
│ ├── transform.py # Validation, normalization, rejection stats
3029
│ ├── load.py # Upserts and request_stats persistence
3130
│ ├── pipeline.py # run_debug_pipeline / run_pipeline_for_web_user
32-
│ ├── worker.py # Queue worker loop
33-
│ ├── user_news_api_key.py # AES-GCM key encryption helpers
31+
│ ├── worker.py # Queue worker loop (key validation + search requests)
32+
│ ├── user_news_api_key.py # AES-GCM encryption helpers + key validation
3433
│ └── ai/ # Mistral-based pocket summary
3534
│ ├── __init__.py
3635
│ ├── client.py
@@ -39,17 +38,21 @@ project/
3938
│ └── schemas.py
4039
├── tests/
4140
│ ├── test_pipeline.py
42-
│ └── test_transform.py
41+
│ ├── test_transform.py
42+
│ └── test_ai_report.py
4343
├── .env.example
44-
├── Dockerfile
44+
├── DockerFile
4545
├── docker-compose.yml
4646
├── main.py # CLI entry point
4747
├── pyproject.toml # Ruff + pytest config
4848
├── requirements.txt
4949
└── requirements-dev.txt
5050
```
5151

52+
> `data/raw/` and `data/clean/` are created on demand by debug runs and are gitignored.
53+
5254
## Environment variables
55+
5356
Copy `.env.example` to `.env` and fill in your values:
5457

5558
```bash
@@ -62,11 +65,12 @@ cp .env.example .env
6265
- `NEWS_DB` (default `news_db`)
6366

6467
**NewsAPI**
65-
- `NEWSAPI_KEY` — used as the fallback key in CLI/debug mode (worker mode reads each user's key from `users_keys` instead)
68+
- `NEWSAPI_KEY`app owner's key; used as the fallback in CLI/debug mode and for all trial requests in worker mode
6669
- `NEWSAPI_URL` (default `https://newsapi.org/v2/everything`)
6770
- `NEWSAPI_DEFAULT_LANGUAGE` (default `ru`)
6871
- `NEWSAPI_SORT_BY` (default `publishedAt`; one of `relevancy`, `popularity`, `publishedAt`)
6972
- `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)
7074

7175
**AI summary (Mistral)**
7276
- `MISTRAL_API_KEY` — required when `AI_SUMMARY_ENABLED=true`
@@ -86,41 +90,65 @@ cp .env.example .env
8690
## Local run
8791

8892
### 1. Install
89-
```powershell
93+
94+
```bash
9095
python -m venv .venv
91-
.venv\Scripts\activate
96+
source .venv/bin/activate # macOS/Linux
97+
# .venv\Scripts\activate # Windows
9298
pip install -r requirements.txt
9399
```
94100

95101
### 2. Initialize the database and tables
96-
```powershell
102+
103+
```bash
97104
python main.py --init-only
98105
```
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.
100108

101109
### 3. Web mode (single run, requires existing `app_users` and `search_requests` rows)
102-
```powershell
110+
111+
```bash
103112
python main.py --keyword python --limit 20 --page_size 50 --language en --user_id 1 --search_request_id 1
104113
```
105-
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`.
106116

107117
### 4. Worker loop (production mode)
108-
```powershell
118+
119+
```bash
109120
python main.py --worker --poll_interval 3
110121
```
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:
124+
1. **Key validation** — picks a `pending_validation` key, pings NewsAPI, writes `valid`/`invalid`/`exhausted`.
125+
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.
112128

113129
### 5. Debug mode
130+
114131
See the **Debug mode** section at the bottom of this README.
115132

116133
## 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+
117143
```bash
118144
docker compose up --build
119145
```
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`).
121148

122149
## Quality checks
123-
```powershell
150+
151+
```bash
124152
pip install -r requirements-dev.txt
125153
python -m compileall .
126154
ruff check .
@@ -133,86 +161,107 @@ pip-audit
133161

134162
| Table | Purpose |
135163
|---|---|
136-
| `app_users` | Web users (Google OAuth identity). |
137-
| `users_keys` | Per-user encrypted NewsAPI keys (AES-GCM). |
138-
| `search_requests` | Queue of search jobs with status (`queued`/`running`/`success`/`failed`). |
164+
| `app_users` | Web users (Google OAuth). Includes `trial_uses` counter tracking how many free trial searches a user has consumed. |
165+
| `users_keys` | Per-user encrypted NewsAPI keys (AES-GCM). Each key goes through a validation lifecycle: `pending_validation → validating → valid / invalid / exhausted`. |
166+
| `search_requests` | Queue of search jobs with status (`queued`/`running`/`success`/`failed`) and `is_trial` flag. |
139167
| `articles` | Deduplicated articles, unique by `url`. |
140168
| `user_news` | Many-to-many link between `app_users`, `search_requests`, and `articles`. |
141-
| `request_stats` | Per-request totals and rejection-reason breakdown (JSONB). |
169+
| `request_stats` | Per-request totals: `income_articles`, `accepted_articles`, `rejected_articles`, `reasons_counts` (JSONB), `prime_reasons` (JSONB). |
142170
| `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.
144190

145191
## AI summary flow
146192

147193
After `run_pipeline_for_web_user` finishes loading and persists `request_stats`, it calls `_generate_and_store_ai_summary`, which:
148194

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 = ?`.
150196
2. Truncates the article list to `AI_REPORT_MAX_ARTICLES` (default 50) before calling the model.
151197
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).
153199
5. Upserts a row in `request_ai_report` keyed by `search_request_id` with `status='success'`.
154200

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`.
158202

159203
Stored summary fields:
160-
- `status` (`success` | `failed`) and `error_text` (populated only when `status='failed'`)
204+
- `status` (`success` | `failed`) and `error_text`
161205
- `news_count` — articles linked to this request (capped at `AI_REPORT_MAX_ARTICLES`)
162206
- `summary` — 1-2 sentence neutral overview
163207
- `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`
165209
- `sentiment_distribution` (JSONB) — `{positive, negative, neutral}` summing to 100
166210
- `main_topics` (JSONB) — 1 to 3 topics in order of importance
167211
- `highlight` (JSONB) — `{url, title, author?, description?, reason}`
168-
- `data_quality_warnings` (JSONB) — pipeline-detected warnings + any flagged by the model
169-
- `model_provider`, `model_name`, `promt_version`, `input_tokens`, `output_tokens`, `total_tokens`
212+
- `data_quality_warnings` (JSONB)
213+
- `model_provider`, `model_name`, `promt_version` (DB column name), `input_tokens`, `output_tokens`, `total_tokens`
170214

171215
## Reliability and safety guarantees
216+
172217
- HTTP retries with backoff and `Retry-After` handling (`429`, `5xx`).
173-
- Strict input validation in transform layer; rejected records are counted by reason and surfaced in `request_stats`.
218+
- Strict input validation in transform layer; rejected records counted by reason in `request_stats`.
174219
- Idempotent article upsert on `url`; idempotent `user_news` insert on `(user_id, article_id, search_request_id)`.
175220
- Atomic worker dequeue (`FOR UPDATE SKIP LOCKED`), safe for multiple worker processes.
176221
- 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.
178223

179224
## Notes
225+
180226
- `.env`, `.venv`, `__pycache__`, and generated files under `data/raw` and `data/clean` are gitignored.
181227
- Never commit real keys — only `.env.example` should be in git.
182228

183229
## Troubleshooting
230+
184231
- **`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).
232+
- **`password authentication failed`** — verify `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`.
233+
- **`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`.
188237

189238
---
190239

191240
## Debug mode (`--debug`)
192241

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

195-
```powershell
244+
```bash
196245
python main.py --debug --keyword python --limit 20 --page_size 50 --language en
197246
```
198247

199248
What it does, page by page:
200249

201-
1. **Extract**`make_extract_debug` calls NewsAPI with the global `NEWSAPI_KEY` from `.env` and writes the raw payload to:
250+
1. **Extract** — calls NewsAPI with `NEWSAPI_KEY` from `.env` and writes the raw payload to:
202251
```
203252
data/raw/<UTC-timestamp>_<sanitized-keyword>_page_<N>.json
204253
```
205-
2. **Transform**`transform_article_debug` reads that raw file, runs the same validation/normalization used in production, and writes two artifacts:
254+
2. **Transform** — runs the same validation/normalization as production and writes:
206255
```
207256
data/clean/cleaned_<keyword>_page_<N>_<timestamp>.json # accepted articles
208-
data/clean/stats/stats_<keyword>_page_<N>_<timestamp>.json # totals + rejection reasons
257+
data/clean/stats/stats_<keyword>_page_<N>_<timestamp>.json
209258
```
210-
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`.
211260

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

214263
Use it to:
215264
- 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`).
217266
- 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

Comments
 (0)