Skip to content

Commit fe68c83

Browse files
committed
feat: measure what SkipCalls adds to the local verdict
probe_live_sources.py --sample asks SkipCalls about 200 numbers drawn from the published database, 200 from the unpublished community pending pool and 50 well-known business lines, and reports per group how often it names a category the app acts on (scam, robocall, telemarketer, fraud), how often that number isn't already in the local database, and how often it answers is_spam with a category the app shows as neutral. The keep-or-remove call for the lookup rests on these numbers.
1 parent 2124ad9 commit fe68c83

2 files changed

Lines changed: 223 additions & 1 deletion

File tree

‎scripts/probe_live_sources.py‎

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,32 @@
1010
For each source the app calls (data/remote/ExternalLookup.kt), this fetches a
1111
sample number and checks the HTTP status and the marker field the Kotlin parser
1212
keys on. Exit status is non-zero when any source stops answering in that shape.
13+
14+
--sample measures what SkipCalls adds: it asks about numbers drawn from the
15+
published database, from the unpublished community pending pool and from
16+
well-known business lines, and counts how often SkipCalls names a spam
17+
category the app acts on and how often that number isn't already in the
18+
local database.
1319
"""
1420

1521
from __future__ import annotations
1622

23+
import argparse
24+
import json
25+
import random
1726
import re
1827
import sys
28+
import time
1929
import urllib.error
2030
import urllib.request
21-
from collections.abc import Callable
31+
from collections.abc import Callable, Iterable
2232
from dataclasses import dataclass
2333
from pathlib import Path
2434

2535
ROOT = Path(__file__).resolve().parent.parent
2636
EXTERNAL_LOOKUP = ROOT / "app" / "src" / "main" / "java" / "com" / "sysadmindoc" / "callshield" / "data" / "remote" / "ExternalLookup.kt"
2737
FETCH_TIMEOUT_SECONDS = 20
38+
DATA = ROOT / "data"
2839

2940

3041
@dataclass(frozen=True)
@@ -94,7 +105,169 @@ def check(source: Source, fetcher: Fetch) -> tuple[bool, str]:
94105
return True, "answers in the shape the app parses, and still flags a known spam number"
95106

96107

108+
# Well-known US customer-service lines. SkipCalls naming one of these as spam
109+
# would be a false alarm the app shows to the user.
110+
BUSINESS_LINES = (
111+
"+18002752273", # Apple Support
112+
"+18008291040", # IRS
113+
"+18007721213", # Social Security
114+
"+18006334227", # Medicare
115+
"+18002758777", # USPS
116+
"+18007425877", # UPS
117+
"+18004633339", # FedEx
118+
"+18002255345", # DHL Express
119+
"+18882804331", # Amazon
120+
"+18004321000", # Bank of America
121+
"+18009359935", # Chase
122+
"+18008693557", # Wells Fargo
123+
"+18009505114", # Citi
124+
"+18002274825", # Capital One
125+
"+18003472683", # Discover
126+
"+18005284800", # American Express
127+
"+18882211161", # PayPal
128+
"+18009346489", # Xfinity
129+
"+18009220204", # Verizon
130+
"+18003310500", # AT&T
131+
"+18009378997", # T-Mobile
132+
"+18002211212", # Delta
133+
"+18008648331", # United
134+
"+18004337300", # American Airlines
135+
"+18004359792", # Southwest
136+
"+18006427676", # Microsoft
137+
"+18006249897", # Dell
138+
"+18004746836", # HP
139+
"+18882378289", # Best Buy
140+
"+18009256278", # Walmart
141+
"+18005913869", # Target
142+
"+18004663337", # Home Depot
143+
"+18004456937", # Lowe's
144+
"+18007742678", # Costco
145+
"+18007467287", # CVS
146+
"+18009254733", # Walgreens
147+
"+18002077847", # GEICO
148+
"+18007828332", # State Farm
149+
"+18007764737", # Progressive
150+
"+18002557828", # Allstate
151+
"+18005315000", # DirecTV
152+
"+18003333474", # DISH
153+
"+18883973742", # Experian
154+
"+18009168800", # TransUnion
155+
"+18773824357", # FTC
156+
"+18008722657", # U.S. Bank
157+
"+18887622265", # PNC
158+
"+18887519000", # TD Bank
159+
"+18008472911", # Visa
160+
"+18006278372", # Mastercard
161+
)
162+
163+
SKIPCALLS_FLAGGED_CATEGORIES = frozenset({"scam", "robocall", "telemarketer", "fraud"})
164+
165+
166+
@dataclass(frozen=True)
167+
class Answer:
168+
number: str
169+
# "flagged" (a category the app acts on), "uncategorized" (is_spam with any
170+
# other category, which the app shows as neutral), "clean" or "error".
171+
verdict: str
172+
173+
174+
def classify(status: int, body: str) -> str:
175+
if status != 200:
176+
return "error"
177+
try:
178+
parsed = json.loads(body)
179+
except ValueError:
180+
return "error"
181+
if not isinstance(parsed, dict) or not isinstance(parsed.get("is_spam"), bool):
182+
return "error"
183+
if not parsed["is_spam"]:
184+
return "clean"
185+
category = str(parsed.get("status_description", "")).strip().lower()
186+
return "flagged" if category in SKIPCALLS_FLAGGED_CATEGORIES else "uncategorized"
187+
188+
189+
def summarize(name: str, answers: list[Answer], local_numbers: set[str]) -> dict:
190+
"""One group's counts. `adds` is a flag for a number the local database doesn't hold."""
191+
flagged = [a for a in answers if a.verdict == "flagged"]
192+
adds = [a for a in flagged if a.number not in local_numbers]
193+
answered = [a for a in answers if a.verdict != "error"]
194+
return {
195+
"group": name,
196+
"asked": len(answers),
197+
"answered": len(answered),
198+
"flagged": len(flagged),
199+
"adds": len(adds),
200+
"uncategorized": sum(1 for a in answers if a.verdict == "uncategorized"),
201+
"errors": len(answers) - len(answered),
202+
"add_examples": [a.number for a in adds[:5]],
203+
}
204+
205+
206+
def database_numbers() -> set[str]:
207+
database = json.loads((DATA / "spam_numbers.json").read_text(encoding="utf-8"))
208+
return {row["number"] for row in database["numbers"]}
209+
210+
211+
def pending_numbers(published: set[str]) -> list[str]:
212+
pending = json.loads((DATA / "community_pending.json").read_text(encoding="utf-8"))
213+
return sorted(
214+
number for number, record in pending["numbers"].items() if not record.get("published") and number not in published
215+
)
216+
217+
218+
def ask_all(numbers: Iterable[str], fetcher: Fetch, delay_seconds: float) -> list[Answer]:
219+
answers = []
220+
for index, number in enumerate(numbers):
221+
if index and delay_seconds:
222+
time.sleep(delay_seconds)
223+
digits = re.sub(r"\D", "", number)
224+
try:
225+
status, body = fetcher(SOURCES[0].url_prefix + digits)
226+
except OSError:
227+
status, body = 0, ""
228+
answers.append(Answer(number, classify(status, body)))
229+
return answers
230+
231+
232+
def sample(fetcher: Fetch, seed: int, per_group: int, delay_seconds: float) -> list[dict]:
233+
published = database_numbers()
234+
pending = pending_numbers(published)
235+
rng = random.Random(seed)
236+
groups = {
237+
"database": rng.sample(sorted(published), min(per_group, len(published))),
238+
"pending": rng.sample(pending, min(per_group, len(pending))),
239+
"business": list(BUSINESS_LINES),
240+
}
241+
return [summarize(name, ask_all(numbers, fetcher, delay_seconds), published) for name, numbers in groups.items()]
242+
243+
244+
def percent(count: int, total: int) -> str:
245+
return f"{100 * count / total:.1f}%" if total else "n/a"
246+
247+
248+
def print_summaries(summaries: list[dict]) -> None:
249+
for s in summaries:
250+
print(
251+
f"{s['group']:<9} asked {s['asked']:>3}, answered {s['answered']:>3}: "
252+
f"flagged {s['flagged']:>3} ({percent(s['flagged'], s['answered'])}), "
253+
f"adds to the local database {s['adds']:>3} ({percent(s['adds'], s['answered'])}), "
254+
f"is_spam without a category the app acts on {s['uncategorized']:>3}, errors {s['errors']}"
255+
)
256+
if s["add_examples"]:
257+
print(f" adds, first few: {', '.join(s['add_examples'])}")
258+
259+
97260
def main() -> int:
261+
parser = argparse.ArgumentParser(description="Check or measure the live caller-lookup sources.")
262+
parser.add_argument("--sample", action="store_true", help="measure what SkipCalls adds instead of checking its shape")
263+
parser.add_argument("--per-group", type=int, default=200, help="numbers drawn from the database and the pending pool")
264+
parser.add_argument("--seed", type=int, default=20260930)
265+
parser.add_argument("--delay", type=float, default=0.5, help="seconds between requests")
266+
args = parser.parse_args()
267+
if args.sample:
268+
print_summaries(sample(fetch, args.seed, args.per_group, args.delay))
269+
return 0
270+
98271
configured = app_source_urls(EXTERNAL_LOOKUP.read_text(encoding="utf-8"))
99272
probed = {source.url_prefix for source in SOURCES}
100273
failures = 0

‎scripts/test_probe_live_sources.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,54 @@ def offline(url):
8080
self.assertIn("no route to host", detail)
8181

8282

83+
class SampleTest(unittest.TestCase):
84+
def test_only_the_categories_the_app_acts_on_count_as_flagged(self):
85+
self.assertEqual("flagged", probe_live_sources.classify(200, SPAM_ANSWER))
86+
self.assertEqual(
87+
"uncategorized",
88+
probe_live_sources.classify(200, '{"is_spam":true,"status_description":"unknown"}'),
89+
)
90+
self.assertEqual("clean", probe_live_sources.classify(200, CLEAN_ANSWER))
91+
self.assertEqual("error", probe_live_sources.classify(429, ""))
92+
self.assertEqual("error", probe_live_sources.classify(200, PARKED_PAGE))
93+
94+
def test_a_flag_adds_only_when_the_local_database_lacks_the_number(self):
95+
answers = [
96+
probe_live_sources.Answer("+18333041447", "flagged"),
97+
probe_live_sources.Answer("+15550100000", "flagged"),
98+
probe_live_sources.Answer("+18002752273", "uncategorized"),
99+
probe_live_sources.Answer("+15550100001", "clean"),
100+
probe_live_sources.Answer("+15550100002", "error"),
101+
]
102+
summary = probe_live_sources.summarize("pending", answers, {"+18333041447"})
103+
self.assertEqual(5, summary["asked"])
104+
self.assertEqual(4, summary["answered"])
105+
self.assertEqual(2, summary["flagged"])
106+
self.assertEqual(1, summary["adds"])
107+
self.assertEqual(["+15550100000"], summary["add_examples"])
108+
self.assertEqual(1, summary["uncategorized"])
109+
self.assertEqual(1, summary["errors"])
110+
111+
def test_numbers_are_asked_as_digits_and_a_dropped_connection_is_an_error(self):
112+
asked = []
113+
114+
def fetcher(url):
115+
asked.append(url)
116+
if url.endswith("15550100002"):
117+
raise OSError("reset")
118+
return 200, SPAM_ANSWER
119+
120+
answers = probe_live_sources.ask_all(["+18333041447", "+15550100002"], fetcher, 0)
121+
self.assertEqual(SKIPCALLS.url_prefix + "18333041447", asked[0])
122+
self.assertEqual(["flagged", "error"], [a.verdict for a in answers])
123+
124+
def test_business_lines_are_distinct_e164_numbers(self):
125+
lines = probe_live_sources.BUSINESS_LINES
126+
self.assertEqual(50, len(lines))
127+
self.assertEqual(len(lines), len(set(lines)))
128+
for line in lines:
129+
self.assertRegex(line, r"^\+1\d{10}$")
130+
131+
83132
if __name__ == "__main__":
84133
unittest.main()

0 commit comments

Comments
 (0)