feat(alerts): address review hardening for multi-source road alerts (#31)
This commit is contained in:
parent
bcae4881d6
commit
625999245e
|
|
@ -13,12 +13,35 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class Georgia511Adapter:
|
class Georgia511Adapter:
|
||||||
"""
|
"""
|
||||||
Adapter for Georgia 511 (GDOT) official traffic events and road closure feeds.
|
Experimental Adapter for Georgia 511 (GDOT) official traffic and incident feeds.
|
||||||
Strictly rate-limited (10 calls/min) with circuit breaking and zero unauthorized calls.
|
|
||||||
|
=== Upstream Endpoint & Architecture ===
|
||||||
|
Base Endpoint: https://511ga.org/api/getEvents
|
||||||
|
Alternative Feeds: GDOT Navigator XML / GeoJSON open feeds.
|
||||||
|
|
||||||
|
=== Access Requirements ===
|
||||||
|
- API Key: Requires registration via the Georgia DOT 511 Developer Portal
|
||||||
|
or formal open data licensing agreement from GDOT.
|
||||||
|
- Configuration: Configured via environment variable `GA511_API_KEY`.
|
||||||
|
- Live Verification Status: UNVERIFIED. Live production access has not yet
|
||||||
|
been provisioned with GDOT; integration operates in EXPERIMENTAL mode.
|
||||||
|
- Safe Default: Disabled by default (`GA511_ENABLED=false`). When enabled
|
||||||
|
without an API key, fixture/mock mode is used to prevent unauthorized calls.
|
||||||
|
|
||||||
|
=== Response Parsing Specification ===
|
||||||
|
Accepts GDOT Event objects:
|
||||||
|
- `id` / `eventId`: Unique state event identifier.
|
||||||
|
- `geometry` or `latitude`/`longitude`: WGS84 point coordinates.
|
||||||
|
- `type` / `event_type`: Categorized into accident, roadClosure, or hazard.
|
||||||
|
- `headline` / `description`: Roadway incident description.
|
||||||
|
- `roadwayName`: Canonical highway name (e.g. "I-75 N", "I-285 W").
|
||||||
|
|
||||||
|
Rate limit: Strictly throttled with geographic cell caching (0.08°) and
|
||||||
|
circuit breaking (3 failures = 5-minute trip).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROVIDER_ID = "ga511_official"
|
PROVIDER_ID = "ga511_official"
|
||||||
PROVIDER_NAME = "Georgia 511"
|
PROVIDER_NAME = "Georgia 511 (Experimental)"
|
||||||
BASE_URL = "https://511ga.org/api/getEvents"
|
BASE_URL = "https://511ga.org/api/getEvents"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from threading import Lock
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||||
|
|
||||||
from road_alerts.ga511_adapter import Georgia511Adapter
|
from road_alerts.ga511_adapter import Georgia511Adapter
|
||||||
from road_alerts.models import (
|
from road_alerts.models import (
|
||||||
|
|
@ -19,21 +23,69 @@ router = APIRouter()
|
||||||
zyla_adapter = ZylaWazeAdapter()
|
zyla_adapter = ZylaWazeAdapter()
|
||||||
ga511_adapter = Georgia511Adapter()
|
ga511_adapter = Georgia511Adapter()
|
||||||
|
|
||||||
|
# Geographic boundary constraint: Maximum allowable span in degrees (approx 111 km x 111 km)
|
||||||
|
MAX_GEOGRAPHIC_SPAN_DEGREES = 1.0
|
||||||
|
|
||||||
@router.get("/feed", response_model=AlertFeedResponse)
|
|
||||||
def get_aggregated_feed(
|
class RateLimiter:
|
||||||
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
|
"""
|
||||||
min_lng: float = Query(..., ge=-180.0, le=180.0, description="Minimum longitude (West)"),
|
Sliding window in-memory rate limiter for backend gateway endpoints.
|
||||||
max_lat: float = Query(..., ge=-90.0, le=90.0, description="Maximum latitude (North)"),
|
Protects upstream budgets and prevents abuse.
|
||||||
max_lng: float = Query(..., ge=-180.0, le=180.0, description="Maximum longitude (East)"),
|
"""
|
||||||
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
|
|
||||||
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
|
def __init__(self, max_requests: int = 60, window_seconds: float = 60.0):
|
||||||
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
|
self.max_requests = max_requests
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self.timestamps = deque()
|
||||||
|
self.lock = Lock()
|
||||||
|
|
||||||
|
def check_and_record(self) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
with self.lock:
|
||||||
|
cutoff = now - self.window_seconds
|
||||||
|
while self.timestamps and self.timestamps[0] < cutoff:
|
||||||
|
self.timestamps.popleft()
|
||||||
|
if len(self.timestamps) >= self.max_requests:
|
||||||
|
return False
|
||||||
|
self.timestamps.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# 60 requests per minute gateway rate limit
|
||||||
|
gateway_rate_limiter = RateLimiter(max_requests=60, window_seconds=60.0)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_rate_limit():
|
||||||
|
if not gateway_rate_limiter.check_and_record():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Maximum 60 requests per minute allowed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_gateway_auth(
|
||||||
|
x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"),
|
||||||
):
|
):
|
||||||
"""
|
required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET")
|
||||||
Retrieves multi-source road alerts within the specified geographic bounding box.
|
if required_secret and x_gateway_key != required_secret:
|
||||||
Returns normalized alerts with provider attribution and confidence scores.
|
raise HTTPException(
|
||||||
"""
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or missing MotorSignal Gateway key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin_auth(
|
||||||
|
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
||||||
|
):
|
||||||
|
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
|
||||||
|
if required_admin and x_admin_key != required_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Unauthorized access to Ops switch controls",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bounding_box(min_lat: float, min_lng: float, max_lat: float, max_lng: float):
|
||||||
if min_lat > max_lat:
|
if min_lat > max_lat:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|
@ -44,6 +96,38 @@ def get_aggregated_feed(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="min_lng cannot be greater than max_lng",
|
detail="min_lng cannot be greater than max_lng",
|
||||||
)
|
)
|
||||||
|
lat_span = max_lat - min_lat
|
||||||
|
lng_span = max_lng - min_lng
|
||||||
|
if lat_span > MAX_GEOGRAPHIC_SPAN_DEGREES or lng_span > MAX_GEOGRAPHIC_SPAN_DEGREES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"Bounding box exceeds maximum allowed geographic span of "
|
||||||
|
f"{MAX_GEOGRAPHIC_SPAN_DEGREES} degrees (~111 km). "
|
||||||
|
f"Requested lat span: {lat_span:.3f}, lng span: {lng_span:.3f}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/feed",
|
||||||
|
response_model=AlertFeedResponse,
|
||||||
|
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
|
||||||
|
)
|
||||||
|
def get_aggregated_feed(
|
||||||
|
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
|
||||||
|
min_lng: float = Query(..., ge=-180.0, le=180.0, description="Minimum longitude (West)"),
|
||||||
|
max_lat: float = Query(..., ge=-90.0, le=90.0, description="Maximum latitude (North)"),
|
||||||
|
max_lng: float = Query(..., ge=-180.0, le=180.0, description="Maximum longitude (East)"),
|
||||||
|
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
|
||||||
|
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
|
||||||
|
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieves multi-source road alerts within the bounded geographic box.
|
||||||
|
Enforces geographic bounds (<= 1.0 degree) and rate limits.
|
||||||
|
"""
|
||||||
|
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||||
|
|
||||||
all_alerts: list[NormalizedAlert] = []
|
all_alerts: list[NormalizedAlert] = []
|
||||||
providers_polled: list[str] = []
|
providers_polled: list[str] = []
|
||||||
|
|
@ -69,9 +153,6 @@ def get_aggregated_feed(
|
||||||
all_alerts.extend(ga_alerts)
|
all_alerts.extend(ga_alerts)
|
||||||
providers_polled.append(ga511_adapter.PROVIDER_ID)
|
providers_polled.append(ga511_adapter.PROVIDER_ID)
|
||||||
|
|
||||||
# Cross-source deduplication: if two alerts from different sources are in
|
|
||||||
# the exact same category and within close proximity (< 150m), keep both
|
|
||||||
# or attribute distinctly without corrupting native alerts.
|
|
||||||
return AlertFeedResponse(
|
return AlertFeedResponse(
|
||||||
success=True,
|
success=True,
|
||||||
count=len(all_alerts),
|
count=len(all_alerts),
|
||||||
|
|
@ -81,7 +162,11 @@ def get_aggregated_feed(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/zyla", response_model=AlertFeedResponse)
|
@router.get(
|
||||||
|
"/zyla",
|
||||||
|
response_model=AlertFeedResponse,
|
||||||
|
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
|
||||||
|
)
|
||||||
def get_zyla_alerts(
|
def get_zyla_alerts(
|
||||||
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||||
min_lng: float = Query(..., ge=-180.0, le=180.0),
|
min_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||||
|
|
@ -91,7 +176,10 @@ def get_zyla_alerts(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Direct endpoint for Zyla Labs Waze Data API #4814.
|
Direct endpoint for Zyla Labs Waze Data API #4814.
|
||||||
|
Enforces geographic box bounds and server-side budget limits.
|
||||||
"""
|
"""
|
||||||
|
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||||
|
|
||||||
alerts = zyla_adapter.fetch_alerts(
|
alerts = zyla_adapter.fetch_alerts(
|
||||||
min_lat=min_lat,
|
min_lat=min_lat,
|
||||||
min_lng=min_lng,
|
min_lng=min_lng,
|
||||||
|
|
@ -108,7 +196,11 @@ def get_zyla_alerts(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ga511", response_model=AlertFeedResponse)
|
@router.get(
|
||||||
|
"/ga511",
|
||||||
|
response_model=AlertFeedResponse,
|
||||||
|
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
|
||||||
|
)
|
||||||
def get_ga511_alerts(
|
def get_ga511_alerts(
|
||||||
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||||
min_lng: float = Query(..., ge=-180.0, le=180.0),
|
min_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||||
|
|
@ -117,7 +209,10 @@ def get_ga511_alerts(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Direct endpoint for official Georgia 511 incident feeds.
|
Direct endpoint for official Georgia 511 incident feeds.
|
||||||
|
Enforces geographic box bounds.
|
||||||
"""
|
"""
|
||||||
|
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||||
|
|
||||||
alerts = ga511_adapter.fetch_alerts(
|
alerts = ga511_adapter.fetch_alerts(
|
||||||
min_lat=min_lat,
|
min_lat=min_lat,
|
||||||
min_lng=min_lng,
|
min_lng=min_lng,
|
||||||
|
|
@ -155,10 +250,11 @@ def get_ops_health():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ops/switches")
|
@router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)])
|
||||||
def update_provider_switch(payload: ProviderSwitchRequest):
|
def update_provider_switch(payload: ProviderSwitchRequest):
|
||||||
"""
|
"""
|
||||||
Runtime enable/disable switch and circuit control for external providers.
|
Runtime enable/disable switch and circuit control for external providers.
|
||||||
|
Requires admin authorization if configured.
|
||||||
"""
|
"""
|
||||||
if payload.provider_id == zyla_adapter.PROVIDER_ID:
|
if payload.provider_id == zyla_adapter.PROVIDER_ID:
|
||||||
zyla_adapter.enabled = payload.enabled
|
zyla_adapter.enabled = payload.enabled
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,30 @@ from road_alerts.models import NormalizedAlert, ProviderHealth
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Category TTLs in minutes
|
# Category and Subtype-aware Freshness & TTL Policies (in minutes)
|
||||||
|
# Mobile speed traps and traffic jams dissipate quickly;
|
||||||
|
# infrastructure repairs, potholes, and full road closures persist across days/shifts.
|
||||||
CATEGORY_TTL_MINUTES = {
|
CATEGORY_TTL_MINUTES = {
|
||||||
"police": 60,
|
"police": 45, # Mobile police presence changes within 30-45m
|
||||||
"hazard": 120,
|
"traffic": 30, # Congestion patterns shift quickly
|
||||||
"debris": 120,
|
"debris": 60, # Debris/objects on road cleared or moved
|
||||||
"pothole": 1440,
|
"hazard": 90, # General roadway hazards
|
||||||
"weatherHazard": 120,
|
"accident": 120, # Average traffic accident duration
|
||||||
"accident": 180,
|
"weatherHazard": 120, # Flooding, ice, or dense fog
|
||||||
"roadClosure": 1440,
|
"pothole": 1440, # Physical pavement damage persists (24 hours)
|
||||||
"traffic": 45,
|
"construction": 1440, # Planned work zone shifts (24 hours)
|
||||||
|
"roadClosure": 1440, # Roadway closed for civil works or major events (24 hours)
|
||||||
"other": 60,
|
"other": 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SUBTYPE_TTL_MINUTES = {
|
||||||
|
"police_speed_trap": 30, # Stationary radar/laser traps move frequently
|
||||||
|
"police_visible": 45,
|
||||||
|
"accident_minor": 60, # Minor incidents clear faster
|
||||||
|
"accident_major": 180, # Multi-lane highway closures take longer
|
||||||
|
"construction": 1440,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ZylaWazeAdapter:
|
class ZylaWazeAdapter:
|
||||||
"""
|
"""
|
||||||
|
|
@ -169,7 +180,7 @@ class ZylaWazeAdapter:
|
||||||
|
|
||||||
if "POLICE" in raw_type or "POLICE" in raw_subtype:
|
if "POLICE" in raw_type or "POLICE" in raw_subtype:
|
||||||
category = "police"
|
category = "police"
|
||||||
if "HIDDEN" in raw_subtype:
|
if "HIDDEN" in raw_subtype or "HIDING" in raw_subtype:
|
||||||
subtype = "police_speed_trap"
|
subtype = "police_speed_trap"
|
||||||
elif "VISIBLE" in raw_subtype:
|
elif "VISIBLE" in raw_subtype:
|
||||||
subtype = "police_visible"
|
subtype = "police_visible"
|
||||||
|
|
@ -197,8 +208,11 @@ class ZylaWazeAdapter:
|
||||||
category = "hazard"
|
category = "hazard"
|
||||||
subtype = "hazard_on_road"
|
subtype = "hazard_on_road"
|
||||||
|
|
||||||
# 4. Freshness and TTL calculation
|
# 4. Freshness and TTL calculation based on category & subtype
|
||||||
ttl_minutes = CATEGORY_TTL_MINUTES.get(category, 60)
|
ttl_minutes = (
|
||||||
|
SUBTYPE_TTL_MINUTES.get(subtype)
|
||||||
|
or CATEGORY_TTL_MINUTES.get(category, 60)
|
||||||
|
)
|
||||||
expires_at = reported_at + timedelta(minutes=ttl_minutes)
|
expires_at = reported_at + timedelta(minutes=ttl_minutes)
|
||||||
is_stale = now > expires_at
|
is_stale = now > expires_at
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,39 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
self.assertEqual(pothole.subtype, "pothole")
|
self.assertEqual(pothole.subtype, "pothole")
|
||||||
self.assertEqual(pothole.road_name, "Peachtree Rd NE")
|
self.assertEqual(pothole.road_name, "Peachtree Rd NE")
|
||||||
|
|
||||||
|
def test_granular_freshness_policies(self):
|
||||||
|
"""
|
||||||
|
Verifies that freshness policies enforce category/subtype TTLs:
|
||||||
|
- Police speed trap (30m) expires after 35 minutes.
|
||||||
|
- Road closure (24h) remains active after 4 hours.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
thirty_five_mins_ago = int((now.timestamp() - 35 * 60) * 1000)
|
||||||
|
four_hours_ago = int((now.timestamp() - 4 * 3600) * 1000)
|
||||||
|
|
||||||
|
# 1. Police speed trap reported 35 mins ago -> expired
|
||||||
|
raw_police = {
|
||||||
|
"uuid": "trap_test_1",
|
||||||
|
"type": "POLICE",
|
||||||
|
"subtype": "POLICE_HIDING",
|
||||||
|
"location": {"x": -85.17, "y": 34.25},
|
||||||
|
"pubMillis": thirty_five_mins_ago,
|
||||||
|
}
|
||||||
|
res_police = self.adapter.normalize_alert(raw_police, now)
|
||||||
|
self.assertIsNone(res_police)
|
||||||
|
|
||||||
|
# 2. Road closure reported 4 hours ago -> still valid (24h TTL)
|
||||||
|
raw_closure = {
|
||||||
|
"uuid": "closure_test_1",
|
||||||
|
"type": "ROAD_CLOSED",
|
||||||
|
"subtype": "ROAD_CLOSED_CONSTRUCTION",
|
||||||
|
"location": {"x": -85.17, "y": 34.25},
|
||||||
|
"pubMillis": four_hours_ago,
|
||||||
|
}
|
||||||
|
res_closure = self.adapter.normalize_alert(raw_closure, now)
|
||||||
|
self.assertIsNotNone(res_closure)
|
||||||
|
self.assertEqual(res_closure.category, "roadClosure")
|
||||||
|
|
||||||
def test_zyla_disabled_by_policy_never_requests(self):
|
def test_zyla_disabled_by_policy_never_requests(self):
|
||||||
"""
|
"""
|
||||||
Ensures that when Zyla is disabled or API key is absent:
|
Ensures that when Zyla is disabled or API key is absent:
|
||||||
|
|
@ -189,10 +222,31 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
|
||||||
self.assertGreaterEqual(len(alerts), 1)
|
self.assertGreaterEqual(len(alerts), 1)
|
||||||
|
|
||||||
first = alerts[0]
|
first = alerts[0]
|
||||||
self.assertEqual(first.source, "Georgia 511")
|
self.assertIn("Georgia 511", first.source)
|
||||||
self.assertEqual(first.source_type, "official_dot")
|
self.assertEqual(first.source_type, "official_dot")
|
||||||
self.assertAlmostEqual(first.confidence, 0.95)
|
self.assertAlmostEqual(first.confidence, 0.95)
|
||||||
|
|
||||||
|
def test_bounding_box_limit_enforcement(self):
|
||||||
|
"""
|
||||||
|
Verifies that geographic queries exceeding 1.0 degree span are rejected.
|
||||||
|
"""
|
||||||
|
from road_alerts.router import validate_bounding_box
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
# Valid box within 1.0 degree span
|
||||||
|
validate_bounding_box(34.2, -85.2, 34.8, -84.8)
|
||||||
|
|
||||||
|
# Inverted coordinates
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
validate_bounding_box(35.0, -85.0, 34.0, -84.0)
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
|
||||||
|
# Overly broad box exceeding 1.0 degree
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
validate_bounding_box(30.0, -90.0, 35.0, -80.0)
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
self.assertIn("maximum allowed geographic span", ctx.exception.detail)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -929,6 +929,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
subtitle:
|
subtitle:
|
||||||
'Real-time crowdsourced police traps, road debris, and hazard alerts from nearby riders and pack members across the United States.',
|
'Real-time crowdsourced police traps, road debris, and hazard alerts from nearby riders and pack members across the United States.',
|
||||||
status: swarmStatus,
|
status: swarmStatus,
|
||||||
|
isEnabled: swarmEnabled,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
|
|
@ -999,6 +1000,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
subtitle:
|
subtitle:
|
||||||
'Offline speed cameras, radar traps & photo enforcement across the United States. 100% operational in mountain passes & cellular dead zones.',
|
'Offline speed cameras, radar traps & photo enforcement across the United States. 100% operational in mountain passes & cellular dead zones.',
|
||||||
status: osmStatus,
|
status: osmStatus,
|
||||||
|
isEnabled: usCamerasEnabled,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
|
|
@ -1050,6 +1052,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
subtitle:
|
subtitle:
|
||||||
'Commercial licensed Waze Data feed via MotorSignal API backend. Disabled by default; client never holds credentials.',
|
'Commercial licensed Waze Data feed via MotorSignal API backend. Disabled by default; client never holds credentials.',
|
||||||
status: zylaStatus,
|
status: zylaStatus,
|
||||||
|
isEnabled: zylaEnabled,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
|
|
@ -1163,10 +1166,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
_buildProviderHeader(
|
_buildProviderHeader(
|
||||||
icon: Icons.account_balance,
|
icon: Icons.account_balance,
|
||||||
iconColor: Colors.deepOrangeAccent,
|
iconColor: Colors.deepOrangeAccent,
|
||||||
title: 'Georgia 511 (Official State Feed)',
|
title: 'Georgia 511 (Experimental Official Feed)',
|
||||||
subtitle:
|
subtitle:
|
||||||
'Official GDOT traffic, incident, construction, and closure open data via MotorSignal backend gateway.',
|
'Experimental GDOT traffic, incident, construction, and closure open data via MotorSignal backend gateway. Live GDOT production credentials unverified.',
|
||||||
status: ga511Status,
|
status: ga511Status,
|
||||||
|
isEnabled: ga511Enabled,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
|
|
@ -1203,7 +1207,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Official public agency data feed. High trust, zero third-party subscription costs.',
|
'Experimental integration. Live GDOT production credentials unverified; uses bounded caching and verified incident datasets.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -1284,6 +1288,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
subtitle:
|
subtitle:
|
||||||
'Optional community hazard & traffic feed inspired by ArgeliusLabs/waze_osint. Disabled by default; zero paid subscriptions.',
|
'Optional community hazard & traffic feed inspired by ArgeliusLabs/waze_osint. Disabled by default; zero paid subscriptions.',
|
||||||
status: wazeOsintStatus,
|
status: wazeOsintStatus,
|
||||||
|
isEnabled: wazeOsintEnabled,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
|
|
@ -1397,6 +1402,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
subtitle:
|
subtitle:
|
||||||
'Commercial telematics feed for verified mobile speed traps, stationary radar, and red light cameras.',
|
'Commercial telematics feed for verified mobile speed traps, stationary radar, and red light cameras.',
|
||||||
status: hereStatus,
|
status: hereStatus,
|
||||||
|
isEnabled: hereEnabled,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
|
|
@ -1499,6 +1505,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
Widget _buildCommunityProviderCard(BuildContext context) {
|
Widget _buildCommunityProviderCard(BuildContext context) {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
final communityStatus = ref.watch(communityStatusProvider);
|
final communityStatus = ref.watch(communityStatusProvider);
|
||||||
|
final communityKey = ref.watch(communityApiKeyProvider);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
|
|
@ -1517,6 +1524,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
title: 'Community Traffic Provider',
|
title: 'Community Traffic Provider',
|
||||||
subtitle: 'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
subtitle: 'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
||||||
status: communityStatus,
|
status: communityStatus,
|
||||||
|
isEnabled: communityKey.trim().isNotEmpty,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
|
|
@ -1622,6 +1630,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
Widget _buildTomTomProviderCard(BuildContext context) {
|
Widget _buildTomTomProviderCard(BuildContext context) {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
final tomtomStatus = ref.watch(tomtomStatusProvider);
|
final tomtomStatus = ref.watch(tomtomStatusProvider);
|
||||||
|
final tomtomKey = ref.watch(tomtomApiKeyProvider);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
|
|
@ -1641,6 +1650,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
subtitle:
|
subtitle:
|
||||||
'Supplies Accidents, Hazards, Closures & Construction data.',
|
'Supplies Accidents, Hazards, Closures & Construction data.',
|
||||||
status: tomtomStatus,
|
status: tomtomStatus,
|
||||||
|
isEnabled: tomtomKey.trim().isNotEmpty,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
|
|
@ -2250,6 +2260,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
required String title,
|
required String title,
|
||||||
required String subtitle,
|
required String subtitle,
|
||||||
required RoadAlertProviderStatus status,
|
required RoadAlertProviderStatus status,
|
||||||
|
bool isEnabled = true,
|
||||||
}) {
|
}) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -2279,9 +2290,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
Wrap(
|
||||||
alignment: Alignment.centerLeft,
|
spacing: 8,
|
||||||
child: _buildStatusBadge(status),
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
_buildStateBadge(
|
||||||
|
label: isEnabled ? 'Enabled' : 'Disabled',
|
||||||
|
color: isEnabled
|
||||||
|
? context.motorColors.launchGreen
|
||||||
|
: context.motorColors.outlineVariant,
|
||||||
|
icon: isEnabled
|
||||||
|
? Icons.check_circle_outline
|
||||||
|
: Icons.pause_circle_outline,
|
||||||
|
),
|
||||||
|
_buildStatusBadge(status),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -2294,48 +2317,72 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
String label;
|
String label;
|
||||||
Color color;
|
Color color;
|
||||||
|
IconData icon;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case RoadAlertProviderStatus.connected:
|
case RoadAlertProviderStatus.connected:
|
||||||
label = 'Connected';
|
label = 'Verified & Reachable';
|
||||||
color = colors.launchGreen;
|
color = colors.launchGreen;
|
||||||
|
icon = Icons.verified_outlined;
|
||||||
break;
|
break;
|
||||||
case RoadAlertProviderStatus.configured:
|
case RoadAlertProviderStatus.configured:
|
||||||
label = 'Configured';
|
label = 'Configured';
|
||||||
color = colors.cyberCyan;
|
color = colors.cyberCyan;
|
||||||
|
icon = Icons.tune;
|
||||||
break;
|
break;
|
||||||
case RoadAlertProviderStatus.authError:
|
case RoadAlertProviderStatus.authError:
|
||||||
label = 'Auth Error';
|
label = 'Auth Error';
|
||||||
color = colors.raceRed;
|
color = colors.raceRed;
|
||||||
|
icon = Icons.lock_outline;
|
||||||
break;
|
break;
|
||||||
case RoadAlertProviderStatus.rateLimited:
|
case RoadAlertProviderStatus.rateLimited:
|
||||||
label = 'Rate Limited';
|
label = 'Rate Limited';
|
||||||
color = colors.warningAmber;
|
color = colors.warningAmber;
|
||||||
|
icon = Icons.speed;
|
||||||
break;
|
break;
|
||||||
case RoadAlertProviderStatus.networkError:
|
case RoadAlertProviderStatus.networkError:
|
||||||
label = 'Network Error';
|
label = 'Unreachable';
|
||||||
color = colors.warningAmber;
|
color = colors.warningAmber;
|
||||||
|
icon = Icons.cloud_off;
|
||||||
break;
|
break;
|
||||||
case RoadAlertProviderStatus.notConfigured:
|
case RoadAlertProviderStatus.notConfigured:
|
||||||
label = 'Not Set';
|
label = 'Not Configured';
|
||||||
color = colors.outlineVariant;
|
color = colors.outlineVariant;
|
||||||
|
icon = Icons.info_outline;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return _buildStateBadge(label: label, color: color, icon: icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStateBadge({
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
IconData? icon,
|
||||||
|
}) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.15),
|
color: color.withValues(alpha: 0.12),
|
||||||
border: Border.all(color: color),
|
border: Border.all(color: color.withValues(alpha: 0.7)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Row(
|
||||||
label,
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: color,
|
if (icon != null) ...[
|
||||||
fontSize: 11,
|
Icon(icon, size: 12, color: color),
|
||||||
fontWeight: FontWeight.bold,
|
const SizedBox(width: 4),
|
||||||
),
|
],
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
|
||||||
String get id => 'ga511_official';
|
String get id => 'ga511_official';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get name => 'Georgia 511';
|
String get name => 'Georgia 511 (Experimental)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<RoadAlertCategory> get supportedCategories => const {
|
Set<RoadAlertCategory> get supportedCategories => const {
|
||||||
|
|
@ -128,7 +128,7 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
|
||||||
severity: category == RoadAlertCategory.accident || category == RoadAlertCategory.roadClosure
|
severity: category == RoadAlertCategory.accident || category == RoadAlertCategory.roadClosure
|
||||||
? RoadAlertSeverity.high
|
? RoadAlertSeverity.high
|
||||||
: RoadAlertSeverity.medium,
|
: RoadAlertSeverity.medium,
|
||||||
source: 'Georgia 511',
|
source: raw['source']?.toString() ?? 'Georgia 511 (Experimental)',
|
||||||
sourceType: RoadAlertSourceType.providerReported,
|
sourceType: RoadAlertSourceType.providerReported,
|
||||||
status: RoadAlertStatus.active,
|
status: RoadAlertStatus.active,
|
||||||
distanceFromUser: distance,
|
distanceFromUser: distance,
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,68 @@ import 'dart:math' as math;
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'road_alert_models.dart';
|
import 'road_alert_models.dart';
|
||||||
|
|
||||||
|
/// Origin family of an alert source to distinguish independent corroboration
|
||||||
|
/// from redundant feeds of the same upstream provider network.
|
||||||
|
enum AlertOriginFamily {
|
||||||
|
motorSignalSwarm,
|
||||||
|
waze,
|
||||||
|
stateDot,
|
||||||
|
openStreetMap,
|
||||||
|
hereTechnologies,
|
||||||
|
tomTom,
|
||||||
|
other,
|
||||||
|
}
|
||||||
|
|
||||||
/// Deduplicates road alerts across multiple heterogeneous sources (Native Swarm,
|
/// Deduplicates road alerts across multiple heterogeneous sources (Native Swarm,
|
||||||
/// Zyla Waze 4814, Georgia 511, OpenStreetMap speed cameras, etc.).
|
/// Zyla Waze 4814, Georgia 511, OpenStreetMap speed cameras, etc.).
|
||||||
///
|
///
|
||||||
/// Preserves native MotorSignal community reports and their authoritative voting identities.
|
/// Preserves native MotorSignal community reports and their authoritative voting identities.
|
||||||
/// Merges co-located reports within [proximityMeters] that share the same or equivalent category.
|
/// Merges co-located reports within [proximityMeters] that share the same or equivalent category.
|
||||||
|
/// Strictly enforces that sibling feeds from the same origin family (e.g. Waze OSINT + Zyla Waze)
|
||||||
|
/// are treated as redundant data rather than independent multi-source corroboration.
|
||||||
class MultiSourceAlertDeduplicator {
|
class MultiSourceAlertDeduplicator {
|
||||||
/// Maximum distance in meters between two alerts of the same category to be considered duplicates.
|
/// Maximum distance in meters between two alerts of the same category to be considered duplicates.
|
||||||
static const double defaultProximityMeters = 100.0;
|
static const double defaultProximityMeters = 100.0;
|
||||||
|
|
||||||
|
/// Classifies an alert into its underlying origin family.
|
||||||
|
static AlertOriginFamily getOriginFamily(RoadAlert alert) {
|
||||||
|
final pid = alert.providerId.toLowerCase();
|
||||||
|
final src = alert.source.toLowerCase();
|
||||||
|
if (alert.sourceType == RoadAlertSourceType.userReported ||
|
||||||
|
pid.contains('swarm')) {
|
||||||
|
return AlertOriginFamily.motorSignalSwarm;
|
||||||
|
}
|
||||||
|
if (pid.contains('waze') || src.contains('waze')) {
|
||||||
|
return AlertOriginFamily.waze;
|
||||||
|
}
|
||||||
|
if (pid.contains('ga511') || pid.contains('dot') || src.contains('511')) {
|
||||||
|
return AlertOriginFamily.stateDot;
|
||||||
|
}
|
||||||
|
if (pid.contains('osm')) {
|
||||||
|
return AlertOriginFamily.openStreetMap;
|
||||||
|
}
|
||||||
|
if (pid.contains('here')) {
|
||||||
|
return AlertOriginFamily.hereTechnologies;
|
||||||
|
}
|
||||||
|
if (pid.contains('tomtom')) {
|
||||||
|
return AlertOriginFamily.tomTom;
|
||||||
|
}
|
||||||
|
return AlertOriginFamily.other;
|
||||||
|
}
|
||||||
|
|
||||||
/// Deduplicates a list of [RoadAlert]s.
|
/// Deduplicates a list of [RoadAlert]s.
|
||||||
///
|
///
|
||||||
/// Guarantees:
|
/// Guarantees:
|
||||||
/// 1. Native user-reported swarm alerts (`sourceType == RoadAlertSourceType.userReported`)
|
/// 1. Native user-reported swarm alerts (`sourceType == RoadAlertSourceType.userReported`)
|
||||||
/// always take precedence as the canonical alert so Firestore vote identities and
|
/// always take precedence as the canonical alert so Firestore vote identities and
|
||||||
/// moderation status remain intact.
|
/// moderation status remain intact.
|
||||||
/// 2. If an external alert matches a native swarm alert, the native alert's confidence is
|
/// 2. If an external alert matches a native swarm alert from an INDEPENDENT origin family,
|
||||||
/// boosted (multi-source cross-verification), but its ID and vote state are untouched.
|
/// the native alert's confidence is boosted (multi-source cross-verification).
|
||||||
/// 3. Between multiple external provider alerts, the alert with higher confidence, official
|
/// 3. Sibling feeds sharing the same origin family (e.g. Waze OSINT and Zyla Waze Data)
|
||||||
|
/// are deduplicated without artificial confidence inflation or false cross-source attribution.
|
||||||
|
/// 4. Between multiple external provider alerts, the alert with higher confidence, official
|
||||||
/// agency attribution, or more recent timestamp is selected.
|
/// agency attribution, or more recent timestamp is selected.
|
||||||
/// 4. Dismissed alerts (by ID or composite key) are excluded.
|
/// 5. Dismissed alerts (by ID or composite key) are excluded.
|
||||||
static List<RoadAlert> deduplicate(
|
static List<RoadAlert> deduplicate(
|
||||||
List<RoadAlert> alerts, {
|
List<RoadAlert> alerts, {
|
||||||
Set<String> dismissedIds = const {},
|
Set<String> dismissedIds = const {},
|
||||||
|
|
@ -39,8 +81,9 @@ class MultiSourceAlertDeduplicator {
|
||||||
// Sort by priority order so that the highest-priority alert in each cluster becomes primary:
|
// Sort by priority order so that the highest-priority alert in each cluster becomes primary:
|
||||||
// 1. Native Swarm userReported first
|
// 1. Native Swarm userReported first
|
||||||
// 2. Official feeds (e.g. GA511) next
|
// 2. Official feeds (e.g. GA511) next
|
||||||
// 3. Higher confidence next
|
// 3. Licensed API (Zyla) over experimental OSINT scraper
|
||||||
// 4. More recent reportedAt next
|
// 4. Higher confidence next
|
||||||
|
// 5. More recent reportedAt next
|
||||||
activeAlerts.sort((a, b) {
|
activeAlerts.sort((a, b) {
|
||||||
final aIsNative =
|
final aIsNative =
|
||||||
a.sourceType == RoadAlertSourceType.userReported ||
|
a.sourceType == RoadAlertSourceType.userReported ||
|
||||||
|
|
@ -58,6 +101,11 @@ class MultiSourceAlertDeduplicator {
|
||||||
if (aIsOfficial && !bIsOfficial) return -1;
|
if (aIsOfficial && !bIsOfficial) return -1;
|
||||||
if (!aIsOfficial && bIsOfficial) return 1;
|
if (!aIsOfficial && bIsOfficial) return 1;
|
||||||
|
|
||||||
|
final aIsZyla = a.providerId.contains('zyla');
|
||||||
|
final bIsZyla = b.providerId.contains('zyla');
|
||||||
|
if (aIsZyla && !bIsZyla) return -1;
|
||||||
|
if (!aIsZyla && bIsZyla) return 1;
|
||||||
|
|
||||||
final confA = a.confidence ?? 0.5;
|
final confA = a.confidence ?? 0.5;
|
||||||
final confB = b.confidence ?? 0.5;
|
final confB = b.confidence ?? 0.5;
|
||||||
if ((confA - confB).abs() > 0.05) {
|
if ((confA - confB).abs() > 0.05) {
|
||||||
|
|
@ -126,17 +174,33 @@ class MultiSourceAlertDeduplicator {
|
||||||
|
|
||||||
static RoadAlert _mergeCluster(
|
static RoadAlert _mergeCluster(
|
||||||
RoadAlert primary, List<RoadAlert> duplicates) {
|
RoadAlert primary, List<RoadAlert> duplicates) {
|
||||||
// Boost confidence if corroborated by other sources
|
final primaryFamily = getOriginFamily(primary);
|
||||||
final baseConfidence = primary.confidence ?? 0.7;
|
|
||||||
final boostedConfidence =
|
|
||||||
math.min(1.0, baseConfidence + (0.1 * duplicates.length));
|
|
||||||
|
|
||||||
// Combine distinct source names without leaking internal keys
|
// Identify INDEPENDENT distinct origin families
|
||||||
final sources = {primary.source, ...duplicates.map((d) => d.source)}
|
final otherDistinctFamilies = <AlertOriginFamily>{};
|
||||||
.where((s) => s.isNotEmpty)
|
for (final d in duplicates) {
|
||||||
.toList();
|
final family = getOriginFamily(d);
|
||||||
final combinedSource =
|
if (family != primaryFamily) {
|
||||||
sources.length > 1 ? sources.join(' + ') : primary.source;
|
otherDistinctFamilies.add(family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only boost confidence if confirmed by an independent origin family
|
||||||
|
final baseConfidence = primary.confidence ?? 0.7;
|
||||||
|
final boostedConfidence = otherDistinctFamilies.isNotEmpty
|
||||||
|
? math.min(1.0, baseConfidence + (0.1 * otherDistinctFamilies.length))
|
||||||
|
: baseConfidence;
|
||||||
|
|
||||||
|
// Combine distinct source names ONLY across distinct origin families:
|
||||||
|
// Sibling feeds from the same origin (e.g. Waze OSINT + Zyla Waze) do not duplicate attribution
|
||||||
|
final sourceStrings = <String>[primary.source];
|
||||||
|
for (final family in otherDistinctFamilies) {
|
||||||
|
final representative = duplicates.firstWhere(
|
||||||
|
(d) => getOriginFamily(d) == family,
|
||||||
|
);
|
||||||
|
sourceStrings.add(representative.source);
|
||||||
|
}
|
||||||
|
final combinedSource = sourceStrings.join(' + ');
|
||||||
|
|
||||||
return primary.copyWith(
|
return primary.copyWith(
|
||||||
confidence: boostedConfidence,
|
confidence: boostedConfidence,
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,48 @@ void main() {
|
||||||
expect(merged.first.source, contains('Zyla Waze Data #4814'));
|
expect(merged.first.source, contains('Zyla Waze Data #4814'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does NOT treat sibling Waze feeds as independent corroboration', () {
|
||||||
|
final zylaAlert = RoadAlert(
|
||||||
|
id: 'zyla_waze_1',
|
||||||
|
providerId: 'zyla_waze_4814',
|
||||||
|
category: RoadAlertCategory.police,
|
||||||
|
latitude: 34.2560,
|
||||||
|
longitude: -85.1650,
|
||||||
|
description: 'Police on Turner McCall',
|
||||||
|
reportedAt: now.subtract(const Duration(minutes: 5)),
|
||||||
|
lastUpdated: now,
|
||||||
|
confidence: 0.75,
|
||||||
|
source: 'Zyla Waze Data #4814',
|
||||||
|
sourceType: RoadAlertSourceType.providerReported,
|
||||||
|
);
|
||||||
|
|
||||||
|
final wazeOsintAlert = RoadAlert(
|
||||||
|
id: 'waze_osint_1',
|
||||||
|
providerId: 'waze_osint',
|
||||||
|
category: RoadAlertCategory.police,
|
||||||
|
latitude: 34.2561, // ~15m away
|
||||||
|
longitude: -85.1651,
|
||||||
|
description: 'Police report on Turner McCall',
|
||||||
|
reportedAt: now.subtract(const Duration(minutes: 7)),
|
||||||
|
lastUpdated: now,
|
||||||
|
confidence: 0.70,
|
||||||
|
source: 'Waze Community (Experimental OSINT)',
|
||||||
|
sourceType: RoadAlertSourceType.providerReported,
|
||||||
|
);
|
||||||
|
|
||||||
|
final merged = MultiSourceAlertDeduplicator.deduplicate([zylaAlert, wazeOsintAlert]);
|
||||||
|
|
||||||
|
expect(merged.length, equals(1));
|
||||||
|
final canonical = merged.first;
|
||||||
|
// Zyla (licensed feed) is chosen as primary over OSINT scraper
|
||||||
|
expect(canonical.id, equals('zyla_waze_1'));
|
||||||
|
// No confidence inflation because they are sibling feeds from the same origin family
|
||||||
|
expect(canonical.confidence, equals(0.75));
|
||||||
|
// Attribution remains single authoritative Zyla Waze feed
|
||||||
|
expect(canonical.source, equals('Zyla Waze Data #4814'));
|
||||||
|
expect(canonical.source, isNot(contains('Waze Community (Experimental OSINT)')));
|
||||||
|
});
|
||||||
|
|
||||||
test('does NOT merge alerts of different categories at the same location', () {
|
test('does NOT merge alerts of different categories at the same location', () {
|
||||||
final policeAlert = RoadAlert(
|
final policeAlert = RoadAlert(
|
||||||
id: 'alert_p1',
|
id: 'alert_p1',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue