Lesson 4: Rules in production: safe, tested and measured
Module 1 · Lesson 4 · About 60 minutes · Needs: Module 1 Lesson 3 (character classes, quantifiers,\band groups in Python'sre)
The situation
It is Monday 7 September 2026, your first week at Tidewell Cycles. Your regular expressions find order numbers, emails and dates. Before they touch the live inbox, Ines asks whether a pattern can freeze the web form, Amara wants contact details removed before messages are stored, and Rosa wants one number: how well a simple keyword router does, so she can judge every later model.
One message runs through the lesson: TW-M0435, from Kofi Asante, 24 April 2026. He asks for the tracking link for order TW-106489, and gives his email address, mobile number, postcode and name.
A real case, first
In January 2007 Russ Cox published "Regular Expression Matching Can Be Simple And Fast (but is slow in Java, Perl, PHP, Python, Ruby, ...)" on his site, swtch.com (link). His point: most popular regex engines, Python's re among them, work by backtracking. On some patterns and inputs they take exponential time, so a few more characters can turn a millisecond into minutes.
An older method avoids this: Ken Thompson's 1968 paper, "Regular Expression Search Algorithm", turned a pattern into a small machine whose time grows only in proportion to the input. Cox later wrote RE2 at Google, an engine that never backtracks (github.com/google/re2).
For Tidewell: a correct pattern can still be dangerous.
What you will be able to do
- Spot, time and rewrite a pattern that backtracks catastrophically.
- Test patterns with positive and negative cases, in plain Python and pytest.
- Redact contact details before storage and check nothing leaked.
- Build and score a keyword rule router as the baseline.
- Decide which jobs stay with rules and which need a model.
1. Catastrophic backtracking and regex denial of service (Essential)
One pattern and one unlucky input can stall a whole service. Knowing the shape of a dangerous pattern lets you avoid writing one.
Here is a first try at the part of an email address before the @: ^([a-z0-9]+\.?)*[a-z0-9]+@, "letters, maybe a dot, repeated, then letters, then @". On kofi.asante@mailbox.example it works at once. The trouble starts when there is no @.
The group ([a-z0-9]+\.?)* can cut kofi into pieces eight ways: kofi, ko + fi, k + o + f + i and five more. Each extra letter doubles the count. With no @, the engine backtracks through every way before it gives up.
Suppose Kofi types his address with no dots and no @: kofiasantemailboxexample, 24 letters. Time the pattern on longer slices of it.
import re, time
EMAIL_START = re.compile(r"^([a-z0-9]+\.?)*[a-z0-9]+@") # first try: the part before the @
print(EMAIL_START.match("kofi.asante@mailbox.example").group())
typed = "kofiasantemailboxexample" # the same address typed with no dots and no @
for n in [12, 16, 20, 22, 24]:
start = time.perf_counter()
EMAIL_START.match(typed[:n])
print(f"{n} characters: {time.perf_counter() - start:.4f} seconds")Code walkthrough
.matchtries the compiled pattern at the start of the text; on a real address it findskofi.asante@.typed[:n]takes the firstnletters; with no@, every match fails.time.perf_counter()reads a precise clock before and after one failed match.
Output
kofi.asante@
12 characters: 0.0001 seconds
16 characters: 0.0020 seconds
20 characters: 0.0307 seconds
22 characters: 0.1183 seconds
24 characters: 0.4686 secondsTwo more letters multiply the time by about four. This is catastrophic backtracking: work that grows exponentially with the input. The times were measured on a Mac; yours will differ, the shape will not. The warning sign is a nested quantifier, a repeat inside a repeat such as (...+)*.
Sending such an input on purpose is regex denial of service, or ReDoS. One form submission keeps a worker busy, and every message behind it waits. Python's re has no time limit.
Three defences, used together: rewrite the pattern so each character matches only one way; refuse over-long inputs; for untrusted text, use an engine that never backtracks, such as RE2 (named only here). Python 3.11's atomic groups and possessive quantifiers also stop backtracking, but change what matches.
SAFE_START = re.compile(r"^[a-z0-9]+(?:\.[a-z0-9]+)*@") # a dot is required between pieces
MAX_LEN = 320 # the longest field this check accepts
def check_email_start(text):
if len(text) > MAX_LEN:
return None # too long to be an address: refuse before matching
return SAFE_START.match(text)
print(check_email_start("kofi.asante@mailbox.example").group())
for n in [24, 320, 100_000]:
text = ("kofiasante" * 10_000)[:n]
start = time.perf_counter()
check_email_start(text)
print(f"{n} characters: {time.perf_counter() - start:.6f} seconds")Code walkthrough
(?:\.[a-z0-9]+)*repeats "a dot, then letters";(?:...)groups without capturing. Every repeat must start with a dot, so letters split only one way.check_email_startrefuses anything overMAX_LENbefore matching; the 100,000-letter input is refused.
Output
kofi.asante@
24 characters: 0.000002 seconds
320 characters: 0.000004 seconds
100000 characters: 0.000000 seconds2. Testing patterns with positive and negative cases (Essential)
A pattern that works on your example can still match things it should not. Tests catch that while it is cheap to fix.
A positive case is a text the pattern must match; a negative case is one it must not. The best negative cases are near misses from the inbox: product codes (TW-BATT-500), message ids (TW-M0435), repair tickets (RJ-2026-0412) and a seven-digit typo.
ORDER_ID = re.compile(r"\bTW-\d{6}\b")
MUST_MATCH = ["Could you send me the tracking link for order TW-106489?",
"(order TW-102870, item: Volt 2)", "any update on TW-106271? need"]
MUST_NOT = ["is TW-BATT-500 the same pack", "message TW-M0435", "order TW-1064890",
"ticket RJ-2026-0412", "call me on 07700 900070"]
def check_pattern(pattern, must_match, must_not):
failures = [("missed", s) for s in must_match if not pattern.search(s)]
failures += [("false hit", s) for s in must_not if pattern.search(s)]
print(f"{len(must_match)} positive, {len(must_not)} negative, {len(failures)} failures")
for kind, s in failures:
print(" ", kind, "|", s)
return failures
check_pattern(ORDER_ID, MUST_MATCH, MUST_NOT)Code walkthrough
\bTW-\d{6}\bisTW-and exactly six digits, with a word boundary\beach side.check_patterncollects missed positives and hit negatives, prints them, and returns the list so a test can require it empty.searchlooks anywhere in the text.
Output
3 positive, 5 negative, 0 failuresBreak it: remove the word boundaries
Delete the two \b and run the same cases.
NO_EDGES = re.compile(r"TW-\d{6}") # the same pattern without the two \b
check_pattern(NO_EDGES, MUST_MATCH, MUST_NOT)
print(NO_EDGES.search("order TW-1064890").group())Code walkthrough
NO_EDGESdrops both boundaries; the last line shows what it pulls out of the typo.
Output
3 positive, 5 negative, 1 failures
false hit | order TW-1064890
TW-106489no match
the typo is left for a person
finds TW-106489
Kofi's real order: wrong customer
The positive cases still pass, so positive-only tests would miss this. The pattern takes six of the seven digits and returns TW-106489, Kofi's real order: Ines would open the wrong customer's order, and nobody would notice.
3. Redacting personal data before storage (Essential)
Every stored message with an address and phone number in it is a copy the shop must protect and delete on time. Removing those details on arrival shrinks that job.
Redaction replaces personal data with a placeholder naming its kind, such as [EMAIL]. Amara's reason: the UK GDPR lists data minimisation, keeping only the personal data you need, among its principles (Article 5(1)(c)). Tidewell's policy (KB21) keeps messages 18 months and removes card details before storage. This is the project's rule, not legal advice.
kofi.asante@mailbox.example
07700 900070
BS12 2FR
Cheers, Kofi
[EMAIL]
[PHONE]
[POSTCODE]
Cheers, [NAME]
The order number stays: Ines needs it to act. The contact details go: the shop already holds them in its customer records, so the copy adds only risk.
Redaction needs the inbox: paste blocks 1 to 4 from the shared data page, then the fingerprint check.
from datetime import date, timedelta
COURSE_DATE = date(2026, 10, 14) # facts, versions and model names checked on this day
DATA_AS_OF = date(2026, 8, 31) # the inbox snapshot: count days to this, never to the clock
WINDOW = (date(2026, 3, 2), date(2026, 8, 30)) # 26 weeks of messages, Monday to Sunday
SEED = 20260831
EVENTS = { # fixed dates the data and the lessons share
"spam_wave": (date(2026, 4, 6), date(2026, 4, 19)),
"battery_notice": date(2026, 6, 9), # email to Volt 2 owners about pack batch VB-0426
"eu_shipping": date(2026, 7, 1), # Germany, Spain and France
"summer_sale": (date(2026, 7, 15), date(2026, 7, 28)),
"carrier_change": date(2026, 8, 10), # ParcelNorth -> SwiftDrop
}
BATCH = "VB-0426"
STAFF = ["Rosa", "Ines", "Dev", "Callum", "Amara"]
PRODUCTS = [ # (sku, name, price in GBP, kind)
("TW-VOLT2-BLK", "Volt 2", 2199, "ebike"),
("TW-VOLT2-STP", "Volt 2 Step-Through", 2249, "ebike"),
("TW-HARB-GRN", "Harbour", 649, "bike"),
("TW-HKID-20R", "Harbour Kids 20", 329, "bike"),
("TW-CAUS-GRY", "Causeway", 1149, "bike"),
("TW-PANN-SET", "Commuter Pannier Set", 89, "accessory"),
("TW-BATT-500", "Volt 2 battery pack (500 Wh)", 549, "accessory"),
("TW-LOCK-DLK", "LockSure D-lock", 45, "accessory"),
("TW-LITE-BCN", "Beacon Light Set", 39, "accessory"),
("TW-SERV-BAS", "Basic Service", 65, "service"),
("TW-SERV-FUL", "Full Service", 129, "service"),
("TW-REPR-PUN", "Puncture Repair", 18, "service"),
("TW-DIAG-EBK", "E-bike Diagnostic", 45, "service"),
("TW-GIFT-CRD", "Gift Card", 50, "gift"), # sold from 25 to 200 GBP; 50 is the usual
]
INTENTS = ["order_status", "returns_refund", "repair_booking", "product_question",
"warranty_fault", "praise", "spam"]
LABELS = { # Ines's written definitions, used for every label in the course
"order_status": "Where an order is, delivery problems, missing items, invoices and order documents.",
"returns_refund": "Sending something back, exchanges without a fault, cancellations, refunds and wrong charges.",
"repair_booking": "Booking, moving or asking the price of a service or repair for wear and tear.",
"product_question": "Questions before or after buying: sizes, stock, range, compatibility, delivery areas.",
"warranty_fault": "A product that is faulty or arrived damaged, including the battery batch VB-0426.",
"praise": "Thanks, compliments and good reviews, with no request that needs action.",
"spam": "Unsolicited selling, phishing and scams.",
}
CUSTOMERS = [ # (first, last, gender, language, home town); all fictional
("Joan", "Hartley", "f", "en", "Clifton"), ("Oliver", "Bennett", "m", "en", "Bristol"),
("Amelia", "Clarke", "f", "en", "Bath"), ("Mohammed", "Khan", "m", "en", "Bristol"),
("Aisha", "Begum", "f", "en", "Bedminster"), ("Daniel", "O'Connor", "m", "en", "Bristol"),
("Priya", "Patel", "f", "en", "Swindon"), ("Arjun", "Sharma", "m", "en", "Bristol"),
("Grace", "Okafor", "f", "en", "Cardiff"), ("Emeka", "Eze", "m", "en", "Bristol"),
("Hannah", "Lewis", "f", "en", "Stroud"), ("Tom", "Hughes", "m", "en", "Gloucester"),
("Siobhan", "Murphy", "f", "en", "Bristol"), ("Liam", "Walsh", "m", "en", "Bath"),
("Mei", "Chen", "f", "en", "Bristol"), ("Wei", "Zhang", "m", "en", "Clifton"),
("Fatima", "Hussain", "f", "en", "Bristol"), ("Yusuf", "Ali", "m", "en", "Bedminster"),
("Zara", "Ahmed", "f", "en", "Taunton"), ("Jack", "Robinson", "m", "en", "Bristol"),
("Ella", "Morgan", "f", "en", "Cardiff"), ("Rhys", "Evans", "m", "en", "Cardiff"),
("Megan", "Price", "f", "en", "Weston-super-Mare"), ("Ben", "Carter", "m", "en", "Bristol"),
("Lucy", "Ward", "f", "en", "Bath"), ("Kofi", "Asante", "m", "en", "Bristol"),
("Ama", "Boateng", "f", "en", "Bristol"), ("Olusegun", "Adeyemi", "m", "en", "Swindon"),
("Funmi", "Adebayo", "f", "en", "Bristol"), ("Raj", "Gupta", "m", "en", "Gloucester"),
("Neha", "Kapoor", "f", "en", "Bristol"), ("Tomasz", "Nowak", "m", "en", "Bristol"),
("Agnieszka", "Kowalska", "f", "en", "Stroud"), ("Stefan", "Ionescu", "m", "en", "Bristol"),
("Ioana", "Popescu", "f", "en", "Bath"), ("Hiroshi", "Tanaka", "m", "en", "Bristol"),
("Lukas", "Becker", "m", "de", "Berlin"), ("Anna", "Schmidt", "f", "de", "Hamburg"),
("Jonas", "Weber", "m", "de", "München"), ("Lea", "Fischer", "f", "de", "Köln"),
("Mehmet", "Yılmaz", "m", "de", "Berlin"), ("Elif", "Demir", "f", "de", "Leipzig"),
("Felix", "Wagner", "m", "de", "Hamburg"), ("Sophie", "Müller", "f", "de", "München"),
("Javier", "García", "m", "es", "Madrid"), ("Lucía", "Fernández", "f", "es", "Sevilla"),
("Alejandro", "Martín", "m", "es", "Valencia"), ("Carmen", "Ruiz", "f", "es", "Madrid"),
("Mateo", "López", "m", "es", "Barcelona"), ("Valentina", "Torres", "f", "es", "Bilbao"),
("Youssef", "El Amrani", "m", "es", "Barcelona"), ("Nuria", "Puig", "f", "es", "Barcelona"),
("Hugo", "Martin", "m", "fr", "Paris"), ("Camille", "Dubois", "f", "fr", "Lyon"),
("Lucas", "Bernard", "m", "fr", "Nantes"), ("Chloé", "Lefèvre", "f", "fr", "Bordeaux"),
("Karim", "Benali", "m", "fr", "Lille"), ("Amina", "Diallo", "f", "fr", "Paris"),
("Théo", "Moreau", "m", "fr", "Lyon"), ("Léa", "Laurent", "f", "fr", "Nantes"),
]
print(len(PRODUCTS), "products,", len(CUSTOMERS), "customers,", len(INTENTS), "intents")
print("women:", sum(c[2] == "f" for c in CUSTOMERS), "men:", sum(c[2] == "m" for c in CUSTOMERS))
print({lang: sum(c[3] == lang for c in CUSTOMERS) for lang in ["en", "de", "es", "fr"]})
print((DATA_AS_OF - WINDOW[0]).days, "days from the first message day to DATA_AS_OF")Code walkthrough
- Block 1, unchanged: dates, products, labels, customers.
Output
14 products, 60 customers, 7 intents
women: 30 men: 30
{'en': 36, 'de': 8, 'es': 8, 'fr': 8}
182 days from the first message day to DATA_AS_OFimport random, re
# Templates: one dict per wording. Keys en, de, es, fr hold the same message in each language
# (English-only wordings have just "en"). {UPPER} slots become gold entities; {lower} slots do not.
T = {
"order_status": [
{"s": "neu", "en": "Hi, I placed an order on {DATE} (order {ORDER_ID}, item: {PRODUCT}) and it still says processing. When will it ship?",
"de": "Hallo, ich habe am {DATE} bestellt (Bestellung {ORDER_ID}, Artikel: {PRODUCT}) und der Status steht immer noch auf in Bearbeitung. Wann wird sie verschickt?",
"es": "Hola, hice un pedido el {DATE} (pedido {ORDER_ID}, artículo: {PRODUCT}) y sigue en preparación. ¿Cuándo lo enviaréis?",
"fr": "Bonjour, j'ai passé commande le {DATE} (commande {ORDER_ID}, article : {PRODUCT}) et elle est toujours en préparation. Quand sera-t-elle expédiée ?"},
{"s": "neg", "en": "Order {ORDER_ID} was due on {DATE} and nothing has arrived. The {carrier} tracking has not changed for three days. Where is my parcel ({PRODUCT})?",
"de": "Bestellung {ORDER_ID} sollte am {DATE} ankommen, aber es ist nichts da. Die Sendungsverfolgung von {carrier} hat sich seit drei Tagen nicht geändert. Wo ist mein Paket ({PRODUCT})?",
"es": "El pedido {ORDER_ID} tenía que llegar el {DATE} y no ha llegado nada. El seguimiento de {carrier} no cambia desde hace tres días. ¿Dónde está mi paquete ({PRODUCT})?",
"fr": "La commande {ORDER_ID} devait arriver le {DATE} et rien n'est arrivé. Le suivi {carrier} n'a pas bougé depuis trois jours. Où est mon colis ({PRODUCT}) ?"},
{"s": "neu", "en": "Could you send me the tracking link for order {ORDER_ID}? The confirmation email never came through to {email}.",
"de": "Könnten Sie mir den Tracking-Link für Bestellung {ORDER_ID} schicken? Die Bestätigung ist nie bei {email} angekommen.",
"es": "¿Me podéis enviar el enlace de seguimiento del pedido {ORDER_ID}? El correo de confirmación nunca llegó a {email}.",
"fr": "Pouvez-vous m'envoyer le lien de suivi de la commande {ORDER_ID} ? L'e-mail de confirmation n'est jamais arrivé sur {email}."},
{"s": "neg", "from": "2026-08-10", "w": 3, "en": "Third time {carrier} has said 'attempted delivery' when I was in all day. Order {ORDER_ID}. This never happened with your old courier."},
{"s": "neu", "en": "any update on {ORDER_ID}? need the {PRODUCT} before {DATE} if poss"},
],
"returns_refund": [
{"s": "neu", "en": "I'd like to return the {PRODUCT} from order {ORDER_ID}. It's unused and still in the box. How do I send it back?",
"de": "Ich möchte {PRODUCT} aus Bestellung {ORDER_ID} zurückgeben. Unbenutzt und noch originalverpackt. Wie schicke ich es zurück?",
"es": "Quiero devolver {PRODUCT} del pedido {ORDER_ID}. Está sin usar y en su caja. ¿Cómo lo envío?",
"fr": "Je voudrais retourner {PRODUCT} de la commande {ORDER_ID}. Il n'a pas servi et il est encore dans sa boîte. Comment le renvoyer ?"},
{"s": "neg", "en": "I sent the return for {ORDER_ID} back on {DATE} and still no refund of {MONEY}. How long does this take?",
"de": "Ich habe die Rücksendung für {ORDER_ID} am {DATE} abgeschickt und noch keine Erstattung über {MONEY} bekommen. Wie lange dauert das?",
"es": "Envié la devolución del pedido {ORDER_ID} el {DATE} y todavía no me habéis devuelto {MONEY}. ¿Cuánto tarda?",
"fr": "J'ai renvoyé la commande {ORDER_ID} le {DATE} et je n'ai toujours pas reçu le remboursement de {MONEY}. Combien de temps faut-il ?"},
{"s": "neu", "en": "The {KIDBIKE} is the wrong size for my daughter. Can I swap it or get a refund? Order {ORDER_ID}.",
"de": "{KIDBIKE} ist für meine Tochter die falsche Größe. Kann ich umtauschen oder bekomme ich das Geld zurück? Bestellung {ORDER_ID}.",
"es": "{KIDBIKE} es de una talla equivocada para mi hija. ¿Puedo cambiarlo o me devolvéis el dinero? Pedido {ORDER_ID}.",
"fr": "{KIDBIKE} n'est pas à la bonne taille pour ma fille. Puis-je l'échanger ou être remboursé ? Commande {ORDER_ID}."},
{"s": "neg", "en": "You charged me {MONEY} twice for order {ORDER_ID}. Please refund the duplicate payment."},
{"s": "neu", "en": "want to cancel order {ORDER_ID} before it ships, changed my mind about the {PRODUCT}. refund to my card pls"},
],
"repair_booking": [
{"s": "neu", "en": "Can I book a {SERVICE} for {DATE}? It's for my bike ({BIKE}). I can drop it at the workshop in the morning.",
"de": "Kann ich für den {DATE} einen Termin für {SERVICE} buchen? Es geht um mein Rad ({BIKE}). Ich kann es morgens in die Werkstatt bringen.",
"es": "¿Puedo reservar {SERVICE} para el {DATE}? Es para mi bici ({BIKE}). Puedo dejarla en el taller por la mañana.",
"fr": "Puis-je réserver {SERVICE} pour le {DATE} ? C'est pour mon vélo ({BIKE}). Je peux le déposer à l'atelier le matin."},
{"s": "neu", "en": "My bike ({BIKE}) needs a repair: {wear}. Can I bring it in or send it to you? I live in {LOC}.",
"de": "Mein Rad ({BIKE}) muss repariert werden: {wear}. Kann ich es vorbeibringen oder einschicken? Ich wohne in {LOC}.",
"es": "Mi bici ({BIKE}) necesita una reparación: {wear}. ¿Puedo llevarla o enviárosla? Vivo en {LOC}.",
"fr": "Mon vélo ({BIKE}) a besoin d'une réparation : {wear}. Puis-je l'apporter ou vous l'envoyer ? J'habite à {LOC}."},
{"s": "neu", "en": "Please can I move my {SERVICE} booking ({ticket}) from {DATE} to the following week? Something came up.",
"de": "Kann ich meinen Termin für {SERVICE} ({ticket}) vom {DATE} auf die Woche danach verschieben? Mir ist etwas dazwischengekommen.",
"es": "¿Puedo cambiar mi cita de {SERVICE} ({ticket}) del {DATE} a la semana siguiente? Me ha surgido algo.",
"fr": "Puis-je déplacer mon rendez-vous {SERVICE} ({ticket}) du {DATE} à la semaine suivante ? J'ai un empêchement."},
{"s": "neu", "en": "hiya, how much is a {SERVICE} and can u fit me in {DATE}? bike is a {BIKE}, cheers"},
{"s": "neu", "en": "I'd like to book the {SERVICE} for my {BIKE}. Is {MONEY} still the price? Any day after {DATE} works."},
],
"product_question": [
{"s": "neu", "en": "Does the {BIKE} come in a smaller frame? I'm 158 cm tall.",
"de": "Gibt es {BIKE} auch mit einem kleineren Rahmen? Ich bin 1,58 m groß.",
"es": "¿{BIKE} viene en una talla de cuadro más pequeña? Mido 1,58 m.",
"fr": "{BIKE} existe-t-il avec un cadre plus petit ? Je mesure 1,58 m."},
{"s": "neu", "en": "What range does the {EBIKE} get on one charge, and is the {ACCESSORY} compatible with it?",
"de": "Welche Reichweite hat {EBIKE} mit einer Akkuladung, und passt {ACCESSORY} dazu?",
"es": "¿Qué autonomía tiene {EBIKE} con una carga, y es compatible con {ACCESSORY}?",
"fr": "Quelle autonomie a {EBIKE} avec une charge, et {ACCESSORY} est-il compatible ?"},
{"s": "neu", "en": "Is the {PRODUCT} in stock? The website says {MONEY} but I can't add it to the basket.",
"de": "Ist {PRODUCT} auf Lager? Auf der Website steht {MONEY}, aber ich kann es nicht in den Warenkorb legen.",
"es": "¿Tenéis {PRODUCT} en stock? En la web pone {MONEY} pero no puedo añadirlo a la cesta.",
"fr": "Est-ce que {PRODUCT} est en stock ? Le site indique {MONEY} mais je n'arrive pas à l'ajouter au panier."},
{"s": "neu", "en": "Do you deliver to {LOC}? And do bikes arrive fully built or do I need to assemble them?"},
{"s": "neu", "en": "quick q: whats the difference between the {SERVICE} and the {SERVICE2}?"},
],
"warranty_fault": [
{"s": "neg", "en": "My e-bike ({EBIKE}) is only four months old and {fault}. Is this covered by the warranty?",
"de": "Mein E-Bike ({EBIKE}) ist erst vier Monate alt und {fault}. Ist das von der Garantie abgedeckt?",
"es": "Mi bici eléctrica ({EBIKE}) solo tiene cuatro meses y {fault}. ¿Lo cubre la garantía?",
"fr": "Mon vélo électrique ({EBIKE}) n'a que quatre mois et {fault}. Est-ce couvert par la garantie ?"},
{"s": "neg", "from": "2026-06-09", "w": 4, "en": "I got your email about battery batch {batch}. My pack has that code and {fault}. What happens now?",
"de": "Ich habe Ihre E-Mail zur Akku-Charge {batch} bekommen. Mein Akku hat diesen Code und {fault}. Wie geht es jetzt weiter?",
"es": "Recibí vuestro correo sobre el lote de baterías {batch}. Mi batería tiene ese código y {fault}. ¿Qué hago ahora?",
"fr": "J'ai reçu votre e-mail sur le lot de batteries {batch}. Ma batterie porte ce code et {fault}. Que se passe-t-il maintenant ?"},
{"s": "neg", "en": "The {PRODUCT} arrived with {damage}. I've attached photos. Can you send a replacement?",
"de": "{PRODUCT} kam mit {damage} an. Fotos sind im Anhang. Können Sie Ersatz schicken?",
"es": "{PRODUCT} llegó con {damage}. Adjunto fotos. ¿Podéis enviarme uno nuevo?",
"fr": "{PRODUCT} est arrivé avec {damage}. Je joins des photos. Pouvez-vous m'en envoyer un autre ?"},
{"s": "neg", "en": "Spoke snapped on my {BIKE} after two rides. That's not normal wear, surely? Bought {DATE}, order {ORDER_ID}."},
{"s": "neg", "en": "{EBIKE} battery dead again. Second pack in a month. I want this sorted properly this time."},
],
"praise": [
{"s": "pos", "en": "Just wanted to say thank you to {PERSON} in the workshop. My bike ({BIKE}) rides like new after the {SERVICE}.",
"de": "Ich wollte mich nur bei {PERSON} in der Werkstatt bedanken. Mein Rad ({BIKE}) fährt sich nach {SERVICE} wie neu.",
"es": "Solo quería dar las gracias a {PERSON} del taller. Mi bici ({BIKE}) va como nueva después de {SERVICE}.",
"fr": "Je voulais juste remercier {PERSON} de l'atelier. Mon vélo ({BIKE}) roule comme neuf après {SERVICE}."},
{"s": "pos", "en": "Love my new bike ({BIKE}). Fast delivery to {LOC} and it was easy to set up. Five stars.",
"de": "Ich liebe mein neues Rad ({BIKE}). Schnelle Lieferung nach {LOC} und leicht aufzubauen. Fünf Sterne.",
"es": "Me encanta mi nueva bici ({BIKE}). Entrega rápida a {LOC} y fácil de montar. Cinco estrellas.",
"fr": "J'adore mon nouveau vélo ({BIKE}). Livraison rapide à {LOC} et facile à monter. Cinq étoiles."},
{"s": "pos", "en": "Great service from {PERSON}, who sorted my problem in one email. Thank you!",
"de": "Super Service von {PERSON}, das Problem war mit einer E-Mail gelöst. Vielen Dank!",
"es": "Un servicio genial de {PERSON}, que me lo resolvió en un solo correo. ¡Gracias!",
"fr": "Super service de la part de {PERSON}, qui a réglé mon problème en un seul e-mail. Merci !"},
{"s": "pos", "en": "the {ACCESSORY} is brilliant, fits perfectly. cheers"},
{"s": "pos", "en": "Honestly the best bike shop in {LOC}. Friendly, quick and fair prices."},
],
"spam": [
{"s": "neu", "en": "Dear partner, we offer guaranteed first-page search ranking for your online store. Reply today for a {MONEY} discount."},
{"s": "neu", "en": "Exclusive partnership opportunity: invest in our crypto bike-sharing token and earn 300% returns. Contact {email} now."},
{"s": "neu", "en": "Your mailbox storage is full. Verify your account within 24 hours at http://mail-verify.example/login to avoid suspension."},
{"s": "neu", "en": "Hello, I am an influencer with 50k followers. Send me a free {BIKE} and I will post about your brand."},
{"s": "neu", "en": "Congratulations! You have been selected for a {MONEY} gift voucher. Click http://prize-claim.example to claim."},
],
}
PHRASES = { # paired fillers for the {lower} slots: (en, de, es, fr)
"fault": [("the battery will not charge", "der Akku lädt nicht mehr", "la batería no carga", "la batterie ne se charge plus"),
("the motor cuts out on hills", "der Motor setzt am Berg aus", "el motor se para en las cuestas", "le moteur se coupe dans les montées"),
("the display shows error E07", "das Display zeigt Fehler E07", "la pantalla muestra el error E07", "l'écran affiche l'erreur E07"),
("the battery runs flat very quickly", "der Akku ist sehr schnell leer", "la batería se agota muy rápido", "la batterie se vide très vite")],
"wear": [("the back tyre has a puncture", "der Hinterreifen hat einen Platten", "la rueda trasera está pinchada", "le pneu arrière est crevé"),
("the gears keep slipping", "die Gangschaltung springt ständig", "los cambios saltan todo el rato", "les vitesses sautent sans arrêt"),
("the brake pads are worn out", "die Bremsbeläge sind abgenutzt", "las pastillas de freno están gastadas", "les plaquettes de frein sont usées"),
("the chain squeaks", "die Kette quietscht", "la cadena chirría", "la chaîne grince")],
"damage": [("a cracked mudguard", "einem gebrochenen Schutzblech", "un guardabarros roto", "un garde-boue fendu"),
("a bent front wheel", "einem verbogenen Vorderrad", "la rueda delantera doblada", "une roue avant voilée"),
("a broken clip", "einem kaputten Clip", "un clip roto", "un clip cassé")],
}
SUBJECTS = {"order_status": ["Where is my order?", "Wo bleibt meine Bestellung?", "¿Dónde está mi pedido?", "Où est ma commande ?"],
"returns_refund": ["Return and refund", "Rücksendung", "Devolución", "Retour"],
"repair_booking": ["Booking a service", "Werkstatttermin", "Cita en el taller", "Rendez-vous atelier"],
"product_question": ["Product question", "Frage zum Produkt", "Pregunta sobre un producto", "Question sur un produit"],
"warranty_fault": ["Fault with my bike", "Defekt", "Avería", "Panne"],
"praise": ["Thank you!", "Danke!", "¡Gracias!", "Merci !"],
"spam": ["Partnership opportunity"] * 4}
FORM_TOPIC = {"order_status": "Order enquiry", "returns_refund": "Returns", "repair_booking": "Workshop",
"product_question": "Product question", "warranty_fault": "Faulty item", "praise": "Feedback", "spam": "Other"}
CHANNELS = {"order_status": (5, 4, 1), "returns_refund": (6, 4, 0), "repair_booking": (4, 6, 0), # email, web form, review
"product_question": (4, 6, 0), "warranty_fault": (5, 3, 2), "praise": (3, 1, 6), "spam": (1, 0, 0)}
LANGS = ["en", "de", "es", "fr"]
MONTHS = {"en": "January February March April May June July August September October November December",
"de": "Januar Februar März April Mai Juni Juli August September Oktober November Dezember",
"es": "enero febrero marzo abril mayo junio julio agosto septiembre octubre noviembre diciembre",
"fr": "janvier février mars avril mai juin juillet août septembre octobre novembre décembre"}
TOWNS = {"en": ["Bristol", "Bath", "Clifton", "Cardiff", "Stroud"], "de": ["Berlin", "Hamburg", "München", "Köln"],
"es": ["Madrid", "Barcelona", "Valencia", "Sevilla"], "fr": ["Paris", "Lyon", "Nantes", "Lille"]}
SPAMMERS = [("Growth Partners", "offers@growth-partners.example"), ("Crypto Ride", "team@cryptoride.example"),
("Mail Admin", "admin@mail-verify.example"), ("Prize Desk", "win@prize-claim.example")]
def fmt_date(d, lang, rng):
m = MONTHS[lang].split()[d.month - 1]
styles = {"en": [f"{d.day} {m}", f"{d.day:02d}/{d.month:02d}", f"{m} {d.day}{'th' if 10 < d.day < 14 else {1: 'st', 2: 'nd', 3: 'rd'}.get(d.day % 10, 'th')}"],
"de": [f"{d.day}. {m}", f"{d.day:02d}.{d.month:02d}."], "es": [f"{d.day} de {m}", f"{d.day}/{d.month}"],
"fr": [f"{d.day} {m}", f"{d.day:02d}/{d.month:02d}/{d.year}"]}[lang]
return rng.choice(styles) if rng else styles[0]
def fmt_money(gbp, lang, rng):
styles = {"en": [f"£{gbp:,}", f"£{gbp:,}.00", f"{gbp} pounds"], "de": [f"{gbp:,} £".replace(",", "."), f"{gbp} Pfund"],
"es": [f"{gbp:,} libras".replace(",", "."), f"{gbp} £"], "fr": [f"{gbp:,} £".replace(",", " "), f"{gbp} livres"]}[lang]
return rng.choice(styles) if rng else styles[0]
def typo(word, rng):
i = rng.randrange(1, len(word) - 1)
return rng.choice([word[:i] + word[i + 1] + word[i] + word[i + 2:], word[:i] + word[i + 1:], word[:i] + word[i] + word[i:]])
print(sum(len(v) for v in T.values()), "wordings,", sum("de" in t for v in T.values() for t in v), "of them in all four languages")Code walkthrough
- Block 2, unchanged: the message wordings.
Output
35 wordings, 18 of them in all four languagesimport html, unicodedata
POOLS = {"PRODUCT": ["bike", "ebike", "accessory"], "BIKE": ["bike", "ebike"], "EBIKE": ["ebike"],
"ACCESSORY": ["accessory"], "SERVICE": ["service"], "SERVICE2": ["service"], "KIDBIKE": ["kids"]}
EXTRA = {"greet": {"en": ["Hi,", "Hello,", "Hello Tidewell,", "Dear Sir or Madam,"], "de": ["Hallo,", "Guten Tag,"],
"es": ["Hola,", "Buenos días,"], "fr": ["Bonjour,", "Madame, Monsieur,"]},
"sign": {"en": ["Thanks,", "Many thanks,", "Cheers,"], "de": ["Danke,", "Viele Grüße,"],
"es": ["Gracias,", "Un saludo,"], "fr": ["Merci,", "Cordialement,"]},
"phone": {"en": "You can call me on ", "de": "Sie erreichen mich unter ", "es": "Me podéis llamar al ",
"fr": "Vous pouvez m'appeler au "},
"quote": {"en": ("On ", ", Tidewell Cycles <help@tidewell.example> wrote:"), "de": ("Am ", " schrieb Tidewell Cycles <help@tidewell.example>:"),
"es": ("El ", ", Tidewell Cycles <help@tidewell.example> escribió:"), "fr": ("Le ", ", Tidewell Cycles <help@tidewell.example> a écrit :")},
"again": {"en": "Sorry, sending this again as I have not heard back. ", "de": "Entschuldigung, ich schicke das noch einmal. ",
"es": "Perdón, lo envío otra vez porque no he recibido respuesta. ", "fr": "Désolé, je renvoie ce message car je n'ai pas eu de réponse. "}}
LABEL_OF = {"PRODUCT": "PRODUCT", "BIKE": "PRODUCT", "EBIKE": "PRODUCT", "ACCESSORY": "PRODUCT", "SERVICE": "PRODUCT",
"SERVICE2": "PRODUCT", "KIDBIKE": "PRODUCT", "ORDER_ID": "ORDER_ID", "DATE": "DATE", "MONEY": "MONEY",
"LOC": "LOC", "PERSON": "PERSON"}
def fill(template, values, lang, rng):
"""Template text -> list of (text, label or None) pieces, formatting dates and money for `lang`."""
pieces = []
for part in re.split(r"(\{\w+\})", template):
if not (part.startswith("{") and part.endswith("}")):
pieces.append((part, None)) if part else None
continue
slot = part[1:-1]
v = values[slot]
if slot == "DATE":
v = fmt_date(v, lang, rng)
elif slot == "MONEY":
v = fmt_money(v, lang, rng)
elif slot in ("fault", "wear", "damage"):
v = PHRASES[slot][v][LANGS.index(lang)]
pieces.append((v, LABEL_OF.get(slot)))
return pieces
def email_of(first, last):
"""'Mehmet', 'Yılmaz' -> 'mehmet.yilmaz@...'; one fixed address per customer, on a reserved .example domain."""
plain = unicodedata.normalize("NFKD", f"{first}.{last}".lower().replace("ı", "i").replace(" ", "").replace("'", ""))
plain = "".join(ch for ch in plain if ch.isascii())
return plain + "@" + ["post.example", "mailbox.example", "inbox.example"][len(first + last) % 3]
def make_messages(n=1800, seed=SEED):
rng = random.Random(seed)
days = [WINDOW[0] + timedelta(i) for i in range((WINDOW[1] - WINDOW[0]).days + 1)]
base = {"order_status": 26, "returns_refund": 16, "repair_booking": 17, "product_question": 18,
"warranty_fault": 11, "praise": 6, "spam": 6}
def weights(d):
w = dict(base)
w["spam"] *= 8 if EVENTS["spam_wave"][0] <= d <= EVENTS["spam_wave"][1] else 1
w["warranty_fault"] *= 3.5 if EVENTS["battery_notice"] <= d <= date(2026, 7, 31) else 2 if d.month == 8 else 1
w["order_status"] *= (2.5 if EVENTS["summer_sale"][0] <= d <= EVENTS["summer_sale"][1] else 1) * (1.6 if d >= EVENTS["carrier_change"] else 1)
w["returns_refund"] *= 1.3 if date(2026, 7, 29) <= d <= date(2026, 8, 15) else 1
vol = (0.6 if d.weekday() >= 5 else 1.0) * (1.5 if d >= EVENTS["eu_shipping"] else 1.0)
return [w[i] * vol for i in INTENTS]
W = [weights(d) for d in days]
n_dup = round(n * 0.03)
picks = sorted(rng.choices(range(len(days)), weights=[sum(w) for w in W], k=n - n_dup))
by_kind = {k: [p for p in PRODUCTS if p[3] == k] for k in ["bike", "ebike", "accessory", "service"]}
by_kind["kids"] = [p for p in PRODUCTS if p[1] == "Harbour Kids 20"]
out = []
for di in picks:
d = days[di]
intent = rng.choices(INTENTS, weights=W[di])[0]
lang = "en" if intent == "spam" else rng.choices(LANGS, weights=[95, 2, 1.5, 1.5] if d < EVENTS["eu_shipping"] else [58, 16, 14, 12])[0]
options = [t for t in T[intent] if lang in t and date.fromisoformat(t.get("from", "2026-01-01")) <= d]
tmpl = rng.choices(options, weights=[t.get("w", 1) for t in options])[0]
if intent == "spam":
cust, email = rng.choice(SPAMMERS)
first, last, gender, town = cust, "", None, None
else:
first, last, gender, _, town = rng.choice([c for c in CUSTOMERS if c[3] == lang])
email = email_of(first, last)
v = {}
for slot, kinds in POOLS.items():
pool = [p for k in kinds for p in by_kind[k]]
pick = rng.choice([p for p in pool if p[1] != v.get("SERVICE")])
v[slot] = pick[1]
v[slot + "_sku"], v[slot + "_price"] = pick[0], pick[2]
main = next((s for s in POOLS if "{" + s + "}" in tmpl["en"]), None)
future = intent == "repair_booking" or "before {DATE}" in tmpl["en"]
v["DATE"] = d + timedelta(rng.randint(2, 14)) if future else d - timedelta(rng.randint(3, 20))
v["MONEY"] = v[main + "_price"] if main else rng.choice([50, 100, 250, 500])
v["ORDER_ID"] = f"TW-{100000 + di * 120 + rng.randrange(120)}"
lives_there = "live in {LOC}" in tmpl["en"] or "shop in {LOC}" in tmpl["en"]
v["LOC"] = town if lives_there and town else rng.choice(TOWNS[lang])
v["PERSON"] = rng.choice(["Dev", "Dev", "Ines", "Callum"]) if intent == "praise" else first
v.update(carrier="SwiftDrop" if d >= EVENTS["carrier_change"] else "ParcelNorth", email=email, batch=BATCH,
ticket=f"RJ-2026-{300 + di * 2 + rng.randrange(3):04d}", fault=rng.randrange(4), wear=rng.randrange(4),
damage=rng.randrange(3))
core = fill(tmpl[lang], v, lang, rng)
en_ref = "".join(t for t, _ in fill(tmpl["en"], v, "en", None)) if lang != "en" else None
channel = rng.choices(["email", "web form", "review"], weights=CHANNELS[intent])[0]
channel = "web form" if channel == "review" and tmpl["s"] == "neu" else channel # reviews praise or complain
pieces, g = list(core), 0
if channel == "email":
if rng.random() < 0.7:
pieces, g = [(rng.choice(EXTRA["greet"][lang]) + "\n\n", None)] + pieces, 1
if rng.random() < 0.15:
pieces += [("\n" + EXTRA["phone"][lang] + rng.choice(["07700 900", "07700900", "+44 7700 900"]) + f"{rng.randrange(1000):03d}.", None)]
if lang == "en" and intent in ("repair_booking", "order_status") and rng.random() < 0.3:
pieces += [(f"\nMy postcode is BS{rng.randint(1, 16)} {rng.randint(1, 9)}{rng.choice('ABDEFGHJLNPQRSTUWXYZ')}{rng.choice('ABDEFGHJLNPQRSTUWXYZ')}.", None)]
if rng.random() < 0.7 and intent != "spam":
pieces += [("\n\n" + rng.choice(EXTRA["sign"][lang]) + "\n", None), (first if rng.random() < 0.6 else f"{first} {last}", "PERSON")]
if rng.random() < 0.15:
pieces += [("\n" + {"en": "Sent from my phone", "de": "Von meinem Handy gesendet", "es": "Enviado desde mi móvil", "fr": "Envoyé de mon téléphone"}[lang], None)]
if rng.random() < 0.25 and intent != "spam":
q = EXTRA["quote"][lang]
prev = fmt_date(d - timedelta(rng.randint(1, 6)), lang, None) + (" de 2026" if lang == "es" else " 2026")
pieces += [("\n\n> " + q[0], None), (prev, "DATE"), (q[1] + "\n> Thanks for your message. We will reply within one working day.", None)]
r = rng.random
if r() < 0.15: # typos
for _ in range(rng.randint(1, 2)):
i = rng.randrange(len(pieces))
if pieces[i][1] is None:
words = pieces[i][0].split(" ")
j = rng.randrange(len(words))
if len(words[j]) >= 5 and words[j].isalpha():
words[j] = typo(words[j], rng)
pieces[i] = (" ".join(words), None)
case = r()
if case < 0.03:
pieces = [(t.upper(), l) for t, l in pieces]
elif case < 0.09:
pieces = [(t.lower(), l) for t, l in pieces]
if r() < 0.08: # emoji after the main text
pieces.insert(g + len(core), (" " + rng.choice(["😡", "😤", "🚲"] if tmpl["s"] == "neg" else ["🙂", "👍", "🚲"]), None))
if r() < 0.05: # odd whitespace
i = next((i for i, (t, l) in enumerate(pieces) if l is None and " " in t), None)
if i is not None:
t = pieces[i][0]
pieces[i] = (t.replace(" ", " ", 1) if r() < 0.5 else t.replace(" ", " ", 1), None)
if channel == "web form" and r() < 0.6: # HTML from the form
pieces = [("<p>", None)] + [(html.escape(t).replace("\n", "<br>\n"), None) if l is None else (t, l) for t, l in pieces] + [("</p>", None)]
if r() < 0.04: # mojibake: UTF-8 bytes read as cp1252
try:
pieces = [(t.encode("utf-8").decode("cp1252"), l) for t, l in pieces]
except UnicodeDecodeError:
pass
body, spans, pos = "", [], 0
for t, l in pieces:
if l:
spans.append((pos, pos + len(t), l))
body, pos = body + t, pos + len(t)
sent = tmpl["s"]
stars = None
if channel == "review":
stars = {"pos": rng.choice([4, 5]), "neu": 3, "neg": rng.choice([1, 2])}[sent]
if r() < 0.08:
stars = {"pos": rng.choice([1, 2]), "neu": rng.choice([1, 5]), "neg": 5}[sent]
subject = {"email": SUBJECTS[intent][LANGS.index(lang)], "web form": "Web form: " + FORM_TOPIC[intent],
"review": "Review: " + (v[main] if main else "Tidewell Cycles")}[channel]
out.append({"date": d.isoformat(), "channel": channel, "language": lang, "customer": f"{first} {last}".strip(),
"gender": gender, "email": email, "subject": subject, "body": body, "intent": intent, "sentiment": sent,
"stars": stars, "entities": spans, "sku": v[main + "_sku"] if main else None,
"order_id": v["ORDER_ID"] if "{ORDER_ID}" in tmpl["en"] else None, "duplicate_of": None,
"core": "".join(t for t, _ in core), "en_ref": en_ref, "label_flipped": False})
originals = [m for m in out if m["intent"] != "spam"]
for m in rng.sample(originals, n_dup): # near-duplicates: the same customer writes again
again, cut = EXTRA["again"][m["language"]], 3 if m["body"].startswith("<p>") else 0 # inside the form's HTML
dup = dict(m, body=m["body"][:cut] + again + m["body"][cut:], entities=[(a + len(again), b + len(again), l) for a, b, l in m["entities"]],
date=min(date.fromisoformat(m["date"]) + timedelta(rng.randint(0, 2)), WINDOW[1]).isoformat(), duplicate_of=m)
out.append(dup)
out.sort(key=lambda m: (m["date"], m["duplicate_of"] is not None))
for i, m in enumerate(out, 1):
m["id"] = f"TW-M{i:04d}"
for m in out:
if m["duplicate_of"] is not None:
m["duplicate_of"] = m["duplicate_of"]["id"]
for m in rng.sample(out, round(n * 0.02)): # deliberate wrong labels, for error analysis
m["intent"], m["label_flipped"] = rng.choice([i for i in INTENTS if i != m["intent"]]), True
split_of = {}
for intent in INTENTS: # stratified 70 / 15 / 15, duplicates follow their original
ids = [m["id"] for m in out if m["intent"] == intent and m["duplicate_of"] is None]
rng.shuffle(ids)
a, b = round(len(ids) * 0.70), round(len(ids) * 0.85)
split_of.update({x: "train" if k < a else "dev" if k < b else "test" for k, x in enumerate(ids)})
for m in out:
m["split"] = split_of[m["duplicate_of"] or m["id"]]
m["period"] = "before_july" if m["date"] < "2026-07-01" else "from_july"
return out
MESSAGES = make_messages()
print(len(MESSAGES), "messages;", MESSAGES[0]["date"], "to", MESSAGES[-1]["date"])
print(MESSAGES[0]["id"], "|", MESSAGES[0]["subject"], "|", MESSAGES[0]["body"][:80].replace("\n", " "))Code walkthrough
- Block 3, unchanged: the generator of the 1,800 simulated
MESSAGES.
Output
1800 messages; 2026-03-02 to 2026-08-30
TW-M0001 | Web form: Workshop | <p>Mon vélo (Causeway) a besoin d'une réparation : les plaquettes de freiimport re
def unmark(marked):
"""'[[Volt 2|PRODUCT]] died' -> ('Volt 2 died', [(0, 6, 'PRODUCT')])."""
text, spans, pos = "", [], 0
for m in re.finditer(r"\[\[(.+?)\|([A-Z_]+)\]\]|([^\[]+|\[)", marked):
if m.group(1):
spans.append((len(text), len(text) + len(m.group(1)), m.group(2)))
text += m.group(1)
else:
text += m.group(3)
return text, spans
# (language, kind, intent, second intent, sentiment, text with [[entity|LABEL]] marks, English reference or None)
HARD = [
("en", "sarcasm", "warranty_fault", None, "neg", "Oh brilliant, the [[Volt 2|PRODUCT]] battery died halfway up the hill again. Really loving my [[£2,199|MONEY]] paperweight.", None),
("en", "two_intents", "order_status", "returns_refund", "neu", "Two things: where is order [[TW-118734|ORDER_ID]]? And I want to send back the [[LockSure D-lock|PRODUCT]] from my last order, it's too heavy.", None),
("en", "very_short", "returns_refund", None, "neg", "refund??", None),
("en", "very_short", "order_status", None, "neu", "where's my bike", None),
("en", "slang", "repair_booking", None, "neu", "yo the gears on my [[Causeway|PRODUCT]] are proper knackered, can u lot sort it [[sat|DATE]]?", None),
("en", "plain", "praise", None, "pos", "[[Dev|PERSON]] fixed my wheel in ten minutes while I waited. Legend.", None),
("en", "sarcasm", "order_status", None, "neg", "Great to see my parcel has been 'out for delivery' for six days. Very scenic route, I assume. Order [[TW-121090|ORDER_ID]].", None),
("en", "negation", "product_question", None, "neu", "I don't want the [[Volt 2|PRODUCT]], I want something without a battery for my commute. Is the [[Harbour|PRODUCT]] any good on hills?", None),
("en", "plain", "warranty_fault", None, "neg", "The display on my [[Volt 2 Step-Through|PRODUCT]] keeps showing E07 and the motor won't engage. Bought it on [[14 March|DATE]].", None),
("en", "plain", "spam", None, "neu", "Hi [[Rosa|PERSON]], loved your shop! We help bike brands get 10x more reviews. Can I send over a quick proposal?", None),
("en", "very_long", "warranty_fault", None, "neu", "Hello, I hope you are well. I'm writing about the email you sent on [[9 June|DATE]] about battery batch VB-0426. I checked the label under my pack as the email said, and it does say VB-0426. To be honest the battery had been fine until about a fortnight ago, when it started dropping from 80% to nothing in a couple of miles. I use the bike every day to take my son to school and then ride on to work, so I can't really be without it for long. Could you tell me how the swap works, whether I need to bring the whole bike in or just the battery, and roughly how long it takes? My order number was [[TW-103552|ORDER_ID]] if that helps. Many thanks, [[Margaret Ellis|PERSON]]", None),
("en", "codes", "product_question", None, "neu", "is TW-BATT-500 the same pack as the one in the [[Volt 2|PRODUCT]] or a newer version?", None),
("en", "plain", "returns_refund", None, "neu", "I'd like to return the [[Commuter Pannier Set|PRODUCT]]. The clips don't fit my rack. Order [[TW-115902|ORDER_ID]].", None),
("en", "plain", "repair_booking", None, "neu", "Can I bring my [[Harbour Kids 20|PRODUCT]] in for a [[Basic Service|PRODUCT]] next week? Any day but [[Wednesday|DATE]].", None),
("en", "mixed", "praise", None, "neu", "Bike is lovely, delivery was a nightmare. Overall happy though, thanks [[Ines|PERSON]].", None),
("en", "slang", "order_status", None, "neg", "any news on my order mate? [[TW-122417|ORDER_ID]]. been ages", None),
("en", "plain", "spam", None, "neu", "Your Tidewell Cycles account has been suspended. Confirm your payment details at tidewell-secure.example within 24 hours.", None),
("en", "very_short", "warranty_fault", None, "neg", "battery dead. again.", None),
("en", "plain", "repair_booking", None, "neu", "Is the [[Full Service|PRODUCT]] still [[£129|MONEY]]? If so I'll book it for the [[2nd of September|DATE]].", None),
("en", "plain", "product_question", None, "neu", "Will a [[LockSure D-lock|PRODUCT]] fit the frame of the [[Causeway|PRODUCT]] or is the tube too fat?", None),
("en", "sarcasm", "returns_refund", None, "neg", "Thanks so much for refunding me in 'five working days'. It's been fifteen. Order [[TW-112006|ORDER_ID]], [[£89|MONEY]].", None),
("en", "two_intents", "warranty_fault", "repair_booking", "neg", "My [[Volt 2|PRODUCT]] motor is cutting out, and while it's in could you also do a [[Basic Service|PRODUCT]]?", None),
("en", "plain", "praise", None, "pos", "Five stars for the [[Harbour|PRODUCT]]. Comfy, fast enough, and the bell is very loud. Ha.", None),
("en", "plain", "order_status", None, "neg", "Ordered on [[3/7|DATE]], still waiting on [[TW-119880|ORDER_ID]]. SwiftDrop says 'exception'. What does that mean?", None),
("en", "plain", "product_question", None, "neu", "Do you price match? Saw the [[Volt 2|PRODUCT]] cheaper elsewhere.", None),
("en", "sarcasm", "order_status", None, "neg", "Love how SwiftDrop left my new bike in the bin store. Really secure. Order [[TW-124118|ORDER_ID]].", None),
("en", "very_long", "returns_refund", None, "neu", "Hello, I bought the [[Harbour Kids 20|PRODUCT]] for my nephew's birthday on [[18 July|DATE]] during your summer sale and paid [[£279|MONEY]]. Unfortunately my sister had already bought him a bike, which nobody told me about until the party. The bike is still in its box, completely unused, and I have the receipt. I understand that sale items can sometimes be treated differently, so I wanted to check before I post it back whether I can still get a full refund, and whether you send a returns label or I need to arrange the courier myself. I would also like to know how long the refund normally takes to appear on my card. Thank you for your help. [[Peter Lowe|PERSON]]", None),
("en", "slang", "repair_booking", None, "neu", "brakes squealing like a banshee lol. book me in?", None),
("en", "plain", "spam", None, "neu", "We are a logistics company offering cheap shipping from overseas factories. Minimum order 500 units. Message us for prices.", None),
("en", "plain", "warranty_fault", None, "neg", "Box arrived soaked and the [[Causeway|PRODUCT]] rear mech is bent. Photos attached.", None),
("en", "very_short", "product_question", None, "neu", "kids helmets?", None),
("en", "plain", "praise", None, "pos", "Shout out to [[Callum|PERSON]] for talking me out of the expensive model. Saved me [[£500|MONEY]] and I love the bike.", None),
("en", "two_intents", "order_status", "product_question", "neu", "Has order [[TW-120577|ORDER_ID]] shipped yet? Also, does the [[Beacon Light Set|PRODUCT]] come with batteries?", None),
("en", "negation", "repair_booking", None, "neu", "This is not a complaint, the bike is great, I just need to know when my [[Full Service|PRODUCT]] slot is.", None),
("en", "very_short", "returns_refund", None, "neg", "Charged twice. [[£45|MONEY]] and [[£45|MONEY]]. Fix it.", None),
("en", "sarcasm", "warranty_fault", None, "neg", "Fourth puncture this month on the 'puncture resistant' tyres. Impressive.", None),
("en", "plain", "spam", None, "neu", "Earn passive income from your bikes! Tokenise your fleet with BikeCoin today.", None),
("en", "plain", "praise", None, "pos", "cheers for sorting the refund so quick, really appreciate it", None),
("en", "very_long", "order_status", None, "neg", "Hi there, I'm getting a bit worried about my order [[TW-123390|ORDER_ID]]. I ordered the [[Volt 2 Step-Through|PRODUCT]] on [[22 July|DATE]] in the sale, and the confirmation said it would arrive within five working days. On [[12 August|DATE]] I got a text from a courier I've never heard of, SwiftDrop, saying they had tried to deliver it, but I was at home all day and nobody knocked. Since then the tracking page just says 'awaiting next attempt'. I've tried calling the number on the text but it goes to a recorded message. Could you check with them what is going on and when I can expect it? I need it for my new job, which starts on [[1 September|DATE]]. Thanks, [[Nadia Hussein|PERSON]]", None),
("en", "slang", "repair_booking", None, "neu", "can i get a puncture fixed same day if i drop it in before 9?", None),
("de", "plain", "order_status", None, "neg", "Meine Bestellung [[TW-121554|ORDER_ID]] ist seit einer Woche unterwegs. Wo bleibt sie?", "My order TW-121554 has been on its way for a week. Where is it?"),
("de", "plain", "warranty_fault", None, "neg", "Der Akku von meinem [[Volt 2|PRODUCT]] lädt nicht mehr. Das Rad habe ich erst seit [[Juli|DATE]].", "The battery on my Volt 2 no longer charges. I have only had the bike since July."),
("de", "sarcasm", "order_status", None, "neg", "Toll, drei Zustellversuche und ich war jedes Mal zu Hause. Super Service von SwiftDrop.", "Great, three delivery attempts and I was at home every time. Super service from SwiftDrop."),
("de", "plain", "returns_refund", None, "neu", "Ich möchte das [[Commuter Pannier Set|PRODUCT]] zurückschicken. Wie bekomme ich ein Rücksendeetikett?", "I would like to send back the Commuter Pannier Set. How do I get a returns label?"),
("de", "plain", "product_question", None, "neu", "Kann man das [[Volt 2|PRODUCT]] auch in [[Deutschland|LOC]] reparieren lassen, oder muss es nach [[Bristol|LOC]]?", "Can the Volt 2 also be repaired in Germany, or does it have to go to Bristol?"),
("de", "code_switch", "repair_booking", None, "neu", "Hallo, mein [[Causeway|PRODUCT]] hat einen flat tyre, kann ich das bei euch fixen lassen oder soll ich es selbst machen?", "Hello, my Causeway has a flat tyre, can I get it fixed by you or should I do it myself?"),
("de", "plain", "praise", None, "pos", "Vielen Dank, das Rad ist heute angekommen und es ist wunderschön!", "Thank you very much, the bike arrived today and it is beautiful!"),
("de", "two_intents", "order_status", "warranty_fault", "neg", "Wo ist meine Bestellung [[TW-122960|ORDER_ID]]? Und ich will das [[LockSure D-lock|PRODUCT]] umtauschen, der Schlüssel klemmt.", "Where is my order TW-122960? And I want to exchange the LockSure D-lock, the key sticks."),
("de", "very_short", "order_status", None, "neu", "Rechnung?", "Invoice?"),
("de", "plain", "repair_booking", None, "neu", "Können Sie mein E-Bike am [[Samstag|DATE]] durchsehen? Die Bremsen schleifen.", "Can you look over my e-bike on Saturday? The brakes are rubbing."),
("de", "very_long", "warranty_fault", None, "neu", "Guten Tag, ich habe am [[14. Juli|DATE]] ein [[Volt 2 Step-Through|PRODUCT]] bei Ihnen gekauft. Seit gestern zeigt das Display den Fehler E07 und der Motor unterstützt nicht mehr. Ich habe den Akku vollständig geladen und das Rad neu gestartet, aber nichts hat geholfen. Bitte teilen Sie mir mit, wie die Garantie funktioniert und ob ich das Rad nach [[Großbritannien|LOC]] schicken muss. Mit freundlichen Grüßen, [[Sabine Krause|PERSON]]", "Good afternoon, on 14 July I bought a Volt 2 Step-Through from you. Since yesterday the display has shown error E07 and the motor no longer assists. I have fully charged the battery and restarted the bike, but nothing has helped. Please tell me how the warranty works and whether I have to send the bike to Great Britain. Kind regards, Sabine Krause"),
("de", "plain", "spam", None, "neu", "Sehr geehrte Damen und Herren, wir bieten Ihnen günstige Werbung auf 500 Fahrrad-Blogs an. Interesse?", "Dear Sir or Madam, we offer you cheap advertising on 500 cycling blogs. Interested?"),
("de", "slang", "warranty_fault", None, "neg", "Moin, Rad is da, aber der Lenker ist total schief montiert. Was nu?", "Hi, the bike is here, but the handlebars are fitted completely crooked. What now?"),
("de", "sarcasm", "returns_refund", None, "neg", "Super, die Erstattung von [[549 £|MONEY]] dauert jetzt schon drei Wochen. Ganz toll.", "Great, the refund of £549 has now taken three weeks. Just wonderful."),
("es", "plain", "order_status", None, "neu", "Hola, ¿cuándo llega mi pedido [[TW-121871|ORDER_ID]]? Ya ha pasado una semana.", "Hello, when will my order TW-121871 arrive? A week has already gone by."),
("es", "plain", "warranty_fault", None, "neg", "La batería de mi [[Volt 2|PRODUCT]] se agota en diez minutos. ¿Está en garantía?", "The battery of my Volt 2 runs out in ten minutes. Is it under warranty?"),
("es", "code_switch", "order_status", None, "neu", "Hi, my order llegó pero falta el cargador. Can you send it?", "Hi, my order arrived but the charger is missing. Can you send it?"),
("es", "plain", "returns_refund", None, "neu", "Quiero devolver la [[Harbour|PRODUCT]], es demasiado grande para mí. ¿Me devolvéis el dinero?", "I want to return the Harbour, it is too big for me. Will you give me my money back?"),
("es", "sarcasm", "order_status", None, "neg", "Genial, el repartidor dejó la bici en la calle bajo la lluvia. Gracias, de verdad.", "Great, the delivery driver left the bike in the street in the rain. Thanks, really."),
("es", "plain", "product_question", None, "neu", "¿La [[Volt 2 Step-Through|PRODUCT]] sirve para alguien de 1,55 m?", "Is the Volt 2 Step-Through suitable for someone who is 1.55 m tall?"),
("es", "plain", "repair_booking", None, "neu", "Necesito cambiar las pastillas de freno. ¿Tenéis hueco el [[jueves|DATE]]?", "I need to change the brake pads. Do you have a slot on Thursday?"),
("es", "plain", "praise", None, "pos", "¡Muchísimas gracias a [[Ines|PERSON]] por responder en español! Todo resuelto.", "Many thanks to Ines for replying in Spanish! All sorted."),
("es", "two_intents", "order_status", "returns_refund", "neg", "Mi pedido [[TW-122204|ORDER_ID]] no ha llegado y además quiero cancelar el [[Beacon Light Set|PRODUCT]] que pedí aparte.", "My order TW-122204 has not arrived and I also want to cancel the Beacon Light Set that I ordered separately."),
("es", "very_short", "product_question", None, "neu", "¿Hacéis envíos a [[Canarias|LOC]]?", "Do you ship to the Canary Islands?"),
("es", "plain", "spam", None, "neu", "Gana [[5.000 euros|MONEY]] al mes desde casa. Haz clic aquí.", "Earn 5,000 euros a month from home. Click here."),
("es", "very_long", "warranty_fault", None, "neg", "Buenas tardes. Compré una [[Causeway|PRODUCT]] el [[20 de julio|DATE]] y me llegó el [[29 de julio|DATE]]. Desde el primer día el cambio trasero hace un ruido muy raro y las marchas saltan cuando subo cuestas. La he llevado a una tienda de bicis aquí en [[Valencia|LOC]] y me dicen que el desviador viene doblado de fábrica. No quiero pagar una reparación por algo que vino mal. ¿Me podéis decir qué hago? ¿La tengo que enviar a [[Inglaterra|LOC]]? Un saludo, [[Marta Gil|PERSON]]", "Good afternoon. I bought a Causeway on 20 July and it arrived on 29 July. From the first day the rear gears have made a very strange noise and the gears slip when I ride uphill. I have taken it to a bike shop here in Valencia and they tell me the derailleur came bent from the factory. I don't want to pay for a repair for something that arrived faulty. Can you tell me what to do? Do I have to send it to England? Best wishes, Marta Gil"),
("es", "slang", "praise", None, "neu", "Tío, la bici mola mazo, pero el sillín es durísimo jaja", "Mate, the bike is really cool, but the saddle is rock hard haha"),
("fr", "plain", "order_status", None, "neg", "Bonjour, ma commande [[TW-122691|ORDER_ID]] est bloquée en transit depuis six jours. Pouvez-vous vérifier ?", "Hello, my order TW-122691 has been stuck in transit for six days. Can you check?"),
("fr", "plain", "warranty_fault", None, "neg", "Mon [[Volt 2|PRODUCT]] affiche l'erreur E07 et le moteur ne marche plus.", "My Volt 2 shows error E07 and the motor no longer works."),
("fr", "sarcasm", "order_status", None, "neg", "Bravo, livré chez le voisin du dessus sans me prévenir. Très pro.", "Well done, delivered to the upstairs neighbour without telling me. Very professional."),
("fr", "plain", "returns_refund", None, "neu", "Je souhaite renvoyer le [[LockSure D-lock|PRODUCT]] : il est trop lourd pour moi. Comment faire ?", "I would like to send back the LockSure D-lock: it is too heavy for me. How do I do that?"),
("fr", "plain", "product_question", None, "neu", "Le [[Harbour|PRODUCT]] est-il livré monté ou faut-il l'assembler ?", "Does the Harbour come assembled or does it need to be put together?"),
("fr", "code_switch", "warranty_fault", None, "neu", "Salut, the bike est arrivé mais the front light ne s'allume pas. C'est normal ?", "Hi, the bike has arrived but the front light does not switch on. Is that normal?"),
("fr", "plain", "repair_booking", None, "neu", "Est-ce que je peux prendre rendez-vous pour un [[Full Service|PRODUCT]] le [[3 septembre|DATE]] ?", "Can I make an appointment for a Full Service on 3 September?"),
("fr", "plain", "praise", None, "pos", "Merci à toute l'équipe, le vélo est parfait et la livraison à [[Lyon|LOC]] a été rapide.", "Thanks to the whole team, the bike is perfect and the delivery to Lyon was quick."),
("fr", "very_short", "returns_refund", None, "neg", "Remboursement ???", "Refund ???"),
("fr", "two_intents", "warranty_fault", "product_question", "neg", "Ma batterie ne charge plus et je voudrais aussi savoir si vous vendez une deuxième batterie ([[Volt 2 battery pack (500 Wh)|PRODUCT]]).", "My battery no longer charges and I would also like to know whether you sell a second battery (Volt 2 battery pack (500 Wh))."),
("fr", "plain", "spam", None, "neu", "Félicitations ! Vous avez gagné un téléphone neuf. Cliquez ici pour le recevoir.", "Congratulations! You have won a new phone. Click here to receive it."),
("fr", "slang", "praise", None, "neu", "Franchement, le vélo est trop bien, mais le livreur a galéré pour trouver chez moi lol", "Honestly, the bike is great, but the delivery driver struggled to find my place lol"),
("fr", "very_long", "warranty_fault", None, "neu", "Bonjour, j'ai acheté un [[Volt 2 Step-Through|PRODUCT]] le [[16 juillet|DATE]] pendant les soldes, pour [[1 999 £|MONEY]]. Depuis une semaine, la batterie se vide très vite : je fais à peine dix kilomètres alors qu'avant j'en faisais cinquante. J'ai lu sur votre site qu'il y avait un problème avec certains lots de batteries, mais le numéro sur la mienne n'est pas VB-0426. Est-ce que ma batterie est quand même couverte par la garantie, et comment faire pour l'échanger depuis la [[France|LOC]] ? Cordialement, [[Hélène Rousseau|PERSON]]", "Hello, I bought a Volt 2 Step-Through on 16 July during the sale, for £1,999. For a week the battery has been running down very quickly: I barely manage ten kilometres when before I did fifty. I read on your website that there was a problem with some battery batches, but the number on mine is not VB-0426. Is my battery still covered by the warranty, and how do I exchange it from France? Kind regards, Hélène Rousseau"),
]
HARD_TEST = []
for k, (lang, kind, intent, second, sent, marked, en) in enumerate(HARD, 1):
text, spans = unmark(marked)
HARD_TEST.append({"id": f"HT{k:02d}", "language": lang, "kind": kind, "intent": intent, "second_intent": second,
"sentiment": sent, "text": text, "entities": spans, "en_ref": en})
from collections import Counter
print(len(HARD_TEST), "hand-written messages:", dict(Counter(h["language"] for h in HARD_TEST)))
print(dict(Counter(h["intent"] for h in HARD_TEST)))
print(dict(Counter(h["kind"] for h in HARD_TEST)))
print(HARD_TEST[0]["text"], HARD_TEST[0]["entities"])Code walkthrough
- Block 4, unchanged:
HARD_TEST.
Output
80 hand-written messages: {'en': 40, 'de': 14, 'es': 13, 'fr': 13}
{'warranty_fault': 16, 'order_status': 18, 'returns_refund': 10, 'repair_booking': 10, 'praise': 10, 'product_question': 9, 'spam': 7}
{'sarcasm': 9, 'two_intents': 6, 'very_short': 8, 'slang': 7, 'plain': 37, 'negation': 2, 'very_long': 6, 'codes': 1, 'mixed': 1, 'code_switch': 3}
Oh brilliant, the Volt 2 battery died halfway up the hill again. Really loving my £2,199 paperweight. [(18, 24, 'PRODUCT'), (82, 88, 'MONEY')]import hashlib, json
def fingerprint(*datasets):
"""The first 12 hex digits of a SHA-256 hash of the data: equal fingerprints mean identical data."""
blob = json.dumps(datasets, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(blob).hexdigest()[:12]
print("messages ", fingerprint(MESSAGES))
print("hard_test", fingerprint(HARD_TEST))Code walkthrough
- Block 12's check, with the two lines this lesson can print; other values mean a block was changed.
Output
messages 294611361162
hard_test 63c80b0396bcThe email pattern must find addresses anywhere, so it uses search. That brings a quieter problem.
EMAIL_PLAIN = re.compile(r"[A-Za-z0-9_%+-]+(?:\.[A-Za-z0-9_%+-]+)*@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+")
EMAIL_EDGE = re.compile(r"(?<![A-Za-z0-9_%+.-])" + EMAIL_PLAIN.pattern) # start only where a word starts
for n in [2_500, 5_000, 10_000, 20_000]:
text = ("kofiasante" * 2_000)[:n] # one long run of letters, no @
timings = []
for pattern in [EMAIL_PLAIN, EMAIL_EDGE]:
start = time.perf_counter()
pattern.search(text)
timings.append(time.perf_counter() - start)
print(f"{n:>6} letters: plain {timings[0]:.4f} s, edge {timings[1]:.6f} s")
print(EMAIL_EDGE.findall("to kofi.asante@mailbox.example and <help@tidewell.example>"))Code walkthrough
EMAIL_PLAINhas no nested quantifier.EMAIL_EDGEadds(?<!...), a lookbehind: "start only if the character before is not part of an address".- Both search the same run of letters; the last line checks
EMAIL_EDGEstill finds real addresses.
Output
2500 letters: plain 0.0338 s, edge 0.000040 s
5000 letters: plain 0.1333 s, edge 0.000083 s
10000 letters: plain 0.5338 s, edge 0.000170 s
20000 letters: plain 2.1487 s, edge 0.000330 s
['kofi.asante@mailbox.example', 'help@tidewell.example']search tries every starting position, and EMAIL_PLAIN reads to the end of the run from each, so twice the letters cost four times the work. The lookbehind rejects starts mid-run, so EMAIL_EDGE reads the run once.
Now the project files. Colab's %%writefile cannot create folders, so make them first.
from pathlib import Path
for folder in ["inbox", "tests"]:
Path(folder).mkdir(exist_ok=True) # %%writefile cannot create folders in Colab
Path("inbox/__init__.py").touch() # makes inbox a package; touch never empties a file
print(sorted(str(p) for p in Path("inbox").iterdir()))Code walkthrough
mkdir(exist_ok=True)makes a folder unless it exists;touch()createsinbox/__init__.py, which makesinboximportable.
Output
['inbox/__init__.py']%%writefile -a inbox/rules.py
# ---- Module 1 Lesson 4: redaction before storage ----
import re
EMAIL = re.compile(r"(?<![A-Za-z0-9_%+.-])[A-Za-z0-9_%+-]+(?:\.[A-Za-z0-9_%+-]+)*"
r"@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+")
PHONE = re.compile(r"(?:\+44 ?|\b0)7\d{3} ?\d{6}\b") # 07700 900070, +44 7700 900466
POSTCODE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\b", re.I)
MAX_BODY = 20_000 # longer bodies go to a person
def redact(text, names=()):
"""Replace emails, mobile numbers, postcodes and the sender's names with typed placeholders."""
if len(text) > MAX_BODY:
raise ValueError("message too long to redact automatically")
text = EMAIL.sub("[EMAIL]", text) # first, so a name inside an address goes with it
text = PHONE.sub("[PHONE]", text)
text = POSTCODE.sub("[POSTCODE]", text)
for name in names:
text = re.sub(r"\b" + re.escape(name) + r"\b", "[NAME]", text, flags=re.I)
return textCode walkthrough
%%writefile -aappends toinbox/rules.py, keeping what earlier lessons wrote ("Appending to" if the file exists).EMAILisEMAIL_EDGE.PHONEstarts with+44and an optional space, or a0starting a word; then7, three digits, an optional space, six digits. Customers write both forms.POSTCODEis the UK shape (BS12 2FR);re.Iignores case.MAX_BODYis section 1's length guard..subreplaces every match; the loop replaces each sender name as a whole word,re.escapemakingO'Connorsafe.
Output
Writing inbox/rules.pyA pattern cannot know Kofi is a name, but the shop knows the sender, so redact takes the names from the message's record.
from inbox.rules import redact
kofi = next(m for m in MESSAGES if m["id"] == "TW-M0435")
print(kofi["customer"], "|", kofi["intent"])
print(redact(kofi["body"], names=kofi["customer"].split()))Code walkthrough
next(...)findsTW-M0435;.split()gives the names["Kofi", "Asante"].
Output
Kofi Asante | order_status
Hello,
Could you send me the tracking link for order TW-106489? The confirmation email never came through to [EMAIL].
You can call me on [PHONE].
My postcode is [POSTCODE].
Cheers,
[NAME]
> On 24 April 2026, Tidewell Cycles <[EMAIL]> wrote:
> Thanks for your message. We will reply within one working day.A leak check then searches the redacted inbox for the original details.
from collections import Counter
tags, leaks = Counter(), Counter()
for m in MESSAGES:
names = m["customer"].split() if m["gender"] else () # spam senders have no person's name
safe = redact(m["body"], names)
for tag in ["[EMAIL]", "[PHONE]", "[POSTCODE]", "[NAME]"]:
tags[tag] += safe.count(tag)
leaks["email"] += m["email"].lower() in safe.lower()
leaks["phone"] += "07700" in safe or "+44 7700" in safe # both forms
leaks["last name"] += bool(names) and names[-1].lower() in safe.lower()
print(dict(tags))
print("still visible:", dict(leaks))Code walkthrough
Countercounts; addingTruecounts one. Spam senders (nogender) are companies, so no names are passed.- The loop counts placeholders, then checks whether the address, a phone number in either form (
07700or+44 7700) or the last name survived. Search for every form a detail can take, not only the one your pattern expects.
Output
{'[EMAIL]': 366, '[PHONE]': 165, '[POSTCODE]': 81, '[NAME]': 841}
still visible: {'email': 0, 'phone': 0, 'last name': 0}Nothing leaked. But a name it was not given, such as a neighbour mentioned in the text, would stay; Module 3 adds an entity recogniser for that.
Last, turn the cases into a test file.
%%writefile -a tests/test_rules.py
# ---- Module 1 Lesson 4: redaction tests ----
import time
import pytest
from inbox.rules import redact
MUST_REDACT = [("mail kofi.asante@mailbox.example", "[EMAIL]"), ("call 07700 900070", "[PHONE]"),
("call 07700900158", "[PHONE]"), ("au +44 7700 900466.", "[PHONE]"),
("My postcode is BS12 2FR.", "[POSTCODE]"),
("postcode bs9 7jx", "[POSTCODE]")]
MUST_KEEP = ["order TW-106489", "ticket RJ-2026-0412", "batch VB-0426", "£2,199 on 14/03",
"my Volt 2 Step-Through", "message TW-M0435"]
@pytest.mark.parametrize("text, tag", MUST_REDACT)
def test_redact_hides(text, tag):
assert tag in redact(text)
@pytest.mark.parametrize("text", MUST_KEEP)
def test_redact_keeps(text):
assert redact(text) == text
def test_redact_long_input_is_fast():
start = time.perf_counter()
redact("kofiasante" * 1_500) # 15,000 letters, no @ anywhere
assert time.perf_counter() - start < 1.0Code walkthrough
- pytest runs every function named
test_...;parametrizeruns one test per case. test_redact_keepsholds the negative cases, which must come back unchanged. The timing test failed in this lesson's first run, withEMAIL_PLAIN.
Output
Writing tests/test_rules.py!python -m pytest -q tests/test_rules.pyCode walkthrough
!runs a shell command;-qgives a short report.
Output
............. [100%]
13 passed in 0.06s4. A keyword rule router as the baseline (Essential)
Before training anything, you need the score of a cheap, explainable method. Without it, no later model can show it is worth its cost.
A baseline is the simple method every better one must beat. Tidewell's is a rule router: an ordered list of keyword rules, one per intent. The first rule with a keyword in the message decides; if none fires, it falls back to order_status, the most common intent.
Order matters: fault messages mention orders too, so broad words such as "order" come last. The router also returns the keyword that fired, so each decision can be explained.
%%writefile -a inbox/rules.py
# ---- Module 1 Lesson 4: the keyword rule router, the baseline ----
import re
FALLBACK = "order_status" # the most common intent, used when no rule fires
RULES = [ # (intent, keywords), checked top to bottom: the first rule that fires wins
("spam", ["partnership", "crypto", "verify your account", "influencer", "click http",
"congratulations", "ranking", "proposal", "suspended"]),
("warranty_fault", ["warranty", "faulty", "snapped", "cracked", "error", "cuts out", "damaged",
"battery runs", "battery died", "vb-0426", "dead", "broken"]),
("returns_refund", ["refund", "return", "send it back", "cancel", "charged me", "swap"]),
("repair_booking", ["book", "booking", "service", "puncture", "repair", "fit me in"]),
("praise", ["thank you to", "great service", "love my", "best bike shop", "five stars",
"legend", "rides like new"]),
("product_question", ["in stock", "come in", "difference", "what range", "compatible",
"will a", "fit the"]),
("order_status", ["tracking", "order", "delivery", "arrived", "parcel", "ship"]),
]
def compile_rules(rules):
return [(intent, re.compile(r"\b(" + "|".join(map(re.escape, words)) + r")", re.I))
for intent, words in rules]
ROUTES = compile_rules(RULES)
def route(text, routes=ROUTES):
"""(intent, the keyword that fired), or (FALLBACK, None) when nothing fires."""
for intent, pattern in routes:
hit = pattern.search(text)
if hit:
return intent, hit.group(1).lower()
return FALLBACK, None
def predict(texts):
return [route(t)[0] for t in texts]Code walkthrough
RULESis in priority order; the keywords came from English training messages only.compile_rulesjoins each intent's keywords into one pattern,\b(refund|return|...);\bstops "book" firing inside "Facebook".routereturns the intent and keyword, or the fallback andNone;routeslets you try changed rules without editing the file.predict(texts)is the course's fixed interface, offered by every model, so Module 10 can compare them all.
Output
Appending to inbox/rules.pyimport importlib, inbox.rules
importlib.reload(inbox.rules) # pick up the code just appended
from inbox.rules import route, predict, RULES, compile_rules
def accuracy(gold, pred):
return sum(g == p for g, p in zip(gold, pred)) / len(gold)
print("Kofi:", route(kofi["body"]))
test = [m for m in MESSAGES if m["split"] == "test"]
print("template test:", len(test), round(accuracy([m["intent"] for m in test], predict([m["body"] for m in test])), 3))
print("HARD_TEST :", len(HARD_TEST), round(accuracy([h["intent"] for h in HARD_TEST], predict([h["text"] for h in HARD_TEST])), 3))Code walkthrough
importlib.reloadre-reads the file, which Python would otherwise keep as first imported.accuracyis the share of predictions equal to the gold label, on the template test split and onHARD_TEST.
Output
Kofi: ('order_status', 'tracking')
template test: 271 0.878
HARD_TEST : 80 0.438Template messages repeat the wordings the keywords came from, so they flatter the router. HARD_TEST, hand-written in four languages with sarcasm and slang, is the headline number every model must beat.
from collections import defaultdict
hard_pred = predict([h["text"] for h in HARD_TEST])
by_lang, by_kind = defaultdict(list), defaultdict(list)
for h, p in zip(HARD_TEST, hard_pred):
by_lang[h["language"]].append(h["intent"] == p)
by_kind[h["kind"]].append(h["intent"] == p)
for group in [by_lang, by_kind]:
print({k: f"{sum(v)}/{len(v)}" for k, v in group.items() if len(v) >= 6})
for h, p in zip(HARD_TEST, hard_pred):
if h["intent"] != p and h["language"] == "en":
print(f'{h["intent"]:16} -> {p:16} | {route(h["text"])[1]} | {h["text"][:44]}')
right = [route(h["text"])[1] for h, p in zip(HARD_TEST, hard_pred) if h["language"] != "en" and h["intent"] == p]
print("non-English right:", len(right), "| by the fallback:", right.count(None))Code walkthrough
defaultdict(list)groups right-or-wrong results by language and by kind; groups of six or more are printed.- The next loop prints each English mistake with the keyword that fired (
None: the fallback). right.count(None)counts correct non-English answers that came from the fallback.
Output
{'en': '25/40', 'de': '3/14', 'es': '3/13', 'fr': '4/13'}
{'sarcasm': '6/9', 'two_intents': '3/6', 'very_short': '4/8', 'slang': '3/7', 'plain': '13/37', 'very_long': '4/6'}
repair_booking -> order_status | None | yo the gears on my Causeway are proper knack
product_question -> order_status | None | I don't want the Volt 2, I want something wi
warranty_fault -> order_status | None | The display on my Volt 2 Step-Through keeps
product_question -> order_status | None | is TW-BATT-500 the same pack as the one in t
praise -> order_status | delivery | Bike is lovely, delivery was a nightmare. Ov
warranty_fault -> repair_booking | service | My Volt 2 motor is cutting out, and while it
product_question -> order_status | None | Do you price match? Saw the Volt 2 cheaper e
spam -> order_status | ship | We are a logistics company offering cheap sh
warranty_fault -> order_status | arrived | Box arrived soaked and the Causeway rear mec
product_question -> order_status | None | kids helmets?
praise -> order_status | None | Shout out to Callum for talking me out of th
returns_refund -> order_status | None | Charged twice. £45 and £45. Fix it.
warranty_fault -> repair_booking | puncture | Fourth puncture this month on the 'puncture
spam -> order_status | None | Earn passive income from your bikes! Tokenis
praise -> returns_refund | refund | cheers for sorting the refund so quick, real
non-English right: 10 | by the fallback: 7Three kinds of mistake. No keyword: "Charged twice. £45 and £45. Fix it." never says "refund". The wrong keyword: "cheers for sorting the refund so quick" is praise, but "refund" fires first. And other languages: the keywords are English, so "Der Akku von meinem Volt 2 lädt nicht mehr" (my Volt 2's battery no longer charges) fires nothing. Of the 10 non-English messages the router gets right, 7 are the fallback guessing order_status: luck, not rules.
5. Rule maintenance and the long tail (Recommended)
A rule list is never finished. Each fix covers fewer messages, and any change can break something.
train = [m for m in MESSAGES if m["split"] == "train"]
fired = Counter(route(m["body"])[1] for m in train)
print(len(fired) - 1, "keywords fired;", fired.pop(None, 0), "messages matched no rule")
ranked = fired.most_common()
print("top 5:", ranked[:5])
print("top 5 share:", round(sum(n for _, n in ranked[:5]) / sum(fired.values()), 2))
print("fired 3 times or fewer:", [k for k, n in ranked if n <= 3])Code walkthrough
Countercounts the keyword that fired per training message;pop(None, 0)takes out the unmatched count.- The share line divides the top five's hits by all routed messages.
Output
35 keywords fired; 242 messages matched no rule
top 5: [('order', 115), ('vb-0426', 110), ('book', 76), ('tracking', 75), ('service', 74)]
top 5 share: 0.44
fired 3 times or fewer: ['cuts out', 'thank you to']This is the long tail: a few keywords do most of the work, the rest catch a handful each, and the unmatched messages are many different wordings, not a few. Each new keyword wins less.
Break it: add "thanks" to praise
"Thanks" sounds like praise. Add it to the praise rule and rerun the test split.
broken = compile_rules([(i, words + ["thanks"] if i == "praise" else words) for i, words in RULES])
print("Kofi now:", route(kofi["body"], broken))
pred = [route(m["body"], broken)[0] for m in test]
print("template test:", round(accuracy([m["intent"] for m in test], pred), 3))
print("routed to praise:", pred.count("praise"), "| really praise:", sum(m["intent"] == "praise" for m in test))Code walkthrough
- The comprehension copies
RULESwith"thanks"added to praise and compiles it; the file is unchanged.
Output
Kofi now: ('praise', 'thanks')
template test: 0.797
routed to praise: 37 | really praise: 14via "tracking"
template test 0.878
via quoted "Thanks for
your message"
template test 0.797
Kofi is now praise, because of the quoted reply: "Thanks for your message". Signatures ("Many thanks") do the same. One reasonable keyword cost 0.081. Maintaining rules means rerunning every test after each change and reading the unmatched messages each week.
6. When rules beat models, and when they stop (Essential)
The useful question is not "rules or models" but which job each does well. Measure both.
Order numbers have a fixed shape Tidewell chose. Score the section 2 pattern against the inbox's gold order numbers.
def score(pattern):
tp = fp = fn = 0
for m in MESSAGES:
gold = {m["body"][s:e] for s, e, label in m["entities"] if label == "ORDER_ID"}
found = set(pattern.findall(m["body"]))
tp, fp, fn = tp + len(gold & found), fp + len(found - gold), fn + len(gold - found)
return f"precision {tp / (tp + fp):.3f}, recall {tp / (tp + fn):.3f}, {fn} missed"
print("as written:", score(ORDER_ID))
missed = sorted({m["body"][s:e] for m in MESSAGES for s, e, label in m["entities"]
if label == "ORDER_ID" and not ORDER_ID.fullmatch(m["body"][s:e])})
print("missed look like:", missed[:3])
print("ignoring case:", score(re.compile(ORDER_ID.pattern, re.I)))Code walkthrough
goldholds the recorded order numbers,foundthe pattern's. Precision is the share of hits that are right; recall the share of real ones found.missedlists gold numbers the pattern cannot match; the last line retries withre.I.
Output
as written: precision 1.000, recall 0.944, 42 missed
missed look like: ['tw-100474', 'tw-101077', 'tw-102121']
ignoring case: precision 1.000, recall 1.000, 0 missedNo positive case in section 2 was lower case, so the tests never asked. With tw-106489 added and re.I, the pattern is perfect on this data. Rules win with a fixed shape, few forms and a need to explain each decision: order numbers, postcodes and redaction stay rules all course.
Rules stop where wording is open: 0.438 on HARD_TEST. Sarcasm, slang and three more languages need a model that learns from examples (Module 4). Rules stay as baseline, guard and fallback.
In production
Teams treat patterns on untrusted text as code. Engines that never backtrack, such as RE2 and the regex libraries of Go and Rust, run in linear time; teams on Python's re cap input lengths and review for nested quantifiers. Every change runs the pattern tests, including a worst-case timing case. Redaction runs when a message arrives, before logs, queues or backups, because a copy made earlier has already leaked. Routers log the keyword that fired, so a wrong route traces to one rule. The rule list is versioned and each change is scored on the whole test set.
What you added to the Tidewell inbox
inbox/rules.py:redact()with linear-timeEMAIL,PHONEandPOSTCODEpatterns and a length guard.inbox/rules.py: the keyword rule router,RULES,route()andpredict(texts), the course's first baseline.tests/test_rules.py: 12 positive, negative and timing tests for redaction.- The baseline to beat: 0.878 on the template test split, 0.438 on
HARD_TEST.
Check yourself
- Why does
^([a-z0-9]+\.?)*[a-z0-9]+@get slow only when the text has no@?
With an @, the first split succeeds. Without one, every split fails, and their number doubles with each letter.
- Your order-number pattern passes all its positive cases. What can still be wrong with it?
False hits: a seven-digit typo, a product code. Only near-miss negative cases catch them.
- Why does
redactreplace emails before names?
The address contains the name; names first would leave [NAME].[NAME]@mailbox.example.
- The router scores 0.878 on the template test split and 0.438 on
HARD_TEST. Which do you report to Rosa, and why?
HARD_TEST, which is closer to real mail; label the template score as flattering.
Exercise
Dev's repair tickets look like RJ-2026-0412. The first try below is too loose: change TICKET until the check prints 0 failures.
TICKET = re.compile(r"RJ-\d+-\d+") # first try: change this line
check_pattern(TICKET,
["Please can I move my booking (RJ-2026-0301)", "ticket RJ-2026-0412, thanks"],
["ticket RJ-2026-04123", "XRJ-2026-0412", "order TW-106489", "RJ-26-0412"])Code walkthrough
check_patternis section 2's helper; the negatives are near misses.
Output
2 positive, 4 negative, 3 failures
false hit | ticket RJ-2026-04123
false hit | XRJ-2026-0412
false hit | RJ-26-0412TICKET = re.compile(r"\bRJ-2026-\d{4}\b")
failures = check_pattern(TICKET,
["Please can I move my booking (RJ-2026-0301)", "ticket RJ-2026-0412, thanks"],
["ticket RJ-2026-04123", "XRJ-2026-0412", "order TW-106489", "RJ-26-0412"])
assert not failures
print("exercise passed")Code walkthrough
\d{4}asks for exactly four digits; the\bstop a letter in front or a fifth digit.assertstops if any case fails.
Output
2 positive, 4 negative, 0 failures
exercise passedKey terms
| Term | Meaning |
|---|---|
| Backtracking | On failure, go back to an earlier choice and try the next |
| Catastrophic backtracking | Matching time that grows exponentially with the input |
| Nested quantifier | A repeat inside a repeat, such as (...+)* |
| ReDoS | Input chosen to make a pattern stall a service |
| Positive / negative case | A text a pattern must / must not match |
| Redaction | Replacing personal data with a typed placeholder |
| Data minimisation | Keeping only the personal data you need |
| Baseline | The simple method every later one must beat |
| Rule router | Ordered keyword rules; the first that fires decides |
| Long tail | Many rare wordings left after the common ones |
Sources
- Russ Cox, "Regular Expression Matching Can Be Simple And Fast (but is slow in Java, Perl, PHP, Python, Ruby, ...)", swtch.com, January 2007. https://swtch.com/~rsc/regexp/regexp1.html
- Ken Thompson, "Regular Expression Search Algorithm", Communications of the ACM, 1968.
- Google, RE2, a regular expression library that never backtracks, open-source repository. https://github.com/google/re2
- Python Software Foundation, "re: Regular expression operations", Python 3 documentation. https://docs.python.org/3/library/re.html
- UK GDPR, Article 5(1)(c), the data minimisation principle. Guidance: Information Commissioner's Office, https://ico.org.uk/ (not legal advice).