Skip to content

Commit 7e4117a

Browse files
authored
Merge pull request #20 from Devathmaj/refactor/deduplication-pipeline
feat: qwen-backed event matching with retroactive consolidation
2 parents 5b1c8ef + 65e53ba commit 7e4117a

18 files changed

Lines changed: 1751 additions & 140 deletions

CONTRIBUTING.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Key obligations that apply directly to this project:
9696
git clone <repo-url>
9797
cd voucherbot
9898
python -m venv .venv && source .venv/bin/activate
99-
pip install -r requirements.txt
99+
pip install -e ".[dev]"
100100

101101
# 2. Configure environment
102102
cp .env.example .env
@@ -120,7 +120,7 @@ voucherbot/
120120
├── main.py # FastAPI app and lifespan
121121
├── config/settings.py # All configuration via pydantic-settings
122122
├── core/ # Exceptions and logging
123-
├── database/ # Engine, init, and bootstrap
123+
├── database/ # Engine, migrations, and bootstrap
124124
├── models/ # SQLAlchemy ORM models
125125
├── providers/
126126
│ ├── base.py # BaseCollector contract
@@ -129,14 +129,21 @@ voucherbot/
129129
│ ├── reddit/
130130
│ │ ├── client.py # Reddit API client ⚠️
131131
│ │ └── collector.py
132-
│ └── website/collector.py
132+
│ ├── website/collector.py
133+
│ ├── pearsonvue/collector.py # Pearson VUE vendor page scraper
134+
│ └── training_provider/collector.py # Training partner page scraper
133135
├── services/
134136
│ ├── scheduler.py # Asyncio scheduler loop
135137
│ ├── dispatcher.py # Lease + source lifecycle
138+
│ ├── event_consolidation.py # Periodic merge of duplicate events
139+
│ ├── retention.py # Null out stale post content
136140
│ ├── ingestion/ # Pipeline, dedup, event matching
137-
│ ├── ai/ # Groq + Gemini provider chain
138-
│ └── email/ # Resend notifications
139-
└── api/routers/ # Read-only REST endpoints
141+
│ ├── ai/ # Groq + Gemini provider chain, AI event matcher
142+
│ ├── email/ # Resend notifications (transactional outbox)
143+
│ └── bot_notification/ # Voucher webhook to a bot server
144+
└── api/
145+
├── rate_limit.py # Health-endpoint rate limiter
146+
└── routers/health.py # Read-only health endpoint
140147
```
141148

142149
Files marked ⚠️ contain policy-sensitive logic. Changes to these files require extra care and a detailed explanation in the PR.
@@ -161,10 +168,10 @@ Files marked ⚠️ contain policy-sensitive logic. Changes to these files requi
161168
New sources are defined in `voucherbot/database/bootstrap.py`. A source entry requires at minimum:
162169

163170
- `name` — unique, descriptive
164-
- `type` — one of `REDDIT`, `RSS`, `BLOG`, `EVENT`, `FORUM`, `WEBSITE`, `API`
171+
- `type` — one of `REDDIT`, `RSS`, `BLOG`, `EVENT`, `FORUM`, `WEBSITE`, `API`, `PEARSONVUE`, `TRAINING_PROVIDER`
165172
- `base_url`
166173
- `priority_tier` — A, B, C, or D (see the scheduler table above)
167-
- `config` — a JSONB object with `feed_url` (RSS), `article_selector` + `content_selector` (Website), or `subreddit` (Reddit)
174+
- `config` — a JSONB object with `feed_url` (RSS), `article_selector` + `content_selector` (Website), `subreddit` (Reddit), or the vendor page type for Pearson VUE / training provider sources
168175

169176
**Before adding a new web source:**
170177

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
*Continuously monitors community and official sources for certification discounts, free exam opportunities, beta exams, and promotional campaigns.*
88

9-
![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)
9+
![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white)
1010
![License](https://img.shields.io/badge/License-see%20LICENSE-lightgrey)
1111
![Deploy](https://img.shields.io/badge/Deploy-Render-46E3B7?logo=render&logoColor=white)
1212
![Status](https://img.shields.io/badge/status-active-success)
@@ -23,6 +23,8 @@ Instead of going through the entire setup and hosting it yourself, you can now *
2323

2424
Just head over to **[voucherbot-preview.pages.dev/#notifications](https://voucherbot-preview.pages.dev/#notifications)** to learn all about it and get it set up in minutes.
2525

26+
The code for the Discord and Telegram bots lives in the separate [Notification-Bot](https://github.com/Devathmaj/Notification-Bot) repository, feel free to check it out.
27+
2628
---
2729

2830
## Table of Contents

Sources/source.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Human-readable reference for all official ingestion sources. The **authoritative runtime catalog** is [`voucherbot/database/bootstrap.py`](../voucherbot/database/bootstrap.py), which seeds the database on app startup.
44

5-
Policy reference: [`deep-research-report (1).md`](../deep-research-report%20(1).md). Collectors prefer RSS/APIs, identify as `VoucherBot`, obey `robots.txt` / Crawl-delay, and skip sources marked `unsupported` (ToS bans HTML scraping).
5+
Collectors prefer RSS/APIs, identify as `VoucherBot`, obey `robots.txt` / Crawl-delay, and skip sources marked `unsupported` (ToS bans HTML scraping).
66

77
## Files
88

@@ -27,7 +27,8 @@ Policy reference: [`deep-research-report (1).md`](../deep-research-report%20(1).
2727
| Collector | Items requested |
2828
|-----------|-----------------|
2929
| Reddit | 25 (`REDDIT_FETCH_LIMIT`) |
30-
| RSS / Website | 25 |
30+
| RSS / Website / Pearson VUE / Training Provider | 10 |
31+
| Curated voucher pages (`note_selector`) | 50 |
3132

3233
Reddit is collected from public RSS feeds by default. The `REDDIT_INGESTION_ENABLED` flag in `.env` (default `false`) gates only the OAuth API: when `false`, the OAuth API is never called and posts come from the RSS feeds.
3334

@@ -40,6 +41,7 @@ These remain in the catalog but are `enabled=false` / `unsupported=true` (RSS al
4041
- ISC2 Insights
4142
- Red Hat Training Specials
4243
- AWS Events / re:Invent pages
44+
- The Register (feed blocked by a proof-of-work challenge)
4345

4446
## Verify sources
4547

docs/details/architecture.md

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ VoucherBot is an async Python service that monitors certification-related source
1111
The application entry point is [voucherbot/main.py](../../voucherbot/main.py). During startup it:
1212

1313
1. configures logging,
14-
2. creates tables and seeds source/keyword data when `IS_PROD=false`,
14+
2. applies Alembic migrations and seeds source/keyword data when `IS_PROD=false`,
1515
3. resets all sources to be due again, and
1616
4. starts the scheduler task.
1717

@@ -45,8 +45,10 @@ The main implementation areas are:
4545
- [voucherbot/services/dispatcher.py](../../voucherbot/services/dispatcher.py) — lease handling, due-source selection, success/failure state updates
4646
- [voucherbot/services/ingestion/pipeline.py](../../voucherbot/services/ingestion/pipeline.py) — end-to-end per-source pipeline
4747
- [voucherbot/services/ingestion/event_matcher.py](../../voucherbot/services/ingestion/event_matcher.py) — canonical event matching and field merging
48+
- [voucherbot/services/event_consolidation.py](../../voucherbot/services/event_consolidation.py) — periodic merge of duplicate canonical events
4849
- [voucherbot/services/ai/analyzer.py](../../voucherbot/services/ai/analyzer.py) — AI extraction provider chain and batching
49-
- [voucherbot/api/routers](../../voucherbot/api/routers) — read-only HTTP endpoints for sources, posts, alerts, and health
50+
- [voucherbot/services/ai/event_matcher_ai.py](../../voucherbot/services/ai/event_matcher_ai.py) — qwen-based same-promotion judge
51+
- [voucherbot/api/routers/health.py](../../voucherbot/api/routers/health.py) — read-only health endpoint with rate limiting
5052

5153
## Scheduler and dispatcher
5254

@@ -98,20 +100,32 @@ New or updated posts are sent to [voucherbot/services/ai/analyzer.py](../../vouc
98100

99101
### 5. Event matching
100102

101-
The matcher in [voucherbot/services/ingestion/event_matcher.py](../../voucherbot/services/ingestion/event_matcher.py) compares extracted fields against existing active events. It uses a weighted score with thresholds for:
103+
The matcher in [voucherbot/services/ingestion/event_matcher.py](../../voucherbot/services/ingestion/event_matcher.py) decides whether an extracted promotion is the same real-world promotion as an existing active event.
102104

103-
- registration URL
104-
- voucher code
105-
- promotion name similarity
106-
- vendor
107-
- certification overlap
108-
- date overlap
105+
By default it runs the incoming promotion through the qwen reasoning model ([voucherbot/services/ai/event_matcher_ai.py](../../voucherbot/services/ai/event_matcher_ai.py)), comparing it against the candidate events that the deterministic weighted score flags as possible matches (score >= `possible_match_threshold`, capped by `ai_candidate_limit`) and letting the model decide whether each is the same promotion:
106+
107+
- `is_same_promotion` and `confidence >= ai_auto_merge_confidence``AUTO_MERGED`
108+
- `is_same_promotion` and `confidence >= ai_possible_match_confidence``POSSIBLE_MATCH`
109+
- otherwise → `NEW`
110+
111+
When the model is unavailable, no `GROQ_API_KEY` is configured, or no candidates exist, the matcher falls back to the legacy weighted score over registration URL, voucher code, promotion-name similarity, vendor, discount, promotion type, certification overlap, and date overlap. The model's `reason` is recorded in `merge_log` for auditability.
109112

110113
The result is one of `AUTO_MERGED`, `POSSIBLE_MATCH`, or `NEW`, and the matcher may merge fields into the canonical event while appending to `merge_log`.
111114

112115
### 6. Email notification
113116

114-
If the AI extraction yields a voucher candidate and the event decision is not `AUTO_MERGED`, the notification service sends an email through Resend. The post is marked `is_notified` only after the send succeeds.
117+
If the AI extraction yields a voucher candidate and the event decision is not `AUTO_MERGED`, delivery intent is staged into the transactional notification outbox in the same commit as the pipeline. Delivery is attempted immediately through Resend with a stable idempotency key; failures stay `PENDING` and are retried by the scheduler. The post is marked `is_notified` only after a send succeeds. The same payload is POSTed to the optional bot server webhook alongside the email (best-effort — a webhook failure never fails the pipeline).
118+
119+
## Event consolidation
120+
121+
Two posts describing the same promotion can become separate events when their sources were processed at different times — the ingestion-time matcher only sees candidates that already exist at that moment. The consolidation sweep in [voucherbot/services/event_consolidation.py](../../voucherbot/services/event_consolidation.py) fixes this retroactively. It runs after every scheduler sweep (throttled by `settings.consolidation.interval_minutes`) and is cross-instance serialised with a Postgres advisory transaction lock.
122+
123+
1. **Discover** — active events are grouped into candidate pairs sharing a cheap identity signal: normalised registration URL, voucher code (case-normalised), or vendor. Pairs are deduplicated by the canonical `(min_id, max_id)` key and capped by `max_pairs_per_sweep`; buckets are sampled to bound quadratic work.
124+
2. **Gate** — each pair is scored with the same deterministic weighted score used at ingestion; only pairs at or above `possible_match_threshold` proceed.
125+
3. **Confirm** — when a Groq key is configured, qwen is asked whether the pair is the same real-world promotion via `compare_events` (the same judge used by the matcher). A `same` decision at `confidence >= ai_possible_match_confidence` merges; otherwise the pair is kept separate. A model outage falls back to the deterministic score at or above `deterministic_auto_merge_threshold`.
126+
4. **Merge** — the pair's survivor is the event with more posts (ties keep the older event). The absorbed event's fields are folded in through the same `_merge_fields` source-priority machinery, its posts are re-pointed to the survivor, both `merge_log` entries are appended, and the absorbed event is set to `ARCHIVED`.
127+
128+
An absorbed event is never folded into a second target within one sweep, and the whole job never raises — failures are logged so the scheduler loop stays healthy.
115129

116130
## Data model summary
117131

@@ -121,6 +135,8 @@ The core SQLAlchemy models are:
121135
- [voucherbot/models/post.py](../../voucherbot/models/post.py)`Post`, `PostStatus`, `VoucherPost`
122136
- [voucherbot/models/event.py](../../voucherbot/models/event.py)`Event`, `EventStatus`, `MatchConfidence`
123137
- [voucherbot/models/keyword.py](../../voucherbot/models/keyword.py) — keyword scoring rows used by the pipeline
138+
- [voucherbot/models/vendor_mapping.py](../../voucherbot/models/vendor_mapping.py) — URL/source-name pattern → vendor lookup
139+
- [voucherbot/models/notification.py](../../voucherbot/models/notification.py) — notification outbox for voucher alert emails
124140
- [voucherbot/models/pipeline_lock.py](../../voucherbot/models/pipeline_lock.py) — pipeline lease row used by the dispatcher
125141

126142
The important relationships are:
@@ -131,13 +147,9 @@ The important relationships are:
131147

132148
## API surface
133149

134-
The FastAPI routes are intentionally read-only and do not implement authentication:
150+
The FastAPI app exposes a single read-only endpoint and does not implement authentication:
135151

136-
- `GET /health` — simple liveness endpoint
137-
- `GET /ready` — DB reachability probe
138-
- `GET /sources` — list sources with optional filters by type or enabled state
139-
- `GET /posts` — list posts with optional filters by status, source type, and minimum score
140-
- `GET /alerts` — list AI-confirmed voucher candidates from the `voucher_posts` view
152+
- `GET /health` — liveness + DB reachability probe (rate-limited per IP)
141153

142154
## Configuration and deployment
143155

docs/details/configuration.md

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ These values are loaded from `.env` through Pydantic settings.
1212
|---|---:|---|
1313
| `DATABASE_URL` | required | Async SQLAlchemy connection string for PostgreSQL |
1414
| `IS_PROD` | `false` | When `true`, startup skips schema/bootstrap work and assumes the database is already prepared |
15+
| `IS_TEST` | `false` | When `true`, seeds a `website:local_test` source pointing at `http://localhost:35926/` for end-to-end pipeline testing |
1516
| `LOG_LEVEL` | `INFO` | Logging level used by the application |
1617

1718
### Email
@@ -21,8 +22,16 @@ These values are loaded from `.env` through Pydantic settings.
2122
| `RESEND_API_KEY` | `None` | API key for Resend-based email delivery |
2223
| `EMAIL_FROM` | `VoucherBot <onboarding@resend.dev>` | Sender address used for alerts |
2324
| `EMAIL_ID` | `None` | Recipient address for voucher notifications |
25+
| `EMAIL_REPLY_TO` | `None` | Optional per-email Reply-To; when unset Resend falls back to the From address |
2426
| `EMAIL_MIN_INTERVAL_SECONDS` | `5.0` | Minimum delay between email sends |
2527

28+
### API rate limiting
29+
30+
| Variable | Default | Purpose |
31+
|---|---:|---|
32+
| `HEALTH_RATE_LIMIT_PER_MINUTE` | `60` | Max `/health` requests per IP per minute; `0` disables the limit |
33+
| `RATE_LIMIT_TRUSTED_PROXIES` | `[]` | Comma-separated proxy IPs whose `X-Forwarded-For` values are trusted for rate limiting |
34+
2635
### Bot webhook notification
2736

2837
| Variable | Default | Purpose |
@@ -59,6 +68,7 @@ These values are loaded from `.env` through Pydantic settings.
5968
| `TICK_JOB_TIMEOUT_SECONDS` | `None` | Optional timeout for scheduler jobs |
6069
| `SOURCE_BACKOFF_BASE_MINUTES` | `5` | Base delay used for recoverable source failures |
6170
| `SOURCE_BACKOFF_MAX_MINUTES` | `360` | Maximum backoff delay for a source |
71+
| `CONTENT_RETENTION_DAYS` | `7` | Posts older than this are content-purged each scheduler sweep |
6272

6373
### AI providers
6474

@@ -75,33 +85,58 @@ These values are loaded from `.env` through Pydantic settings.
7585

7686
Some settings are not loaded from `.env` directly. They are defined in code and can be overridden in tests or custom runtime wiring.
7787

78-
### Event matching weights
88+
### Event matching
7989

8090
These are defined in the `EventMatcherConfig` model:
8191

8292
| Setting | Default | Purpose |
8393
|---|---:|---|
84-
| `weight_registration_url` | `50` | Score weight for exact registration URL matches |
85-
| `weight_voucher_code` | `40` | Score weight for exact voucher-code matches |
86-
| `weight_promotion_name` | `20` | Score weight for promotion-name similarity |
87-
| `weight_vendor` | `15` | Score weight for vendor matches |
88-
| `weight_certifications` | `15` | Score weight for certification overlap |
89-
| `weight_date_overlap` | `10` | Score weight for date-range overlap |
90-
| `auto_merge_threshold` | `75` | Threshold above which an event is auto-merged |
91-
| `possible_match_threshold` | `60` | Threshold above which a possible match is flagged |
92-
| `name_similarity_threshold` | `0.60` | Similarity cutoff for promotion-name credit |
94+
| `use_ai_matcher` | `True` | When enabled, the qwen reasoning model decides whether an incoming promotion matches an existing event |
95+
| `ai_candidate_limit` | `5` | Maximum deterministic-matched candidates submitted to the model per post |
96+
| `ai_auto_merge_confidence` | `0.8` | Model confidence above which a same-promotion decision is an AUTO_MERGED |
97+
| `ai_possible_match_confidence` | `0.5` | Model confidence below which a same-promotion decision is treated as a new event |
98+
| `weight_registration_url` | `50` | Deterministic-fallback score weight for exact registration URL matches |
99+
| `weight_voucher_code` | `40` | Deterministic-fallback score weight for exact voucher-code matches |
100+
| `weight_promotion_name` | `25` | Deterministic-fallback score weight for promotion-name similarity |
101+
| `weight_vendor` | `20` | Deterministic-fallback score weight for vendor matches |
102+
| `weight_discount` | `20` | Deterministic-fallback score weight for discount matches |
103+
| `weight_promotion_type` | `10` | Deterministic-fallback score weight for promotion-type matches |
104+
| `weight_certifications` | `15` | Deterministic-fallback score weight for certification overlap |
105+
| `weight_date_overlap` | `10` | Deterministic-fallback score weight for date-range overlap |
106+
| `auto_merge_threshold` | `70` | Deterministic-fallback threshold above which an event is auto-merged |
107+
| `possible_match_threshold` | `45` | Deterministic-fallback threshold above which a possible match is flagged |
108+
| `name_similarity_threshold` | `0.60` | Deterministic-fallback similarity cutoff for promotion-name credit |
109+
| `candidate_limit` | `100` | Maximum candidate events retrieved for matching |
110+
111+
The deterministic weighted score is only used as a fallback when the qwen model is unavailable, no `GROQ_API_KEY` is configured, or no candidates exist.
112+
113+
### Event consolidation
114+
115+
These are defined in the `EventConsolidationConfig` model and tune the periodic sweep that merges duplicate canonical events ([voucherbot/services/event_consolidation.py](../../voucherbot/services/event_consolidation.py)):
116+
117+
| Setting | Default | Purpose |
118+
|---|---:|---|
119+
| `enabled` | `True` | Master switch for the consolidation sweep |
120+
| `interval_minutes` | `60` | Minimum wall-clock time between sweeps (rate-limits the qwen spend) |
121+
| `max_pairs_per_sweep` | `1000` | Hard cap on candidate pairs examined per sweep |
122+
| `max_ai_calls_per_sweep` | `25` | How many qwen confirmations to allow per sweep |
123+
| `deterministic_auto_merge_threshold` | `70` | Deterministic-score floor for merging when the model is unavailable |
124+
125+
The sweep runs after each scheduler sweep, groups active events by normalised registration URL, voucher code, or vendor, gates pairs with the deterministic weighted score (`possible_match_threshold`), and lets qwen confirm whether each pair is the same real-world promotion before merging and archiving the loser.
93126

94127
### Source priority ordering
95128

96129
The `SOURCE_PRIORITY` list defines how source types are ranked when merging event fields:
97130

98131
1. `WEBSITE`
99-
2. `EVENT`
100-
3. `BLOG`
101-
4. `RSS`
102-
5. `FORUM`
103-
6. `REDDIT`
104-
7. `API`
132+
2. `PEARSONVUE`
133+
3. `TRAINING_PROVIDER`
134+
4. `EVENT`
135+
5. `BLOG`
136+
6. `RSS`
137+
7. `FORUM`
138+
8. `REDDIT`
139+
9. `API`
105140

106141
Higher-priority sources overwrite lower-priority values when a new post updates an existing event.
107142

0 commit comments

Comments
 (0)