cedikit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cedikit/__init__.py +35 -0
- cedikit/cli.py +280 -0
- cedikit/data/prefixes.yaml +34 -0
- cedikit/evaluation.py +197 -0
- cedikit/exceptions.py +42 -0
- cedikit/fees/__init__.py +15 -0
- cedikit/fees/calculator.py +227 -0
- cedikit/fees/tables/levies.yaml +30 -0
- cedikit/fees/tables/mtn.yaml +57 -0
- cedikit/fees/tables/telecel.yaml +44 -0
- cedikit/fraud/__init__.py +17 -0
- cedikit/fraud/classifier.py +83 -0
- cedikit/fraud/rules.py +444 -0
- cedikit/fraud/scam_phrases.yaml +89 -0
- cedikit/ids/__init__.py +15 -0
- cedikit/ids/ghana_card.py +75 -0
- cedikit/ids/gpgps.py +117 -0
- cedikit/ids/regions.yaml +256 -0
- cedikit/integrations/__init__.py +7 -0
- cedikit/integrations/django_validators.py +66 -0
- cedikit/integrations/flask_validators.py +70 -0
- cedikit/integrations/pandas_accessor.py +82 -0
- cedikit/integrations/pydantic_types.py +47 -0
- cedikit/ledger.py +700 -0
- cedikit/money.py +426 -0
- cedikit/phone.py +320 -0
- cedikit/py.typed +0 -0
- cedikit/sms/__init__.py +24 -0
- cedikit/sms/anonymise.py +182 -0
- cedikit/sms/models.py +97 -0
- cedikit/sms/parser.py +351 -0
- cedikit/sms/templates/mtn.yaml +82 -0
- cedikit/sms/templates/telecel.yaml +92 -0
- cedikit-1.0.0.dist-info/METADATA +175 -0
- cedikit-1.0.0.dist-info/RECORD +38 -0
- cedikit-1.0.0.dist-info/WHEEL +4 -0
- cedikit-1.0.0.dist-info/entry_points.txt +2 -0
- cedikit-1.0.0.dist-info/licenses/LICENSE +21 -0
cedikit/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""cedikit - a Python toolkit for Ghanaian phone numbers, cedi amounts and Mobile Money data.
|
|
2
|
+
|
|
3
|
+
Everything runs offline; no data leaves the device.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from cedikit import fees, fraud, ids, ledger, money, phone, sms
|
|
7
|
+
from cedikit.exceptions import (
|
|
8
|
+
CedikitError,
|
|
9
|
+
CediTypeError,
|
|
10
|
+
InvalidIdentifier,
|
|
11
|
+
InvalidPhoneNumber,
|
|
12
|
+
MoneyParseError,
|
|
13
|
+
TemplateError,
|
|
14
|
+
)
|
|
15
|
+
from cedikit.money import Cedi
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Cedi",
|
|
21
|
+
"CediTypeError",
|
|
22
|
+
"CedikitError",
|
|
23
|
+
"InvalidIdentifier",
|
|
24
|
+
"InvalidPhoneNumber",
|
|
25
|
+
"MoneyParseError",
|
|
26
|
+
"TemplateError",
|
|
27
|
+
"__version__",
|
|
28
|
+
"fees",
|
|
29
|
+
"fraud",
|
|
30
|
+
"ids",
|
|
31
|
+
"ledger",
|
|
32
|
+
"money",
|
|
33
|
+
"phone",
|
|
34
|
+
"sms",
|
|
35
|
+
]
|
cedikit/cli.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The ``cedikit`` command-line tool.
|
|
2
|
+
|
|
3
|
+
Examples::
|
|
4
|
+
|
|
5
|
+
cedikit phone clean customers.csv --column phone --output cleaned.csv
|
|
6
|
+
cedikit sms parse inbox.txt --export xlsx
|
|
7
|
+
cedikit fraud check "You have received GHS 500..." --sender 0551234567
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import date
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated, Optional
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from cedikit import __version__, fees, fraud, money, phone
|
|
22
|
+
from cedikit.exceptions import CedikitError
|
|
23
|
+
from cedikit.fees import Kind
|
|
24
|
+
from cedikit.ids import ghana_card, gpgps
|
|
25
|
+
from cedikit.ledger import Ledger
|
|
26
|
+
from cedikit.sms.anonymise import anonymise_many
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(help="Tools for Ghanaian phone numbers, cedi amounts and Mobile Money SMS.")
|
|
29
|
+
phone_app = typer.Typer(help="Clean, check and format phone numbers.")
|
|
30
|
+
money_app = typer.Typer(help="Parse, format and spell out cedi amounts.")
|
|
31
|
+
sms_app = typer.Typer(help="Parse and anonymise Mobile Money SMS.")
|
|
32
|
+
fraud_app = typer.Typer(help="Check payment SMS for signs of fraud.")
|
|
33
|
+
fees_app = typer.Typer(help="Estimate Mobile Money charges.")
|
|
34
|
+
ids_app = typer.Typer(help="Check Ghana Card numbers and digital addresses.")
|
|
35
|
+
for sub, name in [
|
|
36
|
+
(phone_app, "phone"),
|
|
37
|
+
(money_app, "money"),
|
|
38
|
+
(sms_app, "sms"),
|
|
39
|
+
(fraud_app, "fraud"),
|
|
40
|
+
(fees_app, "fees"),
|
|
41
|
+
(ids_app, "ids"),
|
|
42
|
+
]:
|
|
43
|
+
app.add_typer(sub, name=name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExportFormat(str, Enum):
|
|
47
|
+
csv = "csv"
|
|
48
|
+
xlsx = "xlsx"
|
|
49
|
+
json = "json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PhoneStyleOption(str, Enum):
|
|
53
|
+
e164 = "e164"
|
|
54
|
+
local = "local"
|
|
55
|
+
pretty = "pretty"
|
|
56
|
+
international = "international"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
KindOption = Enum("KindOption", {k: k for k in Kind.__args__}, type=str) # type: ignore[misc]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fail(message: str) -> typer.Exit:
|
|
63
|
+
typer.secho(message, fg=typer.colors.RED, err=True)
|
|
64
|
+
return typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _version(value: bool) -> None:
|
|
68
|
+
if value:
|
|
69
|
+
typer.echo(f"cedikit {__version__}")
|
|
70
|
+
raise typer.Exit()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.callback()
|
|
74
|
+
def main(
|
|
75
|
+
version: Annotated[
|
|
76
|
+
Optional[bool], # noqa: UP045 - typer needs Optional on Python 3.10
|
|
77
|
+
typer.Option("--version", callback=_version, is_eager=True, help="Show the version."),
|
|
78
|
+
] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""cedikit: built in Ghana, for Ghana. Everything runs offline."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -- phone --------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@phone_app.command("clean")
|
|
87
|
+
def phone_clean(
|
|
88
|
+
input: Annotated[Path, typer.Argument(exists=True, dir_okay=False, help="CSV file.")],
|
|
89
|
+
column: Annotated[str, typer.Option(help="Column holding the phone numbers.")] = "phone",
|
|
90
|
+
output: Annotated[Optional[Path], typer.Option(help="Where to write the cleaned CSV.")] = None, # noqa: UP045
|
|
91
|
+
style: Annotated[PhoneStyleOption, typer.Option(help="Output format.")] = PhoneStyleOption.e164,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Normalise a column of phone numbers and report what was fixed or invalid."""
|
|
94
|
+
with input.open(newline="", encoding="utf-8-sig") as fh:
|
|
95
|
+
reader = csv.DictReader(fh)
|
|
96
|
+
rows = list(reader)
|
|
97
|
+
fields = list(reader.fieldnames or [])
|
|
98
|
+
if column not in fields:
|
|
99
|
+
raise _fail(f"No column {column!r}. Columns: {', '.join(fields)}")
|
|
100
|
+
|
|
101
|
+
report = phone.clean_column(row[column] for row in rows)
|
|
102
|
+
output = output or input.with_name(f"{input.stem}_cleaned.csv")
|
|
103
|
+
with output.open("w", newline="", encoding="utf-8") as fh:
|
|
104
|
+
writer = csv.DictWriter(fh, fieldnames=[*fields, f"{column}_status", f"{column}_note"])
|
|
105
|
+
writer.writeheader()
|
|
106
|
+
for row, result in zip(rows, report.results, strict=True):
|
|
107
|
+
if result.normalised:
|
|
108
|
+
row[column] = phone.format(result.normalised, style.value)
|
|
109
|
+
writer.writerow(
|
|
110
|
+
{**row, f"{column}_status": result.status, f"{column}_note": result.reason or ""}
|
|
111
|
+
)
|
|
112
|
+
typer.echo(str(report))
|
|
113
|
+
for bad in report.invalid[:10]:
|
|
114
|
+
typer.echo(f" invalid: {bad.original!r} - {bad.reason}")
|
|
115
|
+
if report.invalid_count > 10:
|
|
116
|
+
typer.echo(f" ... and {report.invalid_count - 10} more (see the _status column)")
|
|
117
|
+
typer.echo(f"Saved {output}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@phone_app.command("check")
|
|
121
|
+
def phone_check(number: str) -> None:
|
|
122
|
+
"""Validate one number and show its formats and likely network."""
|
|
123
|
+
try:
|
|
124
|
+
e164 = phone.normalise(number)
|
|
125
|
+
except CedikitError as exc:
|
|
126
|
+
raise _fail(str(exc)) from None
|
|
127
|
+
guess = phone.likely_network(e164)
|
|
128
|
+
typer.echo(f"E.164: {e164}")
|
|
129
|
+
typer.echo(f"Local: {phone.format(e164, 'local')}")
|
|
130
|
+
typer.echo(f"International: {phone.format(e164, 'international')}")
|
|
131
|
+
typer.echo(f"Network: {guess.network} (likely - {guess.note})")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# -- money --------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@money_app.command("parse")
|
|
138
|
+
def money_parse(text: str) -> None:
|
|
139
|
+
"""Parse text such as "GH₵1.2k" into an exact amount."""
|
|
140
|
+
try:
|
|
141
|
+
typer.echo(money.parse(text))
|
|
142
|
+
except CedikitError as exc:
|
|
143
|
+
raise _fail(str(exc)) from None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@money_app.command("words")
|
|
147
|
+
def money_words(amount: str) -> None:
|
|
148
|
+
"""Spell out an amount, as on a cheque."""
|
|
149
|
+
try:
|
|
150
|
+
typer.echo(money.to_words(amount))
|
|
151
|
+
except (CedikitError, ValueError) as exc:
|
|
152
|
+
raise _fail(str(exc)) from None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# -- sms ----------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _read_messages(path: Path) -> list[dict[str, str]]:
|
|
159
|
+
"""Messages separated by blank lines (.txt), or a CSV with a ``text`` column and
|
|
160
|
+
optional ``sender`` and ``received_at`` (ISO date-time) columns."""
|
|
161
|
+
if path.suffix.lower() == ".csv":
|
|
162
|
+
with path.open(newline="", encoding="utf-8-sig") as fh:
|
|
163
|
+
return [row for row in csv.DictReader(fh) if row.get("text")]
|
|
164
|
+
blocks = path.read_text(encoding="utf-8-sig").replace("\r\n", "\n").split("\n\n")
|
|
165
|
+
return [{"text": b.strip()} for b in blocks if b.strip()]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@sms_app.command("parse")
|
|
169
|
+
def sms_parse(
|
|
170
|
+
input: Annotated[
|
|
171
|
+
Path,
|
|
172
|
+
typer.Argument(
|
|
173
|
+
exists=True, dir_okay=False, help="Messages separated by blank lines, or a CSV."
|
|
174
|
+
),
|
|
175
|
+
],
|
|
176
|
+
sender: Annotated[Optional[str], typer.Option(help="Sender ID of the messages.")] = None, # noqa: UP045
|
|
177
|
+
export: Annotated[Optional[ExportFormat], typer.Option(help="Save the ledger.")] = None, # noqa: UP045
|
|
178
|
+
output: Annotated[Optional[Path], typer.Option(help="Output file.")] = None, # noqa: UP045
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Turn a file of MoMo SMS into a ledger and print a summary."""
|
|
181
|
+
ledger = Ledger.from_messages(_read_messages(input), sender).categorise()
|
|
182
|
+
typer.echo(str(ledger.summary()))
|
|
183
|
+
if ledger.notices:
|
|
184
|
+
typer.echo(f"{len(ledger.notices)} notices (e.g. airtime received) were not counted.")
|
|
185
|
+
if ledger.unrecognised:
|
|
186
|
+
typer.secho(
|
|
187
|
+
f"{len(ledger.unrecognised)} messages were not recognised.", fg=typer.colors.YELLOW
|
|
188
|
+
)
|
|
189
|
+
for gap in ledger.balance_gaps():
|
|
190
|
+
typer.secho(
|
|
191
|
+
f"Balance gap before {gap.after.transaction_id or 'a transaction'}: expected "
|
|
192
|
+
f"{money.format(gap.expected)}, message says {money.format(gap.actual)} "
|
|
193
|
+
"(a message may be missing).",
|
|
194
|
+
fg=typer.colors.YELLOW,
|
|
195
|
+
)
|
|
196
|
+
if export or output:
|
|
197
|
+
fmt = export.value if export else (output.suffix.lstrip(".") if output else "csv")
|
|
198
|
+
path = output or input.with_name(f"{input.stem}_ledger.{fmt}")
|
|
199
|
+
try:
|
|
200
|
+
ledger.export(path, fmt) # type: ignore[arg-type] # validated by export()
|
|
201
|
+
except (ValueError, ImportError) as exc:
|
|
202
|
+
raise _fail(str(exc)) from None
|
|
203
|
+
typer.echo(f"Saved {path}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@sms_app.command("anonymise")
|
|
207
|
+
def sms_anonymise(
|
|
208
|
+
input: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
|
|
209
|
+
seed: Annotated[Optional[int], typer.Option(help="Make the output repeatable.")] = None, # noqa: UP045
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Replace names, numbers, IDs, amounts and dates (keeps balances consistent)."""
|
|
212
|
+
messages = [(m["text"], m.get("sender") or None) for m in _read_messages(input)]
|
|
213
|
+
for result in anonymise_many(messages, seed=seed):
|
|
214
|
+
if result.needs_review:
|
|
215
|
+
typer.secho("# CHECK BY HAND: " + " ".join(result.notes), fg=typer.colors.YELLOW)
|
|
216
|
+
typer.echo(result.text + "\n")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# -- fraud --------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@fraud_app.command("check")
|
|
223
|
+
def fraud_check(
|
|
224
|
+
message: Annotated[str, typer.Argument(help="The SMS text (use - to read from stdin).")],
|
|
225
|
+
sender: Annotated[Optional[str], typer.Option(help="Who sent it.")] = None, # noqa: UP045
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Rate how likely a payment SMS is to be fake, and explain why."""
|
|
228
|
+
text = sys.stdin.read() if message == "-" else message
|
|
229
|
+
report = fraud.check(text, sender=sender)
|
|
230
|
+
colour = {"LOW": typer.colors.GREEN, "MEDIUM": typer.colors.YELLOW, "HIGH": typer.colors.RED}
|
|
231
|
+
lines = str(report).splitlines()
|
|
232
|
+
typer.secho(lines[0], fg=colour[report.risk], bold=True)
|
|
233
|
+
typer.echo("\n".join(lines[1:]))
|
|
234
|
+
if sender is None:
|
|
235
|
+
typer.echo("Tip: pass --sender; fake alerts almost always come from personal numbers.")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# -- fees ---------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@fees_app.command("estimate")
|
|
242
|
+
def fees_estimate(
|
|
243
|
+
network: str,
|
|
244
|
+
kind: Annotated[KindOption, typer.Argument()],
|
|
245
|
+
amount: str,
|
|
246
|
+
on: Annotated[
|
|
247
|
+
Optional[str], # noqa: UP045
|
|
248
|
+
typer.Option(help="Date YYYY-MM-DD (default today)."),
|
|
249
|
+
] = None,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Estimate the fee and E-Levy for a transaction."""
|
|
252
|
+
try:
|
|
253
|
+
day = date.fromisoformat(on) if on else None
|
|
254
|
+
typer.echo(str(fees.estimate(network, kind.value, amount, day)))
|
|
255
|
+
except (CedikitError, ValueError) as exc:
|
|
256
|
+
raise _fail(str(exc)) from None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# -- ids ----------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@ids_app.command("check")
|
|
263
|
+
def ids_check(value: str) -> None:
|
|
264
|
+
"""Check a Ghana Card number or GhanaPostGPS address (format only)."""
|
|
265
|
+
if ghana_card.is_valid_format(value):
|
|
266
|
+
typer.echo(
|
|
267
|
+
f"Ghana Card number, valid format: {ghana_card.normalise(value)} "
|
|
268
|
+
f"({ghana_card.card_type(value)})"
|
|
269
|
+
)
|
|
270
|
+
elif gpgps.is_valid_format(value):
|
|
271
|
+
address = gpgps.parse(value)
|
|
272
|
+
place = ", ".join(p for p in (address.district, address.region) if p)
|
|
273
|
+
typer.echo(f"GhanaPostGPS address, valid format: {address.code} ({place})")
|
|
274
|
+
else:
|
|
275
|
+
raise _fail(f"{value!r} is neither a Ghana Card number nor a GhanaPostGPS address.")
|
|
276
|
+
typer.echo("Format check only: this does not confirm that it exists.")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__": # pragma: no cover
|
|
280
|
+
app()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Ghanaian mobile number prefixes and the network each was ORIGINALLY assigned to.
|
|
2
|
+
#
|
|
3
|
+
# Because of mobile number portability, a prefix only tells you the *likely*
|
|
4
|
+
# network - the subscriber may have ported to another operator.
|
|
5
|
+
#
|
|
6
|
+
# Keys are the two national digits after the trunk "0" (e.g. 024 -> "24").
|
|
7
|
+
#
|
|
8
|
+
# Sources (checked 2026-09-26):
|
|
9
|
+
# NCA, "Numbering Plan for Ghana" (nca.org.gh, uploaded 2021): 9-digit national
|
|
10
|
+
# numbers; 20 & 50 Ghana Telecom (now Telecel); 23 Glo; 24, 54, 55 MTN;
|
|
11
|
+
# 26, 56 Airtel and 27, 57 Millicom/Tigo (now AT); 25 mobile broadband.
|
|
12
|
+
# The plan predates later assignments: MTN received 025 blocks in 2021
|
|
13
|
+
# (Graphic Online, "MTN releases new network codes") and uses 053 and 059,
|
|
14
|
+
# which the plan still shows as unassigned.
|
|
15
|
+
# Not included: 028 (Kasapa/Expresso, which has shut down) and 029 (National
|
|
16
|
+
# Security, not public numbers).
|
|
17
|
+
|
|
18
|
+
version: "2026-09-26"
|
|
19
|
+
country_code: "233"
|
|
20
|
+
national_number_length: 9
|
|
21
|
+
|
|
22
|
+
networks:
|
|
23
|
+
MTN:
|
|
24
|
+
display_name: MTN Ghana
|
|
25
|
+
prefixes: ["24", "25", "53", "54", "55", "59"]
|
|
26
|
+
TELECEL:
|
|
27
|
+
display_name: Telecel Ghana
|
|
28
|
+
prefixes: ["20", "50"]
|
|
29
|
+
AT:
|
|
30
|
+
display_name: AT Ghana
|
|
31
|
+
prefixes: ["26", "27", "56", "57"]
|
|
32
|
+
GLO:
|
|
33
|
+
display_name: Glo Mobile Ghana
|
|
34
|
+
prefixes: ["23"]
|
cedikit/evaluation.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Measure the parser and fraud checker against labelled, anonymised messages.
|
|
2
|
+
|
|
3
|
+
The file format is the one used by ``tests/fixtures/sample_messages``: a YAML
|
|
4
|
+
list of entries with ``text``, ``sender`` and (for parser checks) ``expected``
|
|
5
|
+
fields. Keep a held-out set that is never used to tune rules or train models.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from cedikit import evaluation
|
|
10
|
+
genuine = evaluation.load("genuine.yaml")
|
|
11
|
+
scam = evaluation.load("scam.yaml")
|
|
12
|
+
print(evaluation.evaluate_fraud(genuine, scam))
|
|
13
|
+
print(evaluation.evaluate_parser(genuine))
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from decimal import Decimal
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from cedikit import fraud, sms
|
|
27
|
+
from cedikit.fraud.rules import FraudReport, Risk
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from cedikit.fraud.classifier import ScamClassifier
|
|
31
|
+
|
|
32
|
+
__all__ = ["FraudEvaluation", "ParserEvaluation", "evaluate_fraud", "evaluate_parser", "load"]
|
|
33
|
+
|
|
34
|
+
RECALL_TARGET = 0.90
|
|
35
|
+
PRECISION_TARGET = 0.85
|
|
36
|
+
PARSER_TARGET = 0.95
|
|
37
|
+
_MONEY_FIELDS = {"amount", "fee", "tax", "balance", "available_balance"}
|
|
38
|
+
_ORDER: dict[str, int] = {"LOW": 0, "MEDIUM": 1, "HIGH": 2}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load(path: str | Path) -> list[dict[str, Any]]:
|
|
42
|
+
"""Load a labelled YAML file."""
|
|
43
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
44
|
+
if not isinstance(data, list):
|
|
45
|
+
raise ValueError(f"{path}: expected a list of messages")
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ratio(a: int, b: int) -> float:
|
|
50
|
+
return a / b if b else 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class FraudEvaluation:
|
|
55
|
+
true_positives: int
|
|
56
|
+
false_positives: int
|
|
57
|
+
true_negatives: int
|
|
58
|
+
false_negatives: int
|
|
59
|
+
flag_at: Risk
|
|
60
|
+
missed: list[tuple[dict[str, Any], FraudReport]] = field(default_factory=list)
|
|
61
|
+
false_alarms: list[tuple[dict[str, Any], FraudReport]] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def recall(self) -> float:
|
|
65
|
+
"""Share of scams that were flagged."""
|
|
66
|
+
return _ratio(self.true_positives, self.true_positives + self.false_negatives)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def precision(self) -> float:
|
|
70
|
+
"""Share of flagged messages that really were scams."""
|
|
71
|
+
return _ratio(self.true_positives, self.true_positives + self.false_positives)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def f1(self) -> float:
|
|
75
|
+
p, r = self.precision, self.recall
|
|
76
|
+
return 2 * p * r / (p + r) if p + r else 0.0
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
total = sum(
|
|
80
|
+
(self.true_positives, self.false_positives, self.true_negatives, self.false_negatives)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def mark(value: float, target: float) -> str:
|
|
84
|
+
return "meets target" if value >= target else f"below target {target:.0%}"
|
|
85
|
+
|
|
86
|
+
lines = [
|
|
87
|
+
f"Fraud detection on {total} messages (flagged at {self.flag_at} or above):",
|
|
88
|
+
f" Recall: {self.recall:.1%} ({mark(self.recall, RECALL_TARGET)})",
|
|
89
|
+
f" Precision: {self.precision:.1%} ({mark(self.precision, PRECISION_TARGET)})",
|
|
90
|
+
f" F1: {self.f1:.3f}",
|
|
91
|
+
f" Scams caught {self.true_positives}, missed {self.false_negatives}; "
|
|
92
|
+
f"false alarms {self.false_positives}, correct passes {self.true_negatives}",
|
|
93
|
+
]
|
|
94
|
+
lines += [f" MISSED: {m.get('id', m['text'][:50])}" for m, _ in self.missed]
|
|
95
|
+
lines += [f" FALSE ALARM: {m.get('id', m['text'][:50])}" for m, _ in self.false_alarms]
|
|
96
|
+
if total < 100:
|
|
97
|
+
lines.append(f" Note: {total} messages is too few for reliable percentages.")
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def evaluate_fraud(
|
|
102
|
+
genuine: Sequence[dict[str, Any]],
|
|
103
|
+
scam: Sequence[dict[str, Any]],
|
|
104
|
+
*,
|
|
105
|
+
flag_at: Risk = "MEDIUM",
|
|
106
|
+
use_sender: bool = True,
|
|
107
|
+
classifier: ScamClassifier | None = None,
|
|
108
|
+
) -> FraudEvaluation:
|
|
109
|
+
"""Count how many scams are flagged and how many genuine messages are wrongly flagged.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
flag_at: The lowest risk level counted as "flagged".
|
|
113
|
+
use_sender: Pass each message's sender to the checker. Set False to
|
|
114
|
+
measure how well the text alone is judged.
|
|
115
|
+
"""
|
|
116
|
+
tp = fp = tn = fn = 0
|
|
117
|
+
missed: list[tuple[dict[str, Any], FraudReport]] = []
|
|
118
|
+
alarms: list[tuple[dict[str, Any], FraudReport]] = []
|
|
119
|
+
for sample, is_scam in [(g, False) for g in genuine] + [(s, True) for s in scam]:
|
|
120
|
+
report = fraud.check(
|
|
121
|
+
sample["text"],
|
|
122
|
+
sender=sample.get("sender") if use_sender else None,
|
|
123
|
+
classifier=classifier,
|
|
124
|
+
)
|
|
125
|
+
flagged = _ORDER[report.risk] >= _ORDER[flag_at]
|
|
126
|
+
if is_scam and flagged:
|
|
127
|
+
tp += 1
|
|
128
|
+
elif is_scam:
|
|
129
|
+
fn += 1
|
|
130
|
+
missed.append((sample, report))
|
|
131
|
+
elif flagged:
|
|
132
|
+
fp += 1
|
|
133
|
+
alarms.append((sample, report))
|
|
134
|
+
else:
|
|
135
|
+
tn += 1
|
|
136
|
+
return FraudEvaluation(tp, fp, tn, fn, flag_at, missed, alarms)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _actual(tx: sms.Transaction, name: str) -> Any:
|
|
140
|
+
if name.startswith("counterparty_"):
|
|
141
|
+
return getattr(tx.counterparty, name.removeprefix("counterparty_"), None)
|
|
142
|
+
if name == "timestamp":
|
|
143
|
+
return tx.timestamp.isoformat() if tx.timestamp else None
|
|
144
|
+
if name == "type":
|
|
145
|
+
return tx.type.value
|
|
146
|
+
return getattr(tx, name)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def field_mismatches(tx: sms.Transaction, expected: dict[str, Any]) -> list[str]:
|
|
150
|
+
"""Fields of ``tx`` that differ from ``expected``, as readable strings."""
|
|
151
|
+
problems = []
|
|
152
|
+
for name, want in expected.items():
|
|
153
|
+
if name in _MONEY_FIELDS and want is not None:
|
|
154
|
+
want = Decimal(str(want))
|
|
155
|
+
got = _actual(tx, name)
|
|
156
|
+
if got != want:
|
|
157
|
+
problems.append(f"{name}: expected {want!r}, got {got!r}")
|
|
158
|
+
return problems
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class ParserEvaluation:
|
|
163
|
+
total: int
|
|
164
|
+
fully_correct: int
|
|
165
|
+
failures: dict[str, list[str]] = field(default_factory=dict)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def accuracy(self) -> float:
|
|
169
|
+
return _ratio(self.fully_correct, self.total)
|
|
170
|
+
|
|
171
|
+
def __str__(self) -> str:
|
|
172
|
+
status = "meets" if self.accuracy >= PARSER_TARGET else "below"
|
|
173
|
+
lines = [
|
|
174
|
+
f"Parser: {self.fully_correct}/{self.total} messages with every expected field "
|
|
175
|
+
f"correct ({self.accuracy:.1%}, {status} target {PARSER_TARGET:.0%})"
|
|
176
|
+
]
|
|
177
|
+
for sample_id, problems in self.failures.items():
|
|
178
|
+
lines.append(f" {sample_id}: " + "; ".join(problems))
|
|
179
|
+
return "\n".join(lines)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def evaluate_parser(samples: Sequence[dict[str, Any]]) -> ParserEvaluation:
|
|
183
|
+
"""Check that each sample parses with all its ``expected`` fields correct."""
|
|
184
|
+
correct = 0
|
|
185
|
+
failures: dict[str, list[str]] = {}
|
|
186
|
+
for i, sample in enumerate(samples):
|
|
187
|
+
sample_id = str(sample.get("id", i))
|
|
188
|
+
result = sms.parse(sample["text"], sender=sample.get("sender"))
|
|
189
|
+
if result.transaction is None:
|
|
190
|
+
failures[sample_id] = ["not recognised"]
|
|
191
|
+
continue
|
|
192
|
+
problems = field_mismatches(result.transaction, sample.get("expected", {}))
|
|
193
|
+
if problems:
|
|
194
|
+
failures[sample_id] = problems
|
|
195
|
+
else:
|
|
196
|
+
correct += 1
|
|
197
|
+
return ParserEvaluation(len(samples), correct, failures)
|
cedikit/exceptions.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exceptions raised by cedikit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CedikitError(Exception):
|
|
7
|
+
"""Base class for all cedikit errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidPhoneNumber(CedikitError, ValueError):
|
|
11
|
+
"""Raised when a phone number cannot be normalised to a valid Ghanaian mobile number."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, value: object, reason: str) -> None:
|
|
14
|
+
self.value = value
|
|
15
|
+
self.reason = reason
|
|
16
|
+
super().__init__(f"Invalid Ghanaian phone number {value!r}: {reason}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CediTypeError(CedikitError, TypeError):
|
|
20
|
+
"""Raised when a float (or other unsafe type) is used as a money value."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidIdentifier(CedikitError, ValueError):
|
|
24
|
+
"""Raised when a Ghana Card number or digital address has the wrong format."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, value: object, reason: str) -> None:
|
|
27
|
+
self.value = value
|
|
28
|
+
self.reason = reason
|
|
29
|
+
super().__init__(f"Invalid identifier {value!r}: {reason}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TemplateError(CedikitError):
|
|
33
|
+
"""Raised when an SMS template file is malformed."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MoneyParseError(CedikitError, ValueError):
|
|
37
|
+
"""Raised when text cannot be parsed as a cedi amount."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, value: object, reason: str) -> None:
|
|
40
|
+
self.value = value
|
|
41
|
+
self.reason = reason
|
|
42
|
+
super().__init__(f"Cannot parse {value!r} as a cedi amount: {reason}")
|
cedikit/fees/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Estimate Mobile Money fees and the E-Levy from dated, sourced tables.
|
|
2
|
+
|
|
3
|
+
Results are always *estimates*: charges change, and the tables record only
|
|
4
|
+
what has been observed or published. Unknown values are ``None``, never guessed.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from datetime import date
|
|
8
|
+
>>> from cedikit import fees
|
|
9
|
+
>>> fees.estimate("MTN", "cash_out", "50.00").fee
|
|
10
|
+
Decimal('0.50')
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from cedikit.fees.calculator import FeeEstimate, Kind, estimate
|
|
14
|
+
|
|
15
|
+
__all__ = ["FeeEstimate", "Kind", "estimate"]
|