Per merhant pickup rate alerting#719
Conversation
Adds a periodic background task that monitors call pickup rates and sends
Slack warning alerts when rates drop below a configurable threshold.
- Add app/services/pickup_rate/ module with:
- config.py: AlertConfig dataclass (enabled, interval, threshold, alert_type, scope)
with per-merchant fields stubbed for Phase 2 extension
- calculator.py: compute_pickup_rates() mirroring ScoreMonitor._get_daily_call_stats()
for both call-based and lead-based rate calculation
- monitor.py: PickupRateMonitor class with threshold evaluation, Redis-based dedup
(fail-open), and inline slack_alert.send() for alerts
- task.py: initialize_pickup_rate_tasks() following langfuse task registration pattern
- Add 3 dynamic config getters in dynamic.py: ENABLE_PICKUP_RATE_ALERT,
PICKUP_RATE_ALERT_INTERVAL_SECONDS, PICKUP_RATE_ALERT_THRESHOLD
- Register task in main.py lifespan after Langfuse task registration
Edge cases handled: zero calls/leads skipped, Redis unavailable fails open,
DB errors skip the cycle, Slack failures skip mark-alerted for retry.
Lookback window derived from interval_seconds (no separate config needed).
Per-merchant alerting scaffolded for Phase 2 (call_execution_config + DB filters).
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughThis PR introduces a merchant pickup rate alert system with background task monitoring. Changes include database schema extensions for per-merchant alert configuration, new dynamic configuration accessors, query and accessor updates to support merchant filtering, a new multi-module service layer for rate calculation and Slack notifications with Redis deduplication, and wiring of background scheduler tasks during application startup. Changes
Sequence DiagramsequenceDiagram
participant Scheduler as BackgroundScheduler
participant Monitor as PickupRateMonitor
participant Config as DynamicConfig
participant DB as Database
participant Redis
participant Slack
Scheduler->>Monitor: check_and_alert() or<br/>check_all_merchants()
Monitor->>Config: load_config()
Config-->>Monitor: AlertConfig
rect rgba(100, 150, 200, 0.5)
Note over Monitor,DB: Fetch & Calculate
alt check_all_merchants
Monitor->>DB: get_merchants_with_pickup_rate_alert_enabled()
DB-->>Monitor: [merchant_list]
loop For each merchant
Monitor->>DB: compute_pickup_rates(merchant_id)
end
else check_and_alert
Monitor->>DB: compute_pickup_rates()
end
DB-->>Monitor: {call_count, lead_count, call_rate, lead_rate}
end
rect rgba(200, 150, 100, 0.5)
Note over Monitor,Redis: Threshold & Dedup Check
Monitor->>Monitor: _is_threshold_breached()
alt threshold breached
Monitor->>Redis: check dedup key exists
Redis-->>Monitor: key_exists (true/false)
alt not deduplicated
Monitor->>Slack: send alert notification
Slack-->>Monitor: success/failure
alt send succeeded
Monitor->>Redis: _mark_alerted() with TTL
end
end
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds global + per-merchant pickup-rate alerting, including merchant-level configuration stored in Postgres and a new background task that evaluates enabled merchants and posts Slack alerts when pickup rates fall below thresholds.
Changes:
- Adds merchant DB fields (
pickup_rate_alert_enabled,pickup_rate_alert_threshold) via migration and wires them through merchant queries/accessors/decoders/schemas/handlers. - Introduces a new pickup-rate alerting service (monitor + calculator) and registers background tasks for global and per-merchant alerting.
- Extends lead_call_tracker query/accessor APIs to support
merchant_idfiltering for scoped pickup-rate calculations.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| app/services/pickup_rate/task.py | Registers global + per-merchant pickup-rate monitor tasks with the scheduler. |
| app/services/pickup_rate/monitor.py | Implements shared alert flow and adds per-merchant monitoring loop. |
| app/services/pickup_rate/config.py | Adds AlertConfig used to drive scoped alert runs and Redis dedup keys. |
| app/services/pickup_rate/calculator.py | Computes call/lead pickup rates (now supports merchant scoping). |
| app/services/pickup_rate/init.py | Exposes pickup-rate monitor + task initializer as a service module. |
| app/services/langfuse/tasks/score_monitor/score.py | Adjusts FINISHED-call outcome counting to align with intended semantics. |
| app/schemas/breeze_buddy/merchants.py | Adds per-merchant pickup-rate alert fields to create/update/response schemas. |
| app/main.py | Registers pickup-rate background tasks during app lifespan startup. |
| app/database/queries/breeze_buddy/merchants.py | Persists/returns new merchant alert config fields; adds “enabled merchants” query. |
| app/database/queries/breeze_buddy/lead_call_tracker.py | Adds merchant_id filtering to call-tracker and lead-analytics queries. |
| app/database/migrations/025_add_pickup_rate_alert_to_merchants.sql | Adds merchant alert config columns + index. |
| app/database/decoder/breeze_buddy/merchants.py | Decodes the new merchant alert config fields into responses. |
| app/database/accessor/breeze_buddy/merchants.py | Wires new merchant alert config fields + adds accessor to list enabled merchants. |
| app/database/accessor/breeze_buddy/lead_call_tracker.py | Wires merchant_id filter through accessor layer. |
| app/core/config/dynamic.py | Adds dynamic config getters for pickup-rate alert enable/interval/threshold. |
| app/api/routers/breeze_buddy/merchants/handlers.py | Passes through new merchant alert config fields on create/update. |
| async def compute_pickup_rates( | ||
| start_date: datetime, | ||
| end_date: datetime, | ||
| reseller_id: Optional[str] = None, # Phase 2: filter by reseller | ||
| merchant_id: Optional[str] = None, # Phase 2: filter by merchant | ||
| ) -> Optional[Dict[str, Any]]: |
There was a problem hiding this comment.
compute_pickup_rates() accepts reseller_id and the monitor passes it through, but the value is never used in any downstream query calls. This is misleading (and could produce incorrect scoping if reseller-level alerting is expected). Either remove the parameter from the API/config until it’s supported, or plumb it through to the DB accessors/queries so the calculation can actually be filtered by reseller.
| call_trackers = await get_all_lead_call_trackers( | ||
| start_date=start_date, | ||
| end_date=end_date, | ||
| merchant_id=merchant_id, | ||
| ) | ||
|
|
||
| calls_attempted = 0 | ||
| calls_no_answer = 0 | ||
|
|
||
| if call_trackers: | ||
| for tracker, _provider in call_trackers: | ||
| if tracker.status and tracker.status.value == "FINISHED": | ||
| calls_attempted += 1 | ||
| if tracker.outcome == "NO_ANSWER": | ||
| calls_no_answer += 1 | ||
|
|
There was a problem hiding this comment.
Call-based metrics currently fetch all lead_call_tracker rows for the window (get_all_lead_call_trackers(...)) and then count in Python. With per-merchant alerting (check_all_merchants) this scales as O(#enabled_merchants × #calls_in_window) in data transfer and memory. Prefer a SQL aggregation (e.g., COUNT(*) FILTER (WHERE status='FINISHED'), COUNT(*) FILTER (WHERE status='FINISHED' AND outcome='NO_ANSWER')) so only small aggregates are returned per scope.
| """ | ||
| AlertConfig dataclass for the Pickup Rate Alert system. | ||
|
|
||
| Phase 1: global alerting only. | ||
| Phase 2 will extend this with optional merchant_id / reseller_id for per-merchant scope. | ||
| """ |
There was a problem hiding this comment.
The module docstring still describes per-merchant merchant_id/reseller_id fields as a “Phase 2” forward-compatibility extension that is “unused”, but this PR wires and uses them (per-merchant alerting + scoped calculator calls). Updating the docstring will avoid confusion for future maintainers.
| if pickup_rate_alert_threshold is not None: | ||
| set_clauses.append(f"pickup_rate_alert_threshold = ${param_idx}") | ||
| params.append(pickup_rate_alert_threshold) | ||
| param_idx += 1 |
There was a problem hiding this comment.
update_merchant_query currently only updates pickup_rate_alert_threshold when the value is non-NULL. That makes it impossible to clear an existing threshold back to NULL (so the merchant falls back to the global PICKUP_RATE_ALERT_THRESHOLD), even though the schema/migration semantics treat NULL as meaningful. Consider supporting an explicit “clear to NULL” path (e.g., separate clear_pickup_rate_alert_threshold flag, or a sentinel value similar to call_execution_config.telephony_config == "__CLEAR__"), and use the request model’s field-set information to distinguish “not provided” vs “provided as null”.
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/database/accessor/breeze_buddy/merchants.py (1)
268-292:⚠️ Potential issue | 🟠 MajorSupport clearing
pickup_rate_alert_thresholdback to global fallback.
Nonecurrently means “leave unchanged”, butNULLis also the persisted value that makes the monitor fall back to the global threshold. Once a merchant has a custom threshold, the update path cannot clear it; preserve whether the field was supplied and generatepickup_rate_alert_threshold = NULLfor explicit clears.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/database/accessor/breeze_buddy/merchants.py` around lines 268 - 292, The update path currently treats pickup_rate_alert_threshold=None as "leave unchanged" so callers cannot clear a merchant's custom threshold; change the API and query generation to distinguish "not supplied" from "explicitly supplied None": add a companion flag (e.g., pickup_rate_alert_threshold_supplied: bool) or use a sentinel object on the update_merchant_query and the caller that invokes it, then update update_merchant_query to emit "pickup_rate_alert_threshold = NULL" when supplied is true and value is None, emit "pickup_rate_alert_threshold = :val" when supplied is true and value is not None, and omit the column from the SET clause when supplied is false so existing behavior remains; update the function signature(s) that call update_merchant_query to pass the new flag/sentinel accordingly.
🧹 Nitpick comments (3)
app/database/migrations/025_add_pickup_rate_alert_to_merchants.sql (1)
11-12: Consider a partial index to reduce index size.Since the primary use case is
WHERE pickup_rate_alert_enabled = true(the newget_merchants_with_pickup_rate_alert_enabled_query), and the column defaults tofalsefor virtually all merchants, a partial index would be more efficient than a full index on a low-cardinality boolean.♻️ Proposed partial index
-CREATE INDEX IF NOT EXISTS idx_merchants_pickup_rate_alert_enabled - ON merchants(pickup_rate_alert_enabled); +CREATE INDEX IF NOT EXISTS idx_merchants_pickup_rate_alert_enabled + ON merchants(pickup_rate_alert_enabled) + WHERE pickup_rate_alert_enabled = true;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/database/migrations/025_add_pickup_rate_alert_to_merchants.sql` around lines 11 - 12, Replace the full boolean index with a partial index scoped to true values: change the CREATE INDEX statement for idx_merchants_pickup_rate_alert_enabled on merchants(pickup_rate_alert_enabled) to a partial index with "WHERE pickup_rate_alert_enabled = true" (keep IF NOT EXISTS if desired) so the index supports the get_merchants_with_pickup_rate_alert_enabled_query more efficiently and avoids indexing the many false rows.app/services/pickup_rate/config.py (1)
48-55: LGTM, minor note on scope sanitization.Sanitization only strips
:and/. Sincescopeis constructed internally (e.g.f"merchant:{merchant_id}") this is fine today, but ifmerchant.merchant_idever contains whitespace or other Redis-unfriendly chars they'll flow into the key. Consider a stricter sanitizer (e.g. regex to[A-Za-z0-9_.-]) if merchant IDs are user-supplied.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/services/pickup_rate/config.py` around lines 48 - 55, The current __post_init__ builds _redis_key by only replacing ":" and "/", which can allow whitespace or other unsafe characters from scope into redis_dedup_key; update __post_init__ to sanitize scope more strictly (e.g. keep only a safe charset like A-Za-z0-9_.- using a regex replacement) before constructing _redis_key so scope values (used to form merchant:{merchant_id}) are normalized and Redis-friendly; ensure the same sanitized value is what redis_dedup_key returns.app/database/accessor/breeze_buddy/merchants.py (1)
365-370: Return an empty list on this list-accessor failure.This new list accessor currently re-raises after logging. Returning
[]keeps the background monitor on the established safe-sentinel contract for list accessors.Suggested accessor behavior
except Exception as e: logger.error(f"Error fetching merchants with pickup rate alert enabled: {e}") - raise + return []Based on learnings, async accessor functions should catch broad exceptions and return a safe sentinel value on failure (
Nonefor single-value accessors,[]for list accessors) rather than re-raising.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/database/accessor/breeze_buddy/merchants.py` around lines 365 - 370, The exception handler in the list accessor that calls run_parameterized_query and returns decoded merchants (using decode_merchant) should not re-raise on failure; instead log the error via logger.error and return an empty list [] as the safe sentinel for list accessors. Update the except Exception as e block in the function that fetches merchants with pickup rate alert enabled to return [] after logging the error rather than raising the exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/core/config/dynamic.py`:
- Around line 55-62: Validate and clamp/guard the dynamic Redis values before
returning them: in PICKUP_RATE_ALERT_INTERVAL_SECONDS call get_config as before
but enforce a sensible range (e.g., minimum 60 seconds, maximum 604800 seconds /
7 days) and if the retrieved value is out of range or non-positive, log a
warning and return the default 86400; in PICKUP_RATE_ALERT_THRESHOLD call
get_config but ensure the value is a float between 0.0 and 100.0 inclusive, and
if it's outside that range or NaN, log a warning and return the default 40.0.
Use the existing get_config helper and reference the functions
PICKUP_RATE_ALERT_INTERVAL_SECONDS and PICKUP_RATE_ALERT_THRESHOLD when adding
the validation and logging.
In `@app/database/queries/breeze_buddy/merchants.py`:
- Around line 216-224: The update path currently uses "if
pickup_rate_alert_threshold is not None" which prevents callers from explicitly
clearing the threshold to NULL; change the logic to respect Pydantic's
Model.model_fields_set instead: when building set_clauses in the update function
(and when the handler reads MerchantUpdate), check whether
"pickup_rate_alert_threshold" is present in merchant_data.model_fields_set and,
if present, include pickup_rate_alert_threshold in params even if its value is
None so the DB receives NULL; alternatively implement an explicit sentinel or a
clear_threshold flag, but prefer using MerchantUpdate.model_fields_set and
adjust any caller code that currently passes
merchant_data.pickup_rate_alert_threshold directly (and the monitor
check_all_merchants behavior remains unchanged).
In `@app/schemas/breeze_buddy/merchants.py`:
- Around line 35-39: The pickup_rate_alert_threshold field on the Merchant model
and the same field on MerchantUpdate need explicit value bounds to prevent
invalid percentages; update the Field definitions for
pickup_rate_alert_threshold (and its counterpart in MerchantUpdate) to enforce 0
<= value <= 100 (e.g., add ge=0 and le=100 or equivalent Pydantic validators)
and include a short description or error message indicating the allowed range so
out-of-range values are rejected at schema validation time.
In `@app/services/pickup_rate/__init__.py`:
- Around line 11-15: The __all__ export list violates Ruff RUF022 ordering; sort
the entries alphabetically so the list reads e.g.
"initialize_pickup_rate_tasks", "pickup_rate_monitor", "PickupRateMonitor" (or
the alphabetic order appropriate for your case) to satisfy the linter and keep
exports consistent; update the __all__ definition in the module that currently
contains "__all__ = [...]" referencing PickupRateMonitor, pickup_rate_monitor,
and initialize_pickup_rate_tasks.
In `@app/services/pickup_rate/calculator.py`:
- Around line 51-94: The code pulls all rows via get_all_lead_call_trackers(...)
(and similar call in score_monitor/score.py) without pagination and then tallies
calls in Python, causing unbounded memory usage; replace this with a single
DB-side aggregation by adding a new helper (e.g.
get_call_pickup_counts_query()/get_call_pickup_counts) that returns attempted
and no_answer counts as one row and call that from calculator.py instead of
get_all_lead_call_trackers, or if you must iterate keep using paginated calls
(page/page_size) to avoid limit=None; also remove the redundant "if
tracker.status and" check since status is non-optional. Ensure the new function
is used to compute call_pickup_rate (calls_picked = attempted - no_answer) and
update score_monitor/score.py call sites accordingly.
In `@app/services/pickup_rate/monitor.py`:
- Line 148: The comment/log strings in monitor.py use an en dash (–) which
triggers RUF001/RUF003; locate occurrences such as the comment "Skip when there
is no data – avoids false alerts on idle systems" and any other strings
containing "–" and replace the en dash with a plain hyphen "-" (for example in
the other flagged comments/log lines that contain "–"); ensure you update both
comments and any logged messages so the code no longer contains the en dash
character.
- Around line 85-94: The AlertConfig is incorrectly including
merchant.reseller_id which over-constrains per-merchant metrics; remove or null
out the reseller_id when creating merchant-scoped configs in the block that
constructs AlertConfig (the code that sets enabled, interval_seconds,
threshold_percent, alert_type, scope, merchant_id, reseller_id) so the config
only scopes by merchant_id (e.g., omit reseller_id or set reseller_id=None)
before calling self._run_alert_for_config(config); this ensures downstream
functions like compute_pickup_rates / lead-call queries are not filtered by the
merchant owner.
In `@app/services/pickup_rate/task.py`:
- Around line 26-27: Docstrings and log strings contain en dashes (–) which
RUF001/RUF002 flags; replace them with plain ASCII hyphens (-). Update the
occurrences in the module-level docstring and any log or message strings
referenced for the pickup_rate_monitor and pickup_rate_monitor_merchants symbols
(and other strings around the same locations) by swapping the U+2013 character
for a hyphen-minus so lint no longer errors.
- Line 21: The function initialize_pickup_rate_tasks lacks a type hint for its
scheduler parameter; update its signature to include an explicit type (e.g., def
initialize_pickup_rate_tasks(scheduler: Any) -> bool or, if you use APScheduler,
scheduler: AsyncIOScheduler) and add the corresponding import (from typing
import Any or from apscheduler.schedulers.asyncio import AsyncIOScheduler) so
the scheduler parameter is annotated while keeping the function name
initialize_pickup_rate_tasks unchanged.
---
Outside diff comments:
In `@app/database/accessor/breeze_buddy/merchants.py`:
- Around line 268-292: The update path currently treats
pickup_rate_alert_threshold=None as "leave unchanged" so callers cannot clear a
merchant's custom threshold; change the API and query generation to distinguish
"not supplied" from "explicitly supplied None": add a companion flag (e.g.,
pickup_rate_alert_threshold_supplied: bool) or use a sentinel object on the
update_merchant_query and the caller that invokes it, then update
update_merchant_query to emit "pickup_rate_alert_threshold = NULL" when supplied
is true and value is None, emit "pickup_rate_alert_threshold = :val" when
supplied is true and value is not None, and omit the column from the SET clause
when supplied is false so existing behavior remains; update the function
signature(s) that call update_merchant_query to pass the new flag/sentinel
accordingly.
---
Nitpick comments:
In `@app/database/accessor/breeze_buddy/merchants.py`:
- Around line 365-370: The exception handler in the list accessor that calls
run_parameterized_query and returns decoded merchants (using decode_merchant)
should not re-raise on failure; instead log the error via logger.error and
return an empty list [] as the safe sentinel for list accessors. Update the
except Exception as e block in the function that fetches merchants with pickup
rate alert enabled to return [] after logging the error rather than raising the
exception.
In `@app/database/migrations/025_add_pickup_rate_alert_to_merchants.sql`:
- Around line 11-12: Replace the full boolean index with a partial index scoped
to true values: change the CREATE INDEX statement for
idx_merchants_pickup_rate_alert_enabled on merchants(pickup_rate_alert_enabled)
to a partial index with "WHERE pickup_rate_alert_enabled = true" (keep IF NOT
EXISTS if desired) so the index supports the
get_merchants_with_pickup_rate_alert_enabled_query more efficiently and avoids
indexing the many false rows.
In `@app/services/pickup_rate/config.py`:
- Around line 48-55: The current __post_init__ builds _redis_key by only
replacing ":" and "/", which can allow whitespace or other unsafe characters
from scope into redis_dedup_key; update __post_init__ to sanitize scope more
strictly (e.g. keep only a safe charset like A-Za-z0-9_.- using a regex
replacement) before constructing _redis_key so scope values (used to form
merchant:{merchant_id}) are normalized and Redis-friendly; ensure the same
sanitized value is what redis_dedup_key returns.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 60f7dbb4-2499-41ea-a5ce-1dc66728ae91
📒 Files selected for processing (16)
app/api/routers/breeze_buddy/merchants/handlers.pyapp/core/config/dynamic.pyapp/database/accessor/breeze_buddy/lead_call_tracker.pyapp/database/accessor/breeze_buddy/merchants.pyapp/database/decoder/breeze_buddy/merchants.pyapp/database/migrations/025_add_pickup_rate_alert_to_merchants.sqlapp/database/queries/breeze_buddy/lead_call_tracker.pyapp/database/queries/breeze_buddy/merchants.pyapp/main.pyapp/schemas/breeze_buddy/merchants.pyapp/services/langfuse/tasks/score_monitor/score.pyapp/services/pickup_rate/__init__.pyapp/services/pickup_rate/calculator.pyapp/services/pickup_rate/config.pyapp/services/pickup_rate/monitor.pyapp/services/pickup_rate/task.py
| async def PICKUP_RATE_ALERT_INTERVAL_SECONDS() -> int: | ||
| """Returns PICKUP_RATE_ALERT_INTERVAL_SECONDS from Redis (default: 86400 = 24 h)""" | ||
| return await get_config("PICKUP_RATE_ALERT_INTERVAL_SECONDS", 86400, int) | ||
|
|
||
|
|
||
| async def PICKUP_RATE_ALERT_THRESHOLD() -> float: | ||
| """Returns PICKUP_RATE_ALERT_THRESHOLD from Redis (default: 40.0 %)""" | ||
| return await get_config("PICKUP_RATE_ALERT_THRESHOLD", 40.0, float) |
There was a problem hiding this comment.
Validate alert interval and threshold ranges before returning them.
These dynamic values directly drive task cadence, dedup TTL, and 0-100 percentage comparisons. A Redis value like 0, -1, or 500 can make alerting spin, fail dedup, never fire, or always fire.
Suggested guardrails
async def PICKUP_RATE_ALERT_INTERVAL_SECONDS() -> int:
"""Returns PICKUP_RATE_ALERT_INTERVAL_SECONDS from Redis (default: 86400 = 24 h)"""
- return await get_config("PICKUP_RATE_ALERT_INTERVAL_SECONDS", 86400, int)
+ interval_seconds = await get_config(
+ "PICKUP_RATE_ALERT_INTERVAL_SECONDS", 86400, int
+ )
+ if interval_seconds <= 0:
+ logger.warning(
+ "Invalid PICKUP_RATE_ALERT_INTERVAL_SECONDS=%s; using default 86400",
+ interval_seconds,
+ )
+ return 86400
+ return interval_seconds
async def PICKUP_RATE_ALERT_THRESHOLD() -> float:
"""Returns PICKUP_RATE_ALERT_THRESHOLD from Redis (default: 40.0 %)"""
- return await get_config("PICKUP_RATE_ALERT_THRESHOLD", 40.0, float)
+ threshold = await get_config("PICKUP_RATE_ALERT_THRESHOLD", 40.0, float)
+ if not 0 <= threshold <= 100:
+ logger.warning(
+ "Invalid PICKUP_RATE_ALERT_THRESHOLD=%s; using default 40.0",
+ threshold,
+ )
+ return 40.0
+ return threshold🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/core/config/dynamic.py` around lines 55 - 62, Validate and clamp/guard
the dynamic Redis values before returning them: in
PICKUP_RATE_ALERT_INTERVAL_SECONDS call get_config as before but enforce a
sensible range (e.g., minimum 60 seconds, maximum 604800 seconds / 7 days) and
if the retrieved value is out of range or non-positive, log a warning and return
the default 86400; in PICKUP_RATE_ALERT_THRESHOLD call get_config but ensure the
value is a float between 0.0 and 100.0 inclusive, and if it's outside that range
or NaN, log a warning and return the default 40.0. Use the existing get_config
helper and reference the functions PICKUP_RATE_ALERT_INTERVAL_SECONDS and
PICKUP_RATE_ALERT_THRESHOLD when adding the validation and logging.
| if pickup_rate_alert_enabled is not None: | ||
| set_clauses.append(f"pickup_rate_alert_enabled = ${param_idx}") | ||
| params.append(pickup_rate_alert_enabled) | ||
| param_idx += 1 | ||
|
|
||
| if pickup_rate_alert_threshold is not None: | ||
| set_clauses.append(f"pickup_rate_alert_threshold = ${param_idx}") | ||
| params.append(pickup_rate_alert_threshold) | ||
| param_idx += 1 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how MerchantUpdate exposes this field and whether the handler can distinguish None from unset.
rg -nP --type=py -C3 'pickup_rate_alert_threshold' -g '!**/migrations/**'Repository: juspay/clairvoyance
Length of output: 15590
🏁 Script executed:
# Check the MerchantUpdate schema and whether model_fields_set is used in handlers
cat -n app/schemas/breeze_buddy/merchants.py | sed -n '45,60p'Repository: juspay/clairvoyance
Length of output: 690
🏁 Script executed:
# Check the handler to see if it uses model_fields_set or any field tracking
grep -n "model_fields_set" app/api/routers/breeze_buddy/merchants/handlers.pyRepository: juspay/clairvoyance
Length of output: 45
🏁 Script executed:
# Verify the accessor docstring and confirm it documents None as "leaves unchanged"
cat -n app/database/accessor/breeze_buddy/merchants.py | sed -n '266,295p'Repository: juspay/clairvoyance
Length of output: 1350
🏁 Script executed:
# Get the exact handler code that calls the update function
cat -n app/api/routers/breeze_buddy/merchants/handlers.py | sed -n '240,255p'Repository: juspay/clairvoyance
Length of output: 803
🏁 Script executed:
# Check if there are any other update paths that might bypass this issue
rg -n "update_merchant" --type=py app/api/routers/ -A5Repository: juspay/clairvoyance
Length of output: 2600
Callers cannot set pickup_rate_alert_threshold to NULL via the update path.
The is not None guard prevents updating the field to NULL (i.e., reverting to global threshold). Since MerchantUpdate defines pickup_rate_alert_threshold: Optional[float] = None, a field omitted from the request is indistinguishable from explicitly passing None. The monitor (check_all_merchants) uses NULL to signal "use global threshold", so this prevents a valid workflow.
The handler passes merchant_data.pickup_rate_alert_threshold directly without checking model_fields_set. To fix, use Pydantic's model_fields_set to distinguish "field omitted" from "field set to null", or introduce a sentinel value (e.g., Unset singleton or a separate clear_threshold: bool flag).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/database/queries/breeze_buddy/merchants.py` around lines 216 - 224, The
update path currently uses "if pickup_rate_alert_threshold is not None" which
prevents callers from explicitly clearing the threshold to NULL; change the
logic to respect Pydantic's Model.model_fields_set instead: when building
set_clauses in the update function (and when the handler reads MerchantUpdate),
check whether "pickup_rate_alert_threshold" is present in
merchant_data.model_fields_set and, if present, include
pickup_rate_alert_threshold in params even if its value is None so the DB
receives NULL; alternatively implement an explicit sentinel or a clear_threshold
flag, but prefer using MerchantUpdate.model_fields_set and adjust any caller
code that currently passes merchant_data.pickup_rate_alert_threshold directly
(and the monitor check_all_merchants behavior remains unchanged).
| pickup_rate_alert_threshold: Optional[float] = Field( | ||
| None, | ||
| description="Alert threshold % for this merchant. " | ||
| "If null, falls back to global PICKUP_RATE_ALERT_THRESHOLD.", | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the merchants.py file to see the current state
cat -n app/schemas/breeze_buddy/merchants.py | head -70Repository: juspay/clairvoyance
Length of output: 2881
🏁 Script executed:
# Search for how pickup_rate_alert_threshold is used in monitor code
rg "pickup_rate_alert_threshold" -A 3 -B 3Repository: juspay/clairvoyance
Length of output: 15748
🏁 Script executed:
# Check if there are any validators or constraints already on this field
rg "class MerchantCreate|class MerchantUpdate" -A 20Repository: juspay/clairvoyance
Length of output: 5282
🏁 Script executed:
# Find the monitor.py file and examine the AlertConfig and threshold usage
cat -n app/services/pickup_rate/monitor.py | grep -A 20 "pickup_rate_alert_threshold"Repository: juspay/clairvoyance
Length of output: 1177
🏁 Script executed:
# Search for AlertConfig definition and how it uses the threshold
rg "class AlertConfig|threshold" app/services/pickup_rate/monitor.py -B 2 -A 5Repository: juspay/clairvoyance
Length of output: 6382
🏁 Script executed:
# Look for global_threshold definition
rg "global_threshold|PICKUP_RATE_ALERT_THRESHOLD" -B 2 -A 2Repository: juspay/clairvoyance
Length of output: 2816
Add validation bounds to pickup_rate_alert_threshold (0-100 percentages).
Without constraints, API clients can store invalid values like -1 or 500. The monitor compares these directly against computed pickup rates (0-100 %), so out-of-range thresholds silently break alerts: negative values disable them, values >100 spam them constantly.
Suggested schema constraints
pickup_rate_alert_threshold: Optional[float] = Field(
None,
+ ge=0.0,
+ le=100.0,
description="Alert threshold % for this merchant. "
"If null, falls back to global PICKUP_RATE_ALERT_THRESHOLD.",
)
@@
- pickup_rate_alert_threshold: Optional[float] = None
+ pickup_rate_alert_threshold: Optional[float] = Field(None, ge=0.0, le=100.0)Also applies to: MerchantUpdate (line 52)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/schemas/breeze_buddy/merchants.py` around lines 35 - 39, The
pickup_rate_alert_threshold field on the Merchant model and the same field on
MerchantUpdate need explicit value bounds to prevent invalid percentages; update
the Field definitions for pickup_rate_alert_threshold (and its counterpart in
MerchantUpdate) to enforce 0 <= value <= 100 (e.g., add ge=0 and le=100 or
equivalent Pydantic validators) and include a short description or error message
indicating the allowed range so out-of-range values are rejected at schema
validation time.
| __all__ = [ | ||
| "PickupRateMonitor", | ||
| "pickup_rate_monitor", | ||
| "initialize_pickup_rate_tasks", | ||
| ] |
There was a problem hiding this comment.
Sort __all__ to satisfy Ruff RUF022.
Lint fix
__all__ = [
"PickupRateMonitor",
+ "initialize_pickup_rate_tasks",
"pickup_rate_monitor",
- "initialize_pickup_rate_tasks",
]📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| __all__ = [ | |
| "PickupRateMonitor", | |
| "pickup_rate_monitor", | |
| "initialize_pickup_rate_tasks", | |
| ] | |
| __all__ = [ | |
| "initialize_pickup_rate_tasks", | |
| "PickupRateMonitor", | |
| "pickup_rate_monitor", | |
| ] |
🧰 Tools
🪛 Ruff (0.15.10)
[warning] 11-15: __all__ is not sorted
Apply an isort-style sorting to __all__
(RUF022)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/services/pickup_rate/__init__.py` around lines 11 - 15, The __all__
export list violates Ruff RUF022 ordering; sort the entries alphabetically so
the list reads e.g. "initialize_pickup_rate_tasks", "pickup_rate_monitor",
"PickupRateMonitor" (or the alphabetic order appropriate for your case) to
satisfy the linter and keep exports consistent; update the __all__ definition in
the module that currently contains "__all__ = [...]" referencing
PickupRateMonitor, pickup_rate_monitor, and initialize_pickup_rate_tasks.
| try: | ||
| # ------------------------------------------------------------------ | ||
| # 1. Call-based metrics | ||
| # ------------------------------------------------------------------ | ||
| call_trackers = await get_all_lead_call_trackers( | ||
| start_date=start_date, | ||
| end_date=end_date, | ||
| merchant_id=merchant_id, | ||
| ) | ||
|
|
||
| calls_attempted = 0 | ||
| calls_no_answer = 0 | ||
|
|
||
| if call_trackers: | ||
| for tracker, _provider in call_trackers: | ||
| if tracker.status and tracker.status.value == "FINISHED": | ||
| calls_attempted += 1 | ||
| if tracker.outcome == "NO_ANSWER": | ||
| calls_no_answer += 1 | ||
|
|
||
| calls_picked = calls_attempted - calls_no_answer | ||
| call_pickup_rate = ( | ||
| calls_picked / calls_attempted * 100 if calls_attempted > 0 else 0.0 | ||
| ) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # 2. Lead-based metrics | ||
| # ------------------------------------------------------------------ | ||
| lead_data = await get_lead_based_analytics( | ||
| start_date=start_date, | ||
| end_date=end_date, | ||
| merchant_id=merchant_id, | ||
| ) | ||
|
|
||
| total_leads = len(lead_data) if lead_data else 0 | ||
| leads_picked = 0 | ||
|
|
||
| if lead_data: | ||
| for lead in lead_data: | ||
| # A lead is "picked" when more calls were finished than went unanswered | ||
| if lead["finished_calls"] > lead["no_answer_calls"]: | ||
| leads_picked += 1 | ||
|
|
||
| lead_pickup_rate = leads_picked / total_leads * 100 if total_leads > 0 else 0.0 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Gauge typical row volume that get_all_lead_call_trackers would pull per 24h window
# and confirm no other caller passes unbounded args with large windows.
rg -nP --type=py -C2 'get_all_lead_call_trackers\s*\('Repository: juspay/clairvoyance
Length of output: 1402
🏁 Script executed:
# Get the full signature and implementation of get_all_lead_call_trackers
cat -n app/database/accessor/breeze_buddy/lead_call_tracker.py | sed -n '421,460p'Repository: juspay/clairvoyance
Length of output: 1718
🏁 Script executed:
# Get the LeadCallTracker model to check if status is optional/required
fd -t f -i leadcalltracker app/ | head -20Repository: juspay/clairvoyance
Length of output: 45
🏁 Script executed:
# Look for LeadCallTracker definition or imports
rg -n "class LeadCallTracker" app/ -A 15Repository: juspay/clairvoyance
Length of output: 1190
🏁 Script executed:
# Check get_lead_based_analytics to see how it aggregates
rg -n "get_lead_based_analytics" app/ -B 2 -A 20 | head -80Repository: juspay/clairvoyance
Length of output: 6342
🏁 Script executed:
# Look at the full context of both callers with their window sizes
cat -n app/services/pickup_rate/calculator.py | sed -n '40,70p'Repository: juspay/clairvoyance
Length of output: 1584
🏁 Script executed:
# Get the query builder for get_all_lead_call_trackers
rg -n "def get_all_lead_call_trackers_query" app/ -A 40 | head -60Repository: juspay/clairvoyance
Length of output: 3750
🏁 Script executed:
# Check if get_lead_based_analytics applies pagination
rg -n "def get_lead_based_analytics" app/ -A 20Repository: juspay/clairvoyance
Length of output: 7677
🏁 Script executed:
# Look at the score_monitor caller to confirm same unbounded pattern
cat -n app/services/langfuse/tasks/score_monitor/score.py | sed -n '420,430p'Repository: juspay/clairvoyance
Length of output: 544
🏁 Script executed:
# Get the complete query builder implementation
cat -n app/database/queries/breeze_buddy/lead_call_tracker.py | sed -n '333,420p'Repository: juspay/clairvoyance
Length of output: 3285
🏁 Script executed:
# Also check if there's a default page_size applied in the accessor function
cat -n app/database/accessor/breeze_buddy/lead_call_tracker.py | sed -n '421,465p'Repository: juspay/clairvoyance
Length of output: 1884
Unbounded row fetch without pagination on periodic tasks.
get_all_lead_call_trackers() is called without page/page_size arguments (lines 55 and in score_monitor/score.py), which leaves limit=None. The query builder only applies LIMIT when limit is not None (lines 387-393 of the query builder), so these calls fetch every matching row for the entire window into memory. For 24h windows this risks significant memory/latency impact, especially on the global scope task.
Additionally, the counts are then tallied in Python (calls_attempted, calls_no_answer) despite the database being capable of aggregating these values in a single query — unlike get_lead_based_analytics, which returns pre-aggregated per-lead data.
Recommendation: Add a dedicated SQL aggregate (e.g. get_call_pickup_counts_query() returning attempted and no_answer as a single row) to avoid materializing all rows, and align the call-based and lead-based metric calculations.
Minor: Line 66's if tracker.status and ... check is redundant — status is a required (non-optional) field on LeadCallTracker with a default value.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/services/pickup_rate/calculator.py` around lines 51 - 94, The code pulls
all rows via get_all_lead_call_trackers(...) (and similar call in
score_monitor/score.py) without pagination and then tallies calls in Python,
causing unbounded memory usage; replace this with a single DB-side aggregation
by adding a new helper (e.g.
get_call_pickup_counts_query()/get_call_pickup_counts) that returns attempted
and no_answer counts as one row and call that from calculator.py instead of
get_all_lead_call_trackers, or if you must iterate keep using paginated calls
(page/page_size) to avoid limit=None; also remove the redundant "if
tracker.status and" check since status is non-optional. Ensure the new function
is used to compute call_pickup_rate (calls_picked = attempted - no_answer) and
update score_monitor/score.py call sites accordingly.
| config = AlertConfig( | ||
| enabled=True, | ||
| interval_seconds=interval_seconds, | ||
| threshold_percent=threshold, | ||
| alert_type="BOTH", | ||
| scope=f"merchant:{merchant.merchant_id}", | ||
| merchant_id=merchant.merchant_id, | ||
| reseller_id=merchant.reseller_id, | ||
| ) | ||
| await self._run_alert_for_config(config) |
There was a problem hiding this comment.
Don’t pass merchant ownership reseller_id into rate metrics.
Line 92 forwards merchant.reseller_id, which is the user/reseller owner of the merchant, into compute_pickup_rates. Per-merchant pickup-rate scoping should use merchant_id; adding this owner ID can over-constrain the lead-call query and make reseller-owned merchants look like they have no data.
Scope by merchant only
config = AlertConfig(
enabled=True,
interval_seconds=interval_seconds,
threshold_percent=threshold,
alert_type="BOTH",
scope=f"merchant:{merchant.merchant_id}",
merchant_id=merchant.merchant_id,
- reseller_id=merchant.reseller_id,
)Based on learnings, lead_call_tracker rows were backfilled with reseller_id = merchant_id and merchant_identifier = shop_identifier, so the merchant table owner reseller_id should not be used for per-merchant metric scoping.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/services/pickup_rate/monitor.py` around lines 85 - 94, The AlertConfig is
incorrectly including merchant.reseller_id which over-constrains per-merchant
metrics; remove or null out the reseller_id when creating merchant-scoped
configs in the block that constructs AlertConfig (the code that sets enabled,
interval_seconds, threshold_percent, alert_type, scope, merchant_id,
reseller_id) so the config only scopes by merchant_id (e.g., omit reseller_id or
set reseller_id=None) before calling self._run_alert_for_config(config); this
ensures downstream functions like compute_pickup_rates / lead-call queries are
not filtered by the merchant owner.
| ) | ||
| return | ||
|
|
||
| # Skip when there is no data – avoids false alerts on idle systems |
There was a problem hiding this comment.
Replace ambiguous en dashes flagged by Ruff.
Use plain - in these comments/log strings to satisfy RUF001/RUF003 and avoid lint failures.
Also applies to: 165-165, 272-272, 299-299, 316-327
🧰 Tools
🪛 Ruff (0.15.10)
[warning] 148-148: Comment contains ambiguous – (EN DASH). Did you mean - (HYPHEN-MINUS)?
(RUF003)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/services/pickup_rate/monitor.py` at line 148, The comment/log strings in
monitor.py use an en dash (–) which triggers RUF001/RUF003; locate occurrences
such as the comment "Skip when there is no data – avoids false alerts on idle
systems" and any other strings containing "–" and replace the en dash with a
plain hyphen "-" (for example in the other flagged comments/log lines that
contain "–"); ensure you update both comments and any logged messages so the
code no longer contains the en dash character.
| from app.services.pickup_rate.monitor import pickup_rate_monitor | ||
|
|
||
|
|
||
| async def initialize_pickup_rate_tasks(scheduler) -> bool: |
There was a problem hiding this comment.
Annotate the scheduler parameter.
Type hint fix
+from typing import TYPE_CHECKING
+
from app.core.config.dynamic import (
ENABLE_PICKUP_RATE_ALERT,
PICKUP_RATE_ALERT_INTERVAL_SECONDS,
)
@@
from app.core.logger import logger
from app.services.pickup_rate.monitor import pickup_rate_monitor
+
+if TYPE_CHECKING:
+ from app.core.background_tasks.scheduler import BackgroundTaskScheduler
-async def initialize_pickup_rate_tasks(scheduler) -> bool:
+async def initialize_pickup_rate_tasks(scheduler: "BackgroundTaskScheduler") -> bool:As per coding guidelines, “Use type hints on all function signatures (Optional[T], List[T], Dict[str, Any], Union)”.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/services/pickup_rate/task.py` at line 21, The function
initialize_pickup_rate_tasks lacks a type hint for its scheduler parameter;
update its signature to include an explicit type (e.g., def
initialize_pickup_rate_tasks(scheduler: Any) -> bool or, if you use APScheduler,
scheduler: AsyncIOScheduler) and add the corresponding import (from typing
import Any or from apscheduler.schedulers.asyncio import AsyncIOScheduler) so
the scheduler parameter is annotated while keeping the function name
initialize_pickup_rate_tasks unchanged.
| 1. pickup_rate_monitor – global alert, gated by ENABLE_PICKUP_RATE_ALERT | ||
| 2. pickup_rate_monitor_merchants – per-merchant alerts, always registered when |
There was a problem hiding this comment.
Replace ambiguous en dashes flagged by Ruff.
Use plain - in the docstring/log strings to satisfy RUF001/RUF002 and avoid lint failures.
Also applies to: 39-39, 64-64
🧰 Tools
🪛 Ruff (0.15.10)
[warning] 26-26: Docstring contains ambiguous – (EN DASH). Did you mean - (HYPHEN-MINUS)?
(RUF002)
[warning] 27-27: Docstring contains ambiguous – (EN DASH). Did you mean - (HYPHEN-MINUS)?
(RUF002)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/services/pickup_rate/task.py` around lines 26 - 27, Docstrings and log
strings contain en dashes (–) which RUF001/RUF002 flags; replace them with plain
ASCII hyphens (-). Update the occurrences in the module-level docstring and any
log or message strings referenced for the pickup_rate_monitor and
pickup_rate_monitor_merchants symbols (and other strings around the same
locations) by swapping the U+2013 character for a hyphen-minus so lint no longer
errors.
166b905 to
d902ae3
Compare
| if merchant_id: | ||
| values.append(merchant_id) | ||
| conditions.append(f'"merchant_id" = ${len(values)}') | ||
|
|
||
| where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" |
There was a problem hiding this comment.
In get_lead_based_analytics_query, the outcome aggregates (e.g. no_answer_calls) are currently counted without restricting to status = 'FINISHED', while finished_calls is restricted. This makes the downstream lead pickup evaluation (finished_calls > no_answer_calls) inconsistent with the ScoreMonitor logic (which only counts outcomes for FINISHED calls) and can misreport pickup rates if non-finished rows have outcomes set. Consider adding status = 'FINISHED' to the FILTER conditions for outcome counts (at least NO_ANSWER, ideally all outcomes) so they’re comparable to finished_calls.
| pickup_rate_alert_threshold: Optional[float] = Field( | ||
| None, | ||
| description="Alert threshold % for this merchant. " | ||
| "If null, falls back to global PICKUP_RATE_ALERT_THRESHOLD.", | ||
| ) |
There was a problem hiding this comment.
pickup_rate_alert_threshold accepts any float from the API and is later used directly for alert comparisons. To avoid misconfiguration (e.g. negative values or >100), add Pydantic validation/clamping (and ideally a DB CHECK constraint) to keep the threshold within [0, 100]. This also keeps per-merchant behavior consistent with the global PICKUP_RATE_ALERT_THRESHOLD dynamic config which is already clamped.
| else global_threshold | ||
| ) | ||
| config = AlertConfig( | ||
| enabled=True, |
There was a problem hiding this comment.
Bug: Per-merchant lookback always defaults to 24h, ignoring interval
AlertConfig has lookback_hours: int = 24 as default. This block creates per-merchant configs without setting lookback_hours, so every merchant gets a 24h lookback regardless of PICKUP_RATE_ALERT_INTERVAL_SECONDS.
The global path (in load_config()) correctly sets lookback_hours=max(1, interval_seconds // 3600). Per-merchant does not — inconsistency is unintentional.
Concrete: if PICKUP_RATE_ALERT_INTERVAL_SECONDS=3600 (1h):
- Dedup TTL = 1h (correct)
- Per-merchant lookback = 24h (wrong — queries 24h of data for a 1h check cycle)
- A merchant who had bad pickup rate 12h ago but recovered still gets alerts for 24h
Fix: add lookback_hours=max(1, interval_seconds // 3600) to the AlertConfig constructor here, matching the global path behavior.
There was a problem hiding this comment.
PICKUP_RATE_ALERT_INTERVAL_SECOND is for sending alerts interval
If we set it to 12 hours then also it will look for the last 24 hours data pickup rate, that's what lookback_hours are for !
- Add migration 025 to extend merchants table with pickup_rate_alert_enabled and pickup_rate_alert_threshold columns - Add pickup_rate_alert_enabled/threshold fields to MerchantCreate, MerchantUpdate and MerchantResponse schemas - Update merchants query/accessor/decoder to carry new alert config fields - Wire merchant_id filter through lead_call_tracker query and accessor layers to scope pickup rate calculations per merchant - Refactor PickupRateMonitor.check_and_alert() to delegate shared alert logic to _run_alert_for_config() for reuse across global and per-merchant paths - Add PickupRateMonitor.check_all_merchants() to loop all enabled merchants with isolated per-merchant error handling and scoped Redis dedup keys - Register pickup_rate_monitor_merchants background task alongside existing global task; global alerting unchanged and runs in parallel
d902ae3 to
351f824
Compare
Add migration 025 to extend merchants table with pickup_rate_alert_enabled
and pickup_rate_alert_threshold columns
Add pickup_rate_alert_enabled/threshold fields to MerchantCreate,
MerchantUpdate and MerchantResponse schemas
Update merchants query/accessor/decoder to carry new alert config fields
Wire merchant_id filter through lead_call_tracker query and accessor layers
to scope pickup rate calculations per merchant
Refactor PickupRateMonitor.check_and_alert() to delegate shared alert logic
to _run_alert_for_config() for reuse across global and per-merchant paths
Add PickupRateMonitor.check_all_merchants() to loop all enabled merchants
with isolated per-merchant error handling and scoped Redis dedup keys
Register pickup_rate_monitor_merchants background task alongside existing
global task; global alerting unchanged and runs in parallel
DEVPROOF -