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
│ └── 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
140
147
```
141
148
142
149
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
161
168
New sources are defined in `voucherbot/database/bootstrap.py`. A source entry requires at minimum:
162
169
163
170
-`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`
165
172
-`base_url`
166
173
-`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
@@ -23,6 +23,8 @@ Instead of going through the entire setup and hosting it yourself, you can now *
23
23
24
24
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.
25
25
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.
Copy file name to clipboardExpand all lines: Sources/source.md
+4-2Lines changed: 4 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,7 +2,7 @@
2
2
3
3
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.
4
4
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).
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.
33
34
@@ -40,6 +41,7 @@ These remain in the catalog but are `enabled=false` / `unsupported=true` (RSS al
40
41
- ISC2 Insights
41
42
- Red Hat Training Specials
42
43
- AWS Events / re:Invent pages
44
+
- The Register (feed blocked by a proof-of-work challenge)
-[voucherbot/api/routers/health.py](../../voucherbot/api/routers/health.py) — read-only health endpoint with rate limiting
50
52
51
53
## Scheduler and dispatcher
52
54
@@ -98,20 +100,32 @@ New or updated posts are sent to [voucherbot/services/ai/analyzer.py](../../vouc
98
100
99
101
### 5. Event matching
100
102
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.
102
104
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.
109
112
110
113
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`.
111
114
112
115
### 6. Email notification
113
116
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.
115
129
116
130
## Data model summary
117
131
@@ -121,6 +135,8 @@ The core SQLAlchemy models are:
Copy file name to clipboardExpand all lines: docs/details/configuration.md
+51-16Lines changed: 51 additions & 16 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -12,6 +12,7 @@ These values are loaded from `.env` through Pydantic settings.
12
12
|---|---:|---|
13
13
|`DATABASE_URL`| required | Async SQLAlchemy connection string for PostgreSQL |
14
14
|`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 |
15
16
|`LOG_LEVEL`|`INFO`| Logging level used by the application |
16
17
17
18
### Email
@@ -21,8 +22,16 @@ These values are loaded from `.env` through Pydantic settings.
21
22
|`RESEND_API_KEY`|`None`| API key for Resend-based email delivery |
22
23
|`EMAIL_FROM`|`VoucherBot <onboarding@resend.dev>`| Sender address used for alerts |
23
24
|`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 |
24
26
|`EMAIL_MIN_INTERVAL_SECONDS`|`5.0`| Minimum delay between email sends |
25
27
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
+
26
35
### Bot webhook notification
27
36
28
37
| Variable | Default | Purpose |
@@ -59,6 +68,7 @@ These values are loaded from `.env` through Pydantic settings.
59
68
|`TICK_JOB_TIMEOUT_SECONDS`|`None`| Optional timeout for scheduler jobs |
60
69
|`SOURCE_BACKOFF_BASE_MINUTES`|`5`| Base delay used for recoverable source failures |
61
70
|`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 |
62
72
63
73
### AI providers
64
74
@@ -75,33 +85,58 @@ These values are loaded from `.env` through Pydantic settings.
75
85
76
86
Some settings are not loaded from `.env` directly. They are defined in code and can be overridden in tests or custom runtime wiring.
77
87
78
-
### Event matching weights
88
+
### Event matching
79
89
80
90
These are defined in the `EventMatcherConfig` model:
81
91
82
92
| Setting | Default | Purpose |
83
93
|---|---:|---|
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.
93
126
94
127
### Source priority ordering
95
128
96
129
The `SOURCE_PRIORITY` list defines how source types are ranked when merging event fields:
97
130
98
131
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`
105
140
106
141
Higher-priority sources overwrite lower-priority values when a new post updates an existing event.
0 commit comments