CourseNatural Language Processing: The Complete Course · Course guide · part 5 of 73
Part 5 · Course guide

Shared Tidewell Data

103 min read·14 Oct 2026

Every module uses the same Tidewell Cycles data, so you are never learning a new example. This page is the single source for all of it: the code that makes the data, and what that code prints.

All of this data is simulated. Tidewell Cycles is a fictional company. Every name, message, order, email address, phone number and figure below is made up for the course. Email addresses use the reserved .example domain, phone numbers use the 07700 900xxx range that Ofcom reserves for drama, and postcodes have a valid shape but belong to nobody.

How lessons get the data

The data is generated by code, not downloaded: there is no dataset to fetch and nothing to sign up for. The generator uses plain Python and one fixed seed, random.Random(20260831), so it makes exactly the same data on every computer. The blocks below were run in order with Python 3.12.15 on 14 October 2026, and every output on this page comes from that run. The same fingerprints (the last block) also came out on Python 3.9.6, which shows the generator does not depend on the Python version; the course pilot confirms them in Colab.

Each block is a function or a literal, so a lesson pastes only what it needs:

A lesson that needsPastes blocksGets
The inbox messages1, 2, 3MESSAGES (and PRODUCTS, CUSTOMERS, LABELS, DATA_AS_OF)
The headline test set4HARD_TEST (works on its own)
Out-of-scope messages5OOS (works on its own)
Tagged sentences6POS_GOLD (works on its own)
The help centre7HELP_CENTRE (works on its own)
Search queries or questions7, 8SEARCH_QUERIES, QA_PAIRS
Email threads9THREADS, make_threads(quote=True) (works on its own)
The word2vec corpus10W2V_CORPUS (works on its own)
Translation pairs1, 2, 3, 4, 11TRANSLATIONS

Block 3a only prints a summary and is never needed by later code. Blocks 2 and 3 are long, because they hold every wording of every message type; lessons paste them unchanged and never edit them. The Module 1 lab saves blocks 1 to 4 as inbox/data.py, and later labs import from there.

For authors: copy these blocks exactly. Do not invent other products, customers, policies or dates. If a lesson needs more data, add it in the lesson, say "added in this lesson", and keep what is here unchanged. Quote only numbers printed here or measured in the lesson.

The shapes of identifiers (order numbers, SKUs, repair tickets) and the policy facts are listed in 03-the-tidewell-inbox.md.

1. Dates, products, customers and labels

The fixed facts everything else uses: the dates, the fourteen products with their prices, Ines's seven label definitions and the sixty customers. Every other block that needs a product or a customer takes it from here.

python
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

  • COURSE_DATE is when facts were checked; DATA_AS_OF is the morning the inbox was exported. Code counts days to DATA_AS_OF, never to date.today(), so every learner gets the same answer.
  • WINDOW is the first and last day of messages; EVENTS holds the five dated events from the project page. BATCH is the faulty battery batch.
  • PRODUCTS lists each product as (sku, name, price in GBP, kind). The kind (ebike, bike, accessory, service, gift) decides which message templates can mention it.
  • INTENTS and LABELS are the seven intents and Ines's one-line definition of each. Prompts in Module 10 quote LABELS word for word.
  • CUSTOMERS holds (first name, last name, gender, language, home town). The gender column exists only so that Module 10 can check whether a model treats names differently; nothing else reads it.
  • The output confirms the shape: 14 products, 60 customers split 30 and 30 by gender, and 36 English, 8 German, 8 Spanish and 8 French writers. DATA_AS_OF is 182 days after the first message day.

Output:

text
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_OF

2. How each kind of message is worded

The inbox is built from wordings: one dictionary per way of saying something. Most wordings exist in all four languages with the same meaning, which is what gives every German, Spanish and French message a known English reference. This block also holds the small helpers that write dates and prices the way each language does, and the function that makes typos.

python
import 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

  • T maps each intent to its wordings. A wording has a sentiment s (neg, neu, pos) and its text under en, de, es and fr. Some wordings are English only, written the way people type: "any update on {ORDER_ID}? need the {PRODUCT} before {DATE} if poss".
  • Slots in capitals, such as {ORDER_ID} and {PRODUCT}, become gold entities: the generator records where each one lands in the text. Slots in lower case, such as {carrier} or {email}, are filled in but not labelled. {BIKE}, {EBIKE}, {SERVICE}, {ACCESSORY} and {KIDBIKE} are all labelled PRODUCT; they only restrict which product may fill the slot.
  • "from": "2026-06-09" means a wording is only used from that date: the battery-batch email cannot appear before Tidewell sent it, and complaints about the new courier start on 10 August. "w" makes a wording more likely when it is available.
  • PHRASES pairs the fault, wear and damage phrases across the four languages, so a German message about "der Akku lädt nicht mehr" has the English reference "the battery will not charge".
  • SUBJECTS, FORM_TOPIC and CHANNELS give the email subject lines, the web form's topic and how likely each intent is to arrive by email, web form or review.
  • fmt_date writes a date in one of the ways people write it in that language (12 June, 12/06, June 12th; 12. Juni; 12 de junio; 12 juin). fmt_money does the same for prices (£2,199, 2.199 £, 2199 libras). Passing rng=None gives the first, plain style, which the English references use.
  • typo swaps two letters, drops one or doubles one, at a random place inside the word.
  • The output counts 35 wordings, 18 of them in all four languages.

Output:

text
35 wordings, 18 of them in all four languages

3. The message generator

This block builds the 1,800 messages. For each one it picks a day, an intent, a language, a wording and a customer; fills the slots; adds the things real messages carry (greetings, signatures, quoted replies, phone numbers, web-form HTML); adds noise on purpose; and records where every entity ends up. It needs blocks 1 and 2.

python
import 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

  • fill turns a wording into a list of pieces, each (text, label). Plain text has the label None; a filled capital slot carries its entity label. Keeping pieces apart until the end is what makes the entity positions exact, even after noise changes the text around them.
  • email_of makes one fixed address per customer: accents are removed (Martín becomes martin, Yılmaz becomes yilmaz) and the domain is always .example.
  • weights(d) gives each intent a weight on day d. Spam is eight times as likely during the April spam wave; warranty faults are 3.5 times as likely from the battery notice to the end of July and twice as likely in August; order-status messages are 2.5 times as likely during the sale and 1.6 times after the courier change; returns rise after the sale. Weekends have 60% of the weekday volume, and volume is 1.5 times higher from 1 July.
  • picks chooses 1,746 days in proportion to those weights; the other 54 messages (3%) are added later as near-duplicates.
  • For each message the language is English for spam, and otherwise drawn with 95% English before 1 July and 58% English from 1 July. The customer is drawn from the customers who write in that language; spam comes from one of four made-up senders.
  • v holds every slot value: a product of the right kind for each product slot, a date in the past (when an order was placed) or the future (a booking), the product's own price for {MONEY}, an order number that grows with the date (TW-1 followed by five digits), the customer's home town when the wording says where they live, a staff name in praise, the courier in use on that day, and a repair ticket number.
  • core is the message in its own language with no extras or noise; en_ref is the same wording filled in English, with plain dates and prices. Every non-English message therefore has a reference translation.
  • Emails may get a greeting (70%), a phone number (15%), a UK postcode (30% of English repair and order emails), a signature with the customer's name, which is labelled PERSON (70%), a "Sent from my phone" line, and a quoted reply from Tidewell with its date labelled DATE (25%).
  • Noise, each at a fixed rate: typos (15%), all capitals (3%) or all lower case (6%), an emoji after the main text (8%), a non-breaking or zero-width space (5%), HTML from the web form with <p>, <br> and escaped characters such as &#x27; (60% of web-form messages), and mojibake (4%): the UTF-8 bytes read as Windows-1252, which turns é into é and £ into £. A message whose bytes cannot be read that way is left alone.
  • spans records each entity as (start, end, label), character positions in body. For every entity, body[start:end] is exactly the entity's text.
  • Reviews carry 1 to 5 stars that match the sentiment, except in 8% of reviews, where the stars disagree with the text on purpose (Module 5, "ratings as noisy labels"). A neutral wording is never sent as a review.
  • Near-duplicates: 54 messages are a customer writing again, a few days later, with "Sorry, sending this again" in front. duplicate_of holds the id of the original.
  • Messages are sorted by date and numbered TW-M0001 to TW-M1800. Then 2% of them (36) get a deliberately wrong intent, with label_flipped set to True, so that error analysis in Module 5 has real label errors to find. Do not read this field before the error-analysis exercise.
  • split is a stratified 70 / 15 / 15 split into train, dev and test by intent; a near-duplicate always goes into the same split as its original, so no message leaks into the test set in other words. period is before_july or from_july, for the drift and multilingual lessons.
  • The output shows 1,800 messages from 2 March to 30 August, and the first message: a French web-form message that came through with mojibake.

Output:

text
1800 messages; 2026-03-02 to 2026-08-30
TW-M0001 | Web form: Workshop | <p>Mon vélo (Causeway) a besoin d&#x27;une réparation : les plaquettes de frei

Each message is a dictionary with these fields:

FieldExampleNotes
idTW-M0001In date order
date2026-03-02ISO date
channelemail, web form, review
languageen, de, es, fr
customer, emailJoan Hartley, joan.hartley@post.exampleSpam senders have a made-up company name
genderf, m or NoneFor the Module 10 fairness check only
subjectWhere is my order?, Web form: Workshop, Review: Harbour
bodythe full raw textWith greetings, signatures, quoted replies, HTML and noise
intent, sentimentwarranty_fault, negGold labels from Ines's definitions
stars1 to 5, or NoneReviews only
entities[(18, 24, "PRODUCT"), ...]Character spans in body
sku, order_idTW-BATT-500, TW-104233The main product and the order, where the message has one
duplicate_ofTW-M0112 or NoneNear-duplicates
core, en_refclean text; English referenceen_ref is None for English messages
label_flippedFalseTrue for the 36 deliberate label errors
split, periodtrain; before_july

3a. What the generator made

A summary of the inbox. Lessons quote these numbers; nothing later depends on this block.

python
from collections import Counter

def share(rows, key):
    c = Counter(r[key] for r in rows)
    return {k: round(v / len(rows), 3) for k, v in c.most_common()}

print("intents :", dict(Counter(m["intent"] for m in MESSAGES).most_common()))
print("channels:", dict(Counter(m["channel"] for m in MESSAGES).most_common()))
for period in ["before_july", "from_july"]:
    rows = [m for m in MESSAGES if m["period"] == period]
    print(f"{period:11}: {len(rows)} messages, languages {share(rows, 'language')}")
print("splits  :", dict(Counter(m["split"] for m in MESSAGES)))
print("warranty_fault by month:", dict(sorted(Counter(m["date"][:7] for m in MESSAGES if m["intent"] == "warranty_fault").items())))
print("negative share by month:", {mo: round(sum(m["sentiment"] == "neg" for m in MESSAGES if m["date"][:7] == mo) / n, 2)
                                   for mo, n in sorted(Counter(m["date"][:7] for m in MESSAGES).items())})
print("entities:", dict(Counter(l for m in MESSAGES for _, _, l in m["entities"]).most_common()))
print("near-duplicates:", sum(m["duplicate_of"] is not None for m in MESSAGES), "| deliberately wrong labels:", sum(m["label_flipped"] for m in MESSAGES))
print("noise: html", sum("<p>" in m["body"] for m in MESSAGES), "| mojibake", sum("Ã" in m["body"] or "Â" in m["body"] for m in MESSAGES),
      "| zero-width", sum("\u200b" in m["body"] for m in MESSAGES), "| non-breaking space", sum("\xa0" in m["body"] for m in MESSAGES),
      "| quoted reply", sum("\n> " in m["body"] for m in MESSAGES))
reviews = [m for m in MESSAGES if m["stars"]]
print("reviews:", len(reviews), "| stars that disagree with the text:", sum((m["stars"] >= 4 and m["sentiment"] == "neg") or (m["stars"] <= 2 and m["sentiment"] == "pos") or (m["sentiment"] == "neu" and m["stars"] != 3) for m in reviews))

Code walkthrough

  • share turns counts into proportions, most common first.
  • The printed lines count intents, channels, languages before and after 1 July, the splits, warranty faults by month, the share of negative messages by month, entity labels, near-duplicates and deliberate label errors, each kind of noise, and reviews whose stars disagree with their text.
  • What to notice: order_status is the largest intent and praise the smallest, on purpose. English falls from 95.3% of messages before July to 61.8% from July. Warranty faults go from 33 in May to 71 in June and 117 in July, after the battery notice, and the negative share rises from 0.20 in April to about 0.40 from June. These are the patterns Modules 5 and 9 find.

Output:

text
intents : {'order_status': 448, 'warranty_fault': 350, 'product_question': 278, 'returns_refund': 266, 'repair_booking': 239, 'spam': 124, 'praise': 95}
channels: {'email': 945, 'web form': 724, 'review': 131}
before_july: 941 messages, languages {'en': 0.953, 'de': 0.019, 'fr': 0.015, 'es': 0.013}
from_july  : 859 messages, languages {'en': 0.618, 'de': 0.153, 'es': 0.12, 'fr': 0.109}
splits  : {'train': 1262, 'test': 271, 'dev': 267}
warranty_fault by month: {'2026-03': 26, '2026-04': 28, '2026-05': 33, '2026-06': 71, '2026-07': 117, '2026-08': 75}
negative share by month: {'2026-03': 0.23, '2026-04': 0.2, '2026-05': 0.3, '2026-06': 0.41, '2026-07': 0.4, '2026-08': 0.39}
entities: {'PRODUCT': 1465, 'DATE': 809, 'ORDER_ID': 751, 'PERSON': 609, 'MONEY': 276, 'LOC': 120}
near-duplicates: 54 | deliberately wrong labels: 36
noise: html 459 | mojibake 19 | zero-width 45 | non-breaking space 58 | quoted reply 213
reviews: 131 | stars that disagree with the text: 6

Known quirks, kept on purpose

These were found while the lessons were built. The generator stays as it is, so the fingerprints stay the same; lessons quote the numbers below as measured.

  • Mojibake. The summary's mojibake 19 counts only messages containing à or Â. In all, 29 messages are garbled: the other ten are garbled emoji and zero-width spaces, which contain neither letter (Module 1 Lesson 2 measures this).
  • Tagged sentences (block 6). spaCy's en_core_web_sm 3.8.0 tags 3 of the 147 POS_GOLD tokens differently from the hand tags: "Where" as SCONJ, "squeal" as ADJ and "Ines" as NOUN.
  • The repair-booking wording "Is {MONEY} still the price?" fills {MONEY} with the price of the bike, not of the service. It is kept on purpose and taught as a customer misquoting a price.
  • Sunday bookings. 25 of the 186 English repair bookings ask for a Sunday, when the workshop is shut (KB14); Module 8's validator rejects them.
  • Email addresses in core. core is the message without greeting, signature and quoted reply, but 153 messages still contain the customer's email address in core, because the wording itself names it. Redact core too before storing or sending it.
  • The word2vec corpus (block 10). The who slot includes ines (with dev, callum, the mechanic and others), so the corpus puts Ines in a "who fixed it" slot although she does not fix bikes.

4. The hand-written test set

Eighty messages written by hand, not by the templates, and labelled against Ines's definitions. This is the headline test set for every model in the course, because a model that learned the templates will look far better on template messages than it really is. The messages include sarcasm, two requests in one message, code-switching (two languages mixed), slang, very short and very long messages. Forty are English; the forty German, Spanish and French messages each have an English reference translation, written by hand. This block works on its own.

python
import 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

  • Entities are marked inside the text as [[text|LABEL]], which keeps each message readable on one line. unmark removes the marks and returns the clean text with the character spans, in the same (start, end, label) form as MESSAGES.
  • Each row of HARD is (language, kind, intent, second intent, sentiment, marked text, English reference). kind says what makes the message hard; second_intent is set when a message asks for two things, and intent is the one that needs action first.
  • Product codes such as TW-BATT-500, batch codes such as VB-0426 and courier names are not marked as entities, the same rule as in MESSAGES.
  • The output shows 80 messages, the language split, the intents (every intent appears at least 7 times), the kinds of difficulty, and the first message with its two entities.

Output:

text
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')]

5. Out-of-scope messages

Sixty messages that fit none of the seven intents: job applications, emails meant for another business, and questions Tidewell cannot help with. Module 5 uses them to teach a classifier to say "none of these" instead of guessing. This block works on its own.

python
OOS_BASE = [   # messages Tidewell cannot act on: none of the seven intents fits
 "I'm a qualified bike mechanic looking for work. Are you hiring?",
 "Please find my CV attached for any workshop or shop roles.",
 "Do you take students for a week of work experience in the summer?",
 "Is this Tidewater Kayaks? I booked a kayak tour for Saturday.",
 "I think I emailed the wrong company, I was trying to reach my landlord about the boiler.",
 "Are you the hardware shop on the high street? Do you cut keys?",
 "My carbon road bike from another brand creaks when I pedal hard. Any idea why?",
 "Which electric scooter would you recommend for a 10 mile commute?",
 "Can you help me set up the satnav in my car?",
 "What time does the farmers' market by the harbour open on Sundays?",
 "Can you recommend a good café near the harbour for a team lunch?",
 "I found a wallet outside your shop yesterday. Who should I give it to?",
 "Is the cycle path along the river closed for the festival this weekend?",
 "My neighbour's bike alarm goes off every night. Who do I complain to?",
 "Do you know a good physiotherapist for a knee injury from running?",
 "Can I park my van in your loading bay for an hour on Friday?",
 "We are organising a charity ride in October. Would you like to sponsor a water station?",
 "My son's school wants a speaker on careers in engineering. Would anyone come?",
 "What is the best way to learn to ride a unicycle as an adult?",
 "Could you tell me the bus times from Temple Meads to Clifton?",
]
OPENERS_OOS = ["", "Hi, ", "Hello there, quick one: "]
OOS = [{"id": f"OOS{k + 1:02d}", "text": OPENERS_OOS[k // 20] + (t[0].lower() + t[1:] if k // 20 else t)}
       for k, t in enumerate(OOS_BASE * 3)]
print(len(OOS), "out-of-scope messages;", OOS[25]["text"])

Code walkthrough

  • OOS_BASE holds twenty hand-written messages. OOS repeats them three times, the second and third time with a different opening ("Hi, ", "Hello there, quick one: "), so the set has sixty rows with ids OOS01 to OOS60.
  • The output shows the count and message OOS26, the hardware-shop question with "Hi, " in front.

Output:

text
60 out-of-scope messages; Hi, are you the hardware shop on the high street? Do you cut keys?

6. Hand-tagged sentences

Twenty short sentences in the style of the inbox, tagged by hand with Universal POS tags (the part-of-speech tags spaCy reports in token.pos_). Module 3 uses them to measure how accurate a tagger is. This block works on its own.

python
POS_GOLD_TAGGED = [   # hand-tagged with Universal POS tags; tokens split as spaCy splits them
 "My/PRON battery/NOUN will/AUX not/PART charge/VERB ./PUNCT",
 "Where/ADV is/AUX my/PRON order/NOUN ?/PUNCT",
 "The/DET new/ADJ Volt/PROPN 2/NUM arrived/VERB yesterday/NOUN ./PUNCT",
 "Can/AUX I/PRON book/VERB a/DET service/NOUN for/ADP Friday/PROPN ?/PUNCT",
 "The/DET brakes/NOUN squeal/VERB because/SCONJ the/DET pads/NOUN are/AUX old/ADJ ./PUNCT",
 "I/PRON paid/VERB £/SYM 549/NUM for/ADP the/DET battery/NOUN ./PUNCT",
 "Thank/VERB you/PRON for/ADP the/DET quick/ADJ reply/NOUN ./PUNCT",
 "Dev/PROPN fixed/VERB the/DET wheel/NOUN in/ADP ten/NUM minutes/NOUN ./PUNCT",
 "The/DET parcel/NOUN was/AUX left/VERB outside/ADV in/ADP the/DET rain/NOUN ./PUNCT",
 "Please/INTJ send/VERB me/PRON a/DET returns/NOUN label/NOUN ./PUNCT",
 "Does/AUX the/DET lock/NOUN fit/VERB this/DET frame/NOUN ?/PUNCT",
 "We/PRON ordered/VERB two/NUM bikes/NOUN and/CCONJ one/NUM lock/NOUN ./PUNCT",
 "It/PRON stopped/VERB working/VERB after/ADP three/NUM weeks/NOUN ./PUNCT",
 "The/DET display/NOUN shows/VERB an/DET error/NOUN code/NOUN ./PUNCT",
 "I/PRON do/AUX n't/PART want/VERB a/DET refund/NOUN ./PUNCT",
 "My/PRON daughter/NOUN loves/VERB her/PRON new/ADJ bike/NOUN ./PUNCT",
 "Could/AUX you/PRON call/VERB me/PRON tomorrow/NOUN morning/NOUN ?/PUNCT",
 "The/DET chain/NOUN came/VERB off/ADP twice/ADV on/ADP the/DET hill/NOUN ./PUNCT",
 "Ines/PROPN replied/VERB very/ADV quickly/ADV ./PUNCT",
 "Is/AUX the/DET Harbour/PROPN available/ADJ in/ADP a/DET smaller/ADJ size/NOUN ?/PUNCT",
]
POS_TEXTS = ["My battery will not charge.", "Where is my order?", "The new Volt 2 arrived yesterday.",
             "Can I book a service for Friday?", "The brakes squeal because the pads are old.",
             "I paid £549 for the battery.", "Thank you for the quick reply.", "Dev fixed the wheel in ten minutes.",
             "The parcel was left outside in the rain.", "Please send me a returns label.", "Does the lock fit this frame?",
             "We ordered two bikes and one lock.", "It stopped working after three weeks.", "The display shows an error code.",
             "I don't want a refund.", "My daughter loves her new bike.", "Could you call me tomorrow morning?",
             "The chain came off twice on the hill.", "Ines replied very quickly.", "Is the Harbour available in a smaller size?"]
POS_GOLD = [{"text": text, "tokens": [w.rsplit("/", 1)[0] for w in tagged.split()], "tags": [w.rsplit("/", 1)[1] for w in tagged.split()]}
            for text, tagged in zip(POS_TEXTS, POS_GOLD_TAGGED)]
print(len(POS_GOLD), "tagged sentences,", sum(len(s["tokens"]) for s in POS_GOLD), "tokens")

Code walkthrough

  • Each tagged string is token/TAG pairs separated by spaces. The tokens are split as spaCy's English tokeniser splits them, so £549 is two tokens and don't is do and n't; this was checked with spacy.blank("en") (spaCy 3.8.16) on 14 October 2026.
  • POS_TEXTS holds the same sentences as ordinary text. POS_GOLD joins the two into dictionaries with text, tokens and tags.
  • The tags follow the Universal Dependencies guidelines: forms of "be", "do", "can" and "will" used as helpers are AUX, "please" is INTJ, "yesterday" and "tomorrow" are NOUN, and the "off" in "came off" is ADP.
  • The output shows 20 sentences and 147 tokens.

Output:

text
20 tagged sentences, 147 tokens

7. The help centre

The twenty-four help-centre articles Callum owns, KB01 to KB24. They are Tidewell's written policy, the documents Ines's search looks through (Module 6) and the passages the question-answering reader reads (Module 8). This block works on its own.

python
HELP_CENTRE = {   # id: (title, text); Callum owns these articles
 "KB01": ("Returning an item", "You can return most items within 30 days of delivery for a full refund, as long as they are unused and in their original packaging. Sale items follow the same rules as full-price items. To start a return, reply to your order confirmation email or use the returns form on our website, and tell us the order number and which items you are sending back. In the UK we email you a free returns label within one working day. Customers in Germany, Spain and France pay for return postage, and we take 14.95 pounds off the refund to cover it. Pack the item securely, attach the label and keep the proof of postage until your refund arrives. Bikes and e-bikes have their own rules, explained in Returning a bike or e-bike. Gift cards, worn helmets and fitted parts cannot be returned unless they are faulty. If an item arrived damaged or stopped working, please do not use this route: report it as a fault instead, so that we can repair or replace it without you paying for postage."),
 "KB02": ("When will I get my refund?", "We refund to the payment method you used, so a card payment goes back to the same card and a PayPal payment goes back to your PayPal account. Once your return reaches our warehouse in Bristol we check it and send the refund within 5 working days. We email you when we have sent it. Your bank may then take another 3 to 5 working days to show the money in your account, which is outside our control. If you paid with a gift card, the amount goes back onto a new gift card. If you were charged twice for the same order, tell us the order number and the two amounts and we will refund the duplicate payment in full, usually the same day. A refund for a cancelled order is sent when the cancellation is confirmed. If more than 10 working days have passed since we told you the refund was sent, reply to that email with a screenshot of your statement and we will trace it."),
 "KB03": ("Returning a bike or e-bike", "Bikes and e-bikes can be returned within 14 days of delivery if they have not been ridden outdoors and are in the original box with all parts and paperwork. Because a bike is too large for a normal parcel, we arrange the collection for you. In the UK collection is free. In Germany, Spain and France the collection costs 79 pounds, which we take off the refund. Before collection, remove any accessories you fitted yourself, put the pedals and the handlebar back into the position they arrived in, and pack the bike in its box. An e-bike battery must stay in the bike and be switched off. We check every returned bike in the workshop, and the refund follows within 5 working days of the bike arriving. A bike that has been ridden, damaged or modified can only be refunded in part, and we will tell you the amount before we send any money. Children's bikes, including the Harbour Kids 20, follow the same 14 day rule."),
 "KB04": ("What our warranty covers", "Every bike we sell comes with our own warranty from the day of delivery. Frames are covered for 5 years. E-bike motors and batteries are covered for 2 years. All other parts, such as gears, brakes, lights and locks, are covered for 1 year. The warranty covers faults in materials and manufacture. It does not cover normal wear, such as tyres, inner tubes, brake pads, chains and cassettes, or damage from a crash, a fall or a modification. If you think something is faulty, send us your order number, a short description and, if you can, a photo or short video. We will tell you whether to bring the bike to the workshop, send it to us or have a local shop look at it. For customers in Germany, Spain and France we pay for the courier both ways on any accepted warranty claim. A repair or replacement under warranty does not restart the warranty period. Your legal rights as a consumer are not affected by this warranty."),
 "KB05": ("Volt 2 battery batch VB-0426", "On 9 June 2026 we emailed every Volt 2 and Volt 2 Step-Through owner about a fault in some Volt 2 battery packs (500 Wh) from production batch VB-0426. These packs were fitted to bikes sold between March and May 2026 and were also sold separately. An affected pack may stop charging or run flat much faster than normal. To check your pack, switch the bike off, remove the battery and look at the white label underneath it. If the batch code says VB-0426, stop using that battery and contact us with your order number and a photo of the label. We will replace the pack free of charge, even if it seems to work. You can swap it at our Bristol workshop in about 20 minutes, or we can send a new pack by courier with a prepaid box for the old one. If your batch code is different, your battery is not part of this replacement, but our normal 2 year warranty still applies to it."),
 "KB06": ("Caring for your e-bike battery", "A little care makes an e-bike battery last for years. Charge it indoors at room temperature, using only the charger that came with the bike. A full charge from empty takes 4 to 5 hours, and you can charge it at any level; there is no need to run it flat first. If you will not ride for more than a few weeks, store the battery at between 40 and 60 percent charge in a cool, dry place, and top it up every two months. Do not leave the battery in a freezing shed or in a hot car. Keep the contacts clean and dry, and never open the battery case or use a pack that is swollen, cracked or smells of burning. A battery slowly loses capacity with age, so a pack will hold less charge after a few hundred full charges; that is normal and not a fault. If the charger light does not change colour after an hour, try another socket, then contact us. Battery faults on the Volt 2 from batch VB-0426 are covered in their own article."),
 "KB07": ("How far will my e-bike go?", "The Volt 2 and Volt 2 Step-Through will usually go between 40 and 80 kilometres on one charge, which is about 25 to 50 miles. Where you land in that range depends on the assistance level, the weight of the rider and luggage, hills, wind, tyre pressure and temperature. The lowest assistance level gives the longest range, and the highest level gives the shortest. Cold weather lowers range: below 5 degrees you may get a third less than in summer. Stopping and starting in town also uses more power than a steady ride. Keep your tyres pumped to the pressure printed on the tyre wall, because soft tyres cost range. If your range suddenly drops by half or more from one week to the next, that is not normal ageing. Check the batch code on your battery first, then book an E-bike Diagnostic so that we can test the battery and the motor. A second Volt 2 battery pack (500 Wh) doubles your range and fits the same frame mount."),
 "KB08": ("Delivery in the UK", "We deliver to all UK mainland addresses. Parcels such as accessories, locks and lights arrive in 2 to 4 working days. Delivery is free on orders over 50 pounds and costs 4.95 pounds below that. Bikes and e-bikes arrive in 3 to 5 working days and cost 29 pounds to deliver, because they travel on a pallet with a two-person crew. We send your order the same day if you order before 1 pm on a working day. You get an email with a tracking link as soon as your order leaves the warehouse. Deliveries to the Scottish Highlands, Northern Ireland and the islands can take two extra days. If you will not be in, you can choose a safe place or a neighbour from the tracking page. Bikes always need a signature. During our summer sale from 15 to 28 July 2026 orders took up to two extra working days to leave the warehouse."),
 "KB09": ("Delivery to Germany, Spain and France", "Since 1 July 2026 we deliver to Germany, Spain and France. Parcels arrive in 4 to 7 working days and cost 14.95 pounds. Bikes and e-bikes arrive in 5 to 8 working days and cost 79 pounds. Our prices include any import charges, so you will not be asked to pay duty or VAT on delivery. If a courier asks you for money, do not pay it and contact us. You get a tracking link by email when your order leaves our Bristol warehouse. We can only deliver to addresses on the mainland, so we cannot yet send orders to the Canary Islands, the Balearic Islands or Corsica. Prices on our website are in pounds, and your bank converts them when you pay; it may charge a small fee. Our help team now replies in English, German, Spanish and French. Warranty claims from these countries are handled by courier, and we pay for the courier both ways on any accepted claim."),
 "KB10": ("Tracking your parcel and our courier", "When your order leaves the warehouse we email you a tracking link. Until 9 August 2026 our courier was ParcelNorth. From 10 August 2026 all our parcels and bikes travel with SwiftDrop, so your tracking link and any text messages now come from SwiftDrop. If the tracking page says delivery was attempted, SwiftDrop will try again the next working day and leave a card with a link to rearrange. After three attempts the parcel comes back to us, and we contact you to arrange another delivery for free. If the tracking has not changed for 3 working days, or says exception, contact us with your order number and we will chase the courier for you. Please do not open a claim with the courier yourself; we do that, because the parcel is ours until it reaches you. You can change the delivery day or choose a neighbour from the SwiftDrop link up to 8 pm on the day before delivery."),
 "KB11": ("Missing or damaged parcels", "If your parcel arrives damaged, or an item is missing, tell us within 48 hours of delivery. Send your order number, photos of the box and the item, and a list of what is missing or broken. Keep all the packaging until we have replied, because the courier may need to see it. We will send a replacement part or item free of charge, or arrange a collection if the item cannot be used. If the tracking says your parcel was delivered but you cannot find it, check with your neighbours and any safe place you chose, then contact us. We will open an investigation with the courier, which can take up to 5 working days, and send you a replacement or a refund when it is finished. If a bike arrives with a part that is bent or cracked, please do not ride it; we will either send the part or book a repair with our workshop or a shop near you at our cost. Missing chargers, keys and manuals are posted to you within 2 working days."),
 "KB12": ("Choosing a frame size", "Choosing the right frame size matters more than any other choice you make. Our adult bikes come in three frame sizes, chosen by your height. Small fits riders from 155 to 168 cm, Medium from 168 to 180 cm and Large from 180 to 193 cm. If you are between two sizes, pick the smaller one for a more upright, relaxed ride, or the larger one if you have long legs or like to ride fast. The Harbour comes in all three sizes. The Causeway comes in Medium and Large. The Volt 2 comes in Medium and Large, while the Volt 2 Step-Through has a low frame that is easy to step over and comes in one size that suits riders from 150 to 185 cm. You can try any bike at our Bristol shop before you buy. If the size you ordered is wrong, you can exchange it within 14 days, as long as the bike has not been ridden outdoors."),
 "KB13": ("Children's bikes", "The Harbour Kids 20 has 20 inch wheels and suits most children aged 6 to 9, or from about 115 to 135 cm tall. A good test is that your child can stand over the frame with both feet flat on the ground. It has a light aluminium frame, seven gears, two hand brakes that small hands can reach, and a chain guard to keep clothes clean. It does not come with stabilisers, and we do not recommend fitting them to a bike of this size. It arrives 90 percent built, like our adult bikes. A Basic Service is free at our Bristol workshop within the first 3 months, which is a good time to check the brakes and gears once everything has settled. Children grow quickly, so we offer to buy back any Harbour Kids 20 in good condition for 30 percent of its price as credit towards an adult bike. A helmet is not included, and we do not currently sell children's helmets."),
 "KB14": ("Booking a service or repair", "Our workshop in Bristol is open Monday to Saturday from 9:00 to 17:30. You can book online, by replying to any of our emails or by calling in. A Basic Service costs 65 pounds and takes about an hour: we check and adjust the gears and brakes, check the tyres and wheels, oil the chain and tighten every bolt. A Full Service costs 129 pounds and takes one working day: we strip and clean the drivetrain, check the bearings, true the wheels and replace worn cables. A Puncture Repair costs 18 pounds and is done the same day if you bring the bike in before 10:00. Prices for parts are extra, and we always ask before fitting anything that costs more than 20 pounds. Drop your bike off from 9:00 on the day of your booking. To move a booking, reply to your confirmation email at least 24 hours before; you will need your repair ticket number, which looks like RJ-2026-0412."),
 "KB15": ("What happens at a Full Service", "A Full Service is the most thorough care we offer, and we recommend it once a year for a bike you ride every day. It takes one working day and costs 129 pounds. We start by washing the bike and taking off the chain, cassette and chainset. These parts are cleaned in a solvent bath, measured for wear and refitted or replaced with your agreement. We check the headset and bottom bracket bearings, true both wheels, and replace the gear and brake cables. Brakes are bled or adjusted, and brake pads are replaced if they are worn. On an e-bike we also check the motor mounting, the wiring and the battery contacts, and update the motor software if a new version is available. At the end a mechanic takes the bike for a short test ride and writes a report of what was done and anything that will need attention soon. Parts are charged on top of the service price. A Full Service includes everything in a Basic Service."),
 "KB16": ("The E-bike Diagnostic", "If your e-bike is not behaving as it should, an E-bike Diagnostic is the fastest way to find out why. It costs 45 pounds and takes about 45 minutes at our Bristol workshop. We connect the bike to the maker's software, read any error codes, test the battery's health and capacity, check the motor and the sensors, and update the software. Common error codes include E07, which points to a fault in the motor or its sensor, and E10, which points to the battery connection. If the diagnostic finds a fault covered by the warranty, there is nothing to pay. If you book a repair after the diagnostic, we take the 45 pounds off the price of the repair. You will get a short written report by email that shows the battery's remaining capacity as a percentage of new. Customers outside the UK can ask a local shop to run the same test; we will tell them which software to use."),
 "KB17": ("Punctures and tyres", "Most punctures are caused by sharp stones, glass or thorns, or by tyres that are too soft. Pump your tyres to the pressure printed on the tyre wall at least once a week, because a soft tyre is much more likely to pinch and puncture. Our bikes come with puncture resistant tyres, which have a protective layer under the tread. They make punctures less likely, but no tyre is puncture proof. Tyres and inner tubes are wear parts and are not covered by the warranty. If you get a puncture, our workshop can repair it the same day for 18 pounds if you bring the bike in before 10:00. If you would rather fix it yourself, we sell inner tubes for every bike we stock, and our tools fit every valve on our bikes. Replace a tyre when the tread has worn flat, the protective layer shows through or the side wall is cracked. If you get several punctures in a month, ask us to check the rim tape and the tyre for anything stuck in it."),
 "KB18": ("Paying for your order", "We accept Visa, Mastercard, American Express, PayPal and Tidewell gift cards. All prices are in pounds and include VAT. We take payment when your order is placed. For orders over 500 pounds you can spread the cost over 12 months with no interest through our finance partner; you will see the option at checkout, and the partner runs a credit check before you are accepted. If a payment fails, your bank has usually blocked it for security, so try again or contact your bank. If you see two payments for the same order, one is usually a temporary hold that disappears within 5 working days. If both payments are taken, tell us the order number and we will refund the duplicate payment straight away. We never ask for your card details by email or text message, and we will never ask you to confirm a payment on a website other than tidewell.example. If someone does, please forward the message to us and do not click the link."),
 "KB19": ("Gift cards", "Tidewell gift cards are available from 25 pounds to 200 pounds and can be spent online or in our Bristol shop on bikes, accessories and workshop services. You can buy a printed card in the shop or an e-gift card on our website, which is sent by email at the time you choose. A gift card is valid for 24 months from the date it was bought. You can use more than one gift card on an order, and if your order costs more than the card, you pay the difference with another payment method. Any balance that is not spent stays on the card until it expires. Gift cards cannot be exchanged for cash and cannot be returned once they have been sent, but if an e-gift card email never arrives, we will resend it to the same or a new address. To check a balance, enter the code on the gift card page of our website. A lost printed card can only be replaced if you still have the receipt."),
 "KB20": ("Changing or cancelling an order", "You can change or cancel an order free of charge until it leaves our warehouse. Reply to your order confirmation email with your order number, which looks like TW-104233, and tell us what you want to change. Most orders leave the same working day if they were placed before 1 pm, so please write as soon as you can. If the order has already left, we cannot stop it; instead, refuse the delivery or return the items under our normal returns rules once they arrive. A cancelled order is refunded in full to the original payment method within 5 working days. You cannot add items to an order once it has been placed, but you can place a second order, and if both are still in the warehouse we will try to send them together. To change the delivery address after dispatch, use the link in your tracking email. Workshop bookings are changed separately, by replying to the booking confirmation."),
 "KB21": ("Your data and privacy", "We only collect the personal information we need to sell you bikes, deliver your orders and look after your warranty, such as your name, address, email address, phone number and order history. We never sell your personal information. Messages you send to our help team are kept for 18 months after your last order or message and are then deleted. Before we store a message, our systems remove card numbers and other payment details from it. We use your messages to answer you and, with personal details removed, to improve our help-centre articles and our replies. You can ask for a copy of the personal information we hold about you, or ask us to correct or delete it, by emailing privacy@tidewell.example. Amara, who looks after data protection at Tidewell, replies within one month. If you are unhappy with how we have handled your information, you can complain to the data protection authority where you live. Marketing emails are only sent if you have opted in, and every one has an unsubscribe link."),
 "KB22": ("Contacting us", "The quickest way to reach us is by email at help@tidewell.example. We reply within one working day, Monday to Saturday, and usually much sooner. Since 1 July 2026 you can write to us in English, German, Spanish or French, and we will reply in the same language. Please put your order number or repair ticket number in your message, because it lets us find your order straight away. If you are writing about a fault, a photo or short video helps a lot. Our Bristol shop and workshop are open Monday to Saturday from 9:00 to 17:30 and closed on Sundays and bank holidays. You can also use the contact form on our website, which sends your message to the same team. For anything to do with your personal data, write to privacy@tidewell.example instead. We do not offer support by phone at the moment, but if a call would help, tell us when you are free and someone from the team will ring you. We never ask for passwords or card details."),
 "KB23": ("Locks and keeping your bike safe", "A good lock is the best protection for your bike. We recommend a strong D-lock such as the LockSure D-lock, which costs 45 pounds and comes with three keys. Lock the frame and the back wheel to something solid and fixed, such as a cycle stand, and keep the lock off the ground so that it is harder to attack. Take lights, panniers and the e-bike display with you, and remove the e-bike battery if you are leaving the bike for a long time. Write down your frame number, which is printed under the bottom bracket, and keep a photo of your bike; the police will ask for both if it is stolen. If your bike is stolen, report it to the police first and then to your insurer. Lost keys can be replaced: send us the number stamped on your key and we will post two new ones for 10 pounds. A lock that sticks or will not open is a fault and is covered by our 1 year warranty on parts."),
 "KB24": ("Setting up a delivered bike", "Bikes are delivered 90 percent built in a large box. You need to fit the handlebar, the pedals and the front wheel, and pump up the tyres. The tools you need are in the small box inside, together with a printed guide; it takes most people about 30 minutes. Start by cutting the cable ties carefully, so that you do not cut any brake or gear cables. Turn the handlebar so that it is straight and in line with the front wheel, then tighten the stem bolts evenly. The pedals are marked L and R: the left pedal tightens anticlockwise and the right pedal tightens clockwise. Check that the front wheel's quick release or axle is fully closed and that both brakes stop the wheels firmly before your first ride. If anything looks bent, loose or crooked when you open the box, stop and send us a photo. If you would rather not build the bike yourself, our Bristol workshop will set it up for free, or a local shop can do it and we will refund up to 30 pounds."),
}
words = [len(t.split()) for _, t in HELP_CENTRE.values()]
print(len(HELP_CENTRE), "articles,", min(words), "to", max(words), "words each,", sum(words), "words in all")
print("KB05:", HELP_CENTRE["KB05"][0])

Code walkthrough

  • HELP_CENTRE maps each article id to (title, text). The facts in the articles are the policy listed in 03-the-tidewell-inbox.md; lessons quote them from here.
  • The articles cover returns, refunds, warranty, the battery batch, battery care and range, delivery in the UK and to the EU, the courier, missing parcels, sizes, children's bikes, the workshop, punctures, payments, gift cards, order changes, privacy, contact and locks.
  • The output counts 24 articles of 155 to 182 words each, 4,049 words in all, and names KB05, the battery-batch article.

Output:

text
24 articles, 155 to 182 words each, 4049 words in all
KB05: Volt 2 battery batch VB-0426

8. Search queries and questions

Sixty search queries, each with the article that answers it, and forty questions, each with the exact words in the article that answer it. Eight questions cannot be answered from their article, on purpose. It needs block 7.

python
SEARCH_QUERIES = [   # (query as Ines would type it, the article that answers it)
 ("how do I send something back", "KB01"), ("return unused pannier bag", "KB01"), ("returns label EU customer cost", "KB01"),
 ("refund not arrived yet", "KB02"), ("money back how long", "KB02"), ("charged twice same order", "KB02"),
 ("can I return an e-bike I bought last week", "KB03"), ("send bike back collection fee Spain", "KB03"),
 ("is the motor covered", "KB04"), ("warranty on frame how many years", "KB04"), ("brake pads worn is that warranty", "KB04"),
 ("VB-0426", "KB05"), ("battery recall email June", "KB05"), ("my battery label batch code", "KB05"),
 ("how long to charge battery", "KB06"), ("store battery over winter", "KB06"), ("battery swollen", "KB06"),
 ("range of the Volt 2", "KB07"), ("how many miles per charge", "KB07"), ("e-bike goes less far in cold", "KB07"),
 ("UK delivery cost", "KB08"), ("free delivery threshold", "KB08"), ("when will my bike arrive UK", "KB08"),
 ("do you ship to Germany", "KB09"), ("customs charges France", "KB09"), ("deliver to Canary Islands", "KB09"),
 ("SwiftDrop", "KB10"), ("tracking says exception", "KB10"), ("courier missed me three times", "KB10"),
 ("parcel arrived broken", "KB11"), ("charger missing from box", "KB11"), ("says delivered but not here", "KB11"),
 ("what size frame for 172 cm", "KB12"), ("step-through frame height range", "KB12"), ("between two sizes", "KB12"),
 ("bike for a 7 year old", "KB13"), ("stabilisers kids bike", "KB13"), ("children's helmet", "KB13"),
 ("book a service", "KB14"), ("workshop opening hours Saturday", "KB14"), ("move my repair booking", "KB14"),
 ("what is included in full service", "KB15"), ("annual service e-bike software update", "KB15"),
 ("error E07", "KB16"), ("battery health test", "KB16"), ("diagnostic fee refunded if repaired", "KB16"),
 ("keep getting flat tyres", "KB17"), ("puncture fixed same day", "KB17"),
 ("pay in instalments", "KB18"), ("payment declined", "KB18"), ("text asking for card details", "KB18"),
 ("gift card expiry", "KB19"), ("e-gift card never arrived", "KB19"),
 ("cancel order before it ships", "KB20"), ("change delivery address", "KB20"),
 ("delete my data", "KB21"), ("how long do you keep my emails", "KB21"),
 ("do you reply in Spanish", "KB22"), ("bike stolen what do I do", "KB23"), ("handlebars crooked after unboxing", "KB24"),
]
QA_PAIRS = [   # (question, article, answer: an exact span of the article text, or None when the article cannot answer)
 ("How long do I have to return an unused item?", "KB01", "within 30 days of delivery"),
 ("How much do EU customers pay for return postage?", "KB01", "14.95 pounds"),
 ("How soon after my return arrives is the refund sent?", "KB02", "within 5 working days"),
 ("What happens to a refund if I paid with a gift card?", "KB02", "the amount goes back onto a new gift card"),
 ("How long do I have to return a bike?", "KB03", "within 14 days of delivery"),
 ("What does bike collection cost in Spain?", "KB03", "79 pounds"),
 ("How long is the frame warranty?", "KB04", "5 years"),
 ("Are brake pads covered by the warranty?", "KB04", "It does not cover normal wear"),
 ("Which batch of Volt 2 batteries is affected?", "KB05", "VB-0426"),
 ("Where is the batch code printed?", "KB05", "the white label underneath it"),
 ("How long does a battery swap take at the workshop?", "KB05", "about 20 minutes"),
 ("How long does a full charge take?", "KB06", "4 to 5 hours"),
 ("At what charge level should I store the battery?", "KB06", "between 40 and 60 percent"),
 ("What range does the Volt 2 have in kilometres?", "KB07", "between 40 and 80 kilometres"),
 ("How much range can I lose in the cold?", "KB07", "a third less than in summer"),
 ("When did delivery to Germany start?", "KB09", "1 July 2026"),
 ("Do I pay import VAT when my bike arrives in France?", "KB09", "you will not be asked to pay duty or VAT on delivery"),
 ("Which courier do you use now?", "KB10", "SwiftDrop"),
 ("What happens after three failed delivery attempts?", "KB10", "the parcel comes back to us"),
 ("How quickly must I report a damaged parcel?", "KB11", "within 48 hours of delivery"),
 ("Which frame size fits someone 175 cm tall?", "KB12", "Medium from 168 to 180 cm"),
 ("Do you sell children's helmets?", "KB13", "we do not currently sell children's helmets"),
 ("How much is a Basic Service?", "KB14", "65 pounds"),
 ("When is the workshop open?", "KB14", "Monday to Saturday from 9:00 to 17:30"),
 ("What does error E07 mean?", "KB16", "a fault in the motor or its sensor"),
 ("Is the diagnostic fee refunded if I book a repair?", "KB16", "we take the 45 pounds off the price of the repair"),
 ("Can I pay in instalments?", "KB18", "spread the cost over 12 months with no interest"),
 ("How long is a gift card valid?", "KB19", "24 months from the date it was bought"),
 ("Can I add an item to an order I already placed?", "KB20", "You cannot add items to an order once it has been placed"),
 ("How long do you keep my messages?", "KB21", "18 months after your last order or message"),
 ("Is there a phone number for support?", "KB22", "We do not offer support by phone at the moment"),
 ("How many keys come with the LockSure D-lock?", "KB23", "three keys"),
 ("Is the Volt 2 waterproof enough to ride in heavy rain?", "KB07", None),
 ("How much does a replacement e-bike motor cost?", "KB16", None),
 ("How much does the Harbour Kids 20 weigh?", "KB13", None),
 ("How many gears does the Causeway have?", "KB12", None),
 ("What is the name of your finance partner?", "KB18", None),
 ("Can I collect my online order from the Bristol shop?", "KB08", None),
 ("How much does a spare e-bike charger cost?", "KB06", None),
 ("Do you deliver on Sundays?", "KB08", None),
]
for _, art, ans in QA_PAIRS:   # every answer must be copied exactly from its article
    assert ans is None or ans in HELP_CENTRE[art][1], ans
print(len(SEARCH_QUERIES), "search queries over", len({a for _, a in SEARCH_QUERIES}), "articles")
print(len(QA_PAIRS), "questions,", sum(a is None for *_, a in QA_PAIRS), "with no answer in the article")

Code walkthrough

  • SEARCH_QUERIES pairs what Ines might type with the right article. Some share words with the article ("UK delivery cost"); some share none ("money back how long"); some are codes ("VB-0426"); some are typed in a hurry ("says delivered but not here"). Module 6 scores search with recall at 5 and mean reciprocal rank on them.
  • QA_PAIRS holds (question, article, answer). The answer is a span copied exactly from the article, or None when the article does not contain it (for example, the weight of the Harbour Kids 20, which no article gives).
  • The assert loop checks every answer really is in its article, so a typo in the data stops the cell instead of producing a wrong score.
  • The output confirms 60 queries spread over all 24 articles, and 40 questions with 8 unanswerable.

Output:

text
60 search queries over 24 articles
40 questions, 8 with no answer in the article

9. Email threads

Twenty-four long email threads between customers and Tidewell, each with a reference summary written by hand. Module 8 summarises them and Module 9 scores those summaries with ROUGE. TH01 is Mrs Hartley's battery thread, the running example. This block works on its own.

python
import random
from datetime import date, timedelta

TURNS = {   # one email per code; {placeholders} come from the thread's spec
 "report": "{greet}\n\n{problem}\n\nMy order number is {order}.\n\nThanks,\n{cust}",
 "detail": "Hi {staff_first},\n\n{detail}\n\nThanks,\n{cust}",
 "chase1": "Hello again,\n\nI am just checking whether there is any news on this. {chase1} I would be grateful for an update when you have a moment.\n\nKind regards,\n{cust}",
 "chase2": "Hello,\n\nI wrote several days ago and I still do not have an answer. {chase2} I have been patient, but this is taking far too long, and I would like to know exactly what is happening and when.\n\n{cust}",
 "chase3": "I am writing yet again. {chase3} Nobody has given me a date or a straight answer. If this is not sorted out this week I will ask my bank to reverse the payment, and I will tell everyone I know to shop elsewhere.\n\n{cust}",
 "thanks": "Hi {last_sender},\n\nThank you, that is good news. {thanks}\n\nBest wishes,\n{cust}",
 "ack": "Hi {first},\n\nThank you for getting in touch, and I am sorry to hear about this. {ack} Could you {ask}? Once I have that I will get this moving straight away.\n\nBest wishes,\n{staff}\nTidewell Cycles",
 "update": "Hi {first},\n\nThanks for your patience. {update} I will let you know as soon as I hear more.\n\nBest wishes,\n{staff}\nTidewell Cycles",
 "update2": "Hi {first},\n\nI am sorry, I have more news and it is not what I hoped. {update2}\n\nBest wishes,\n{staff}\nTidewell Cycles",
 "holding": "Hi {first},\n\nSorry for the slow reply. We are still looking into this and will be in touch shortly.\n\nBest wishes,\n{staff}\nTidewell Cycles",
 "escalate": "Hi {first},\n\nI am Rosa Mensah, the owner of Tidewell Cycles. {staff_first} passed your emails to me, and I want to apologise properly: you should not have had to chase us. {escalate}\n\nWith apologies,\nRosa Mensah\nTidewell Cycles",
 "resolve": "Hi {first},\n\n{resolution}\n\nIf anything else comes up, just reply to this email.\n\nBest wishes,\n{staff}\nTidewell Cycles",
}
THREAD_SPECS = [
 {"id": "TH01", "cust": "Joan Hartley", "staff": "Ines Duarte", "order": "TW-103917", "start": "2026-06-10", "subject": "Volt 2 battery",
  "turns": "report ack detail update chase1 holding chase2 update2 chase3 escalate thanks",
  "problem": "I received your email of 9 June about the battery batch. I am afraid my Volt 2 battery stopped charging last week, and when I looked at the label it says VB-0426. I rely on the bike to visit my sister in Bath, so I would like to know what happens next.",
  "ask": "send a photo of the white label under the battery", "ack": "Batch VB-0426 is the batch we wrote about, and we will replace the pack free of charge.",
  "detail": "Here is the photo of the label. It clearly says VB-0426. I have stopped using the battery as your email advised.",
  "update": "Your replacement pack is reserved. New packs are due from our supplier at the end of June, and we will send yours as soon as they arrive.",
  "chase1": "It is now July, and the end of June has passed without a word about my replacement battery.",
  "chase2": "It is a month since I sent you the photo of the label, and I still have no battery and no date.",
  "chase3": "First the new packs were coming at the end of June, now it is the end of July.",
  "gaps": [1, 1, 3, 21, 2, 5, 1, 3, 1, 1],
  "update2": "Our supplier has delayed the new packs again, to the last week of July. I know this is not the answer you wanted.",
  "escalate": "A new Volt 2 battery pack is reserved for you from the first delivery and will reach you by courier on Friday 24 July, with a prepaid box for the old one. I have also added a free Full Service to your account, to use whenever you like.",
  "thanks": "Friday 24 July is fine and I will be in. I appreciate you writing to me yourself.",
  "summary": "Mrs Hartley's Volt 2 battery is from the faulty batch VB-0426 and stopped charging in June. Tidewell confirmed the batch, but the replacement packs were delayed twice and she chased three times, more angrily each time. Rosa apologised, arranged a new pack by courier for Friday 24 July and added a free Full Service, which Mrs Hartley accepted."},
 {"id": "TH02", "cust": "Oliver Bennett", "staff": "Ines Duarte", "order": "TW-108245", "start": "2026-04-14", "subject": "Lost parcel",
  "turns": "report ack detail update chase1 resolve thanks",
  "problem": "My Beacon Light Set was meant to arrive last Thursday. ParcelNorth's tracking has said 'in transit' for six days and has not moved.",
  "ask": "confirm the delivery address on the order", "ack": "I have asked ParcelNorth to trace the parcel.",
  "detail": "The address is right: 14 Orchard Road, Bristol BS5 7QT. Nobody here has seen a card from the courier.",
  "update": "ParcelNorth has opened an investigation, which can take up to 5 working days.",
  "chase": "It has been a week since the investigation started.",
  "resolution": "ParcelNorth has confirmed the parcel is lost. I have sent a new Beacon Light Set today by tracked delivery, at no cost to you, and you should have it in 2 to 4 working days.",
  "thanks": "The new lights arrived this morning and they work perfectly.",
  "summary": "Oliver's Beacon Light Set was lost by ParcelNorth in April. After the courier's investigation confirmed the loss, Tidewell sent a replacement free of charge, and it arrived and works."},
 {"id": "TH03", "cust": "Siobhan Murphy", "staff": "Ines Duarte", "order": "TW-122608", "start": "2026-08-13", "subject": "Delivery attempts",
  "turns": "report ack detail chase2 update resolve thanks",
  "problem": "SwiftDrop says it tried to deliver my Harbour twice, on Tuesday and Wednesday, but I was at home both days and nobody knocked or left a card.",
  "ask": "tell me which days next week someone will be in", "ack": "SwiftDrop took over our deliveries on 10 August, and we are seeing a few problems like this.",
  "detail": "I am in all day on Monday and Tuesday. Please ask them to ring the bell, it is the blue door.",
  "chase": "The tracking now says the parcel is being returned to you.",
  "update": "I have stopped the return and asked SwiftDrop to deliver on Monday with a note about the blue door.",
  "resolution": "SwiftDrop has confirmed delivery for Monday. Because of the trouble, I have refunded the 29 pounds delivery charge to your card.",
  "thanks": "The bike came on Monday and the driver even carried it inside.",
  "summary": "Siobhan's Harbour delivery failed twice in August after SwiftDrop took over, and the parcel was sent back. Ines stopped the return, booked a Monday delivery and refunded the 29 pound delivery charge; the bike arrived on Monday."},
 {"id": "TH04", "cust": "Priya Patel", "staff": "Ines Duarte", "order": "TW-111930", "start": "2026-05-12", "subject": "Refund for return",
  "turns": "report ack detail update chase1 resolve thanks",
  "problem": "I returned the Commuter Pannier Set on 28 April and the tracking shows you received it on 30 April, but I have not had my refund of 89 pounds.",
  "ask": "send the tracking number of your return", "ack": "Refunds normally go out within 5 working days of the return arriving, so this is late.",
  "detail": "The return tracking number is on the receipt from the post office: it was delivered to your Bristol warehouse on 30 April.",
  "update": "I found your return. It was checked in on 30 April but the refund was not released.",
  "chase": "My bank still shows nothing.",
  "resolution": "The refund of 89 pounds was sent to your card today. Your bank may take 3 to 5 working days to show it. I am sorry for the delay.",
  "thanks": "The money is back in my account now.",
  "summary": "Priya's refund of 89 pounds for a returned Commuter Pannier Set was not released after the return arrived on 30 April. Ines found the return, sent the refund and apologised; Priya confirmed the money arrived."},
 {"id": "TH05", "cust": "Mohammed Khan", "staff": "Ines Duarte", "order": "TW-107712", "start": "2026-04-02", "subject": "Charged twice",
  "turns": "report ack detail resolve thanks",
  "problem": "I have been charged 649 pounds twice for my Harbour. Both payments are on my statement and neither is pending.",
  "ask": "send a screenshot of your statement showing both payments", "ack": "Sometimes one payment is a temporary hold, but if both have been taken we will refund one straight away.",
  "detail": "Screenshot attached. Both payments of 649 pounds were taken on 1 April.",
  "resolution": "You are right, the payment was taken twice. I have refunded the duplicate 649 pounds today, and it should reach your card within 3 to 5 working days.",
  "thanks": "I can see the refund. Thanks for sorting it so fast.",
  "summary": "Mohammed was charged 649 pounds twice for his Harbour. After he sent a statement screenshot, Ines refunded the duplicate payment the same day."},
 {"id": "TH06", "cust": "Hannah Lewis", "staff": "Ines Duarte", "order": "TW-113385", "start": "2026-05-20", "subject": "Wrong size",
  "turns": "report ack detail update resolve thanks",
  "problem": "I bought the Harbour Kids 20 for my son but he is only 108 cm and cannot reach the ground. It has not been ridden outdoors.",
  "ask": "tell me whether you would like a refund or a smaller bike", "ack": "The Harbour Kids 20 suits children from about 115 cm, so he may need a little longer before it fits.",
  "detail": "I would rather have a refund, as you do not sell a smaller bike.",
  "update": "I have booked a free collection for Wednesday 27 May.",
  "resolution": "The bike arrived back in perfect condition, and I have refunded 329 pounds to your card.",
  "thanks": "Refund received, and thanks for the advice about sizes.",
  "summary": "Hannah's son was too small for the Harbour Kids 20. Tidewell collected the unridden bike for free and refunded 329 pounds."},
 {"id": "TH07", "cust": "Daniel O'Connor", "staff": "Dev Malhotra", "order": "TW-109874", "start": "2026-04-21", "subject": "Full Service booking",
  "turns": "report ack detail update chase1 resolve thanks",
  "problem": "I have a Full Service booked for my Causeway on 28 April, repair ticket RJ-2026-0412, but I am away that week. Can I move it?",
  "ask": "tell me two or three days that suit you", "ack": "Moving a booking is no problem if we hear at least 24 hours before.",
  "detail": "Any day in the week of 4 May works, ideally Tuesday.",
  "update": "Tuesday 5 May is full, so I have pencilled in Thursday 7 May.",
  "chase": "I have not had a new confirmation email.",
  "resolution": "Your Full Service is confirmed for Thursday 7 May. Please drop the bike off from 9:00; it will be ready by 17:30.",
  "thanks": "Thursday is perfect, see you then.",
  "summary": "Daniel asked to move his Full Service booking (RJ-2026-0412) from 28 April. Dev offered Thursday 7 May instead of the full Tuesday, and after Daniel chased the confirmation it was confirmed."},
 {"id": "TH08", "cust": "Grace Okafor", "staff": "Dev Malhotra", "order": "TW-105520", "start": "2026-03-23", "subject": "Motor cutting out",
  "turns": "report ack detail update resolve thanks",
  "problem": "The motor on my Volt 2 Step-Through cuts out on hills and the display flashes E07. It is three months old.",
  "ask": "bring the bike in for an E-bike Diagnostic", "ack": "E07 usually points to the motor or its sensor.",
  "detail": "I can bring it in on Saturday morning.",
  "update": "The diagnostic found a faulty speed sensor, which is covered by the warranty, so there is nothing to pay.",
  "resolution": "The new sensor is fitted and the bike has been test ridden up Park Hill with no errors. It is ready to collect.",
  "thanks": "Collected it today and it is riding perfectly.",
  "summary": "Grace's three-month-old Volt 2 Step-Through showed E07 and cut out on hills. The diagnostic found a faulty speed sensor, which Dev replaced free under warranty."},
 {"id": "TH09", "cust": "Marta Gil", "staff": "Ines Duarte", "order": "TW-120336", "start": "2026-07-31", "subject": "Causeway gears",
  "turns": "report ack detail update chase1 resolve thanks",
  "problem": "I bought a Causeway that was delivered to Valencia on 29 July. From the first day the gears slip, and a local shop says the derailleur was bent in the factory.",
  "ask": "send a photo of the derailleur and the shop's quote", "ack": "A part that arrives bent is covered, and you should not have to pay for it.",
  "detail": "Photos and the quote are attached. The shop wants 60 euros to replace the derailleur hanger and adjust the gears.",
  "update": "Dev has looked at the photos and agrees the hanger is bent.",
  "chase": "Can the shop go ahead?",
  "resolution": "Please let the shop do the repair. Send us the receipt and we will refund the cost in full, up to 60 euros.",
  "thanks": "The shop fixed it today and I have sent you the receipt.",
  "summary": "Marta's Causeway arrived in Valencia with a bent derailleur hanger. Tidewell agreed from her photos that it was a delivery fault and refunded a local shop's repair, up to 60 euros."},
 {"id": "TH10", "cust": "Lukas Becker", "staff": "Ines Duarte", "order": "TW-119611", "start": "2026-08-04", "subject": "Display error",
  "turns": "report ack detail update resolve thanks",
  "problem": "My Volt 2 arrived in Berlin last week. The display shows E10 and the motor will not start.",
  "ask": "check that the battery is pushed fully into its mount and locked", "ack": "E10 points to the battery connection, which can be loosened in transit.",
  "detail": "I removed the battery and pushed it back in until it clicked. The error is still there.",
  "update": "We will collect the bike by courier, at our cost, and repair it in our Bristol workshop.",
  "resolution": "The battery contacts were bent in transit. Dev replaced the mount and the bike is on its way back to you with SwiftDrop.",
  "thanks": "The bike is back and working, thank you.",
  "summary": "Lukas's new Volt 2 showed E10 on arrival in Berlin. Reseating the battery did not help, so Tidewell collected the bike, replaced the damaged battery mount and returned it at no cost."},
 {"id": "TH11", "cust": "Camille Dubois", "staff": "Ines Duarte", "order": "TW-120901", "start": "2026-08-05", "subject": "Missing charger",
  "turns": "report ack detail resolve thanks",
  "problem": "My Volt 2 Step-Through arrived in Lyon without the battery charger.",
  "ask": "check the small box with the tools and pedals", "ack": "The charger is usually packed with the tools.",
  "detail": "I have checked every box and there is no charger.",
  "resolution": "I have posted a new charger today, and it should reach Lyon within 4 to 7 working days. I am sorry it was missing.",
  "thanks": "The charger arrived and the battery is charging now.",
  "summary": "Camille's Volt 2 Step-Through arrived in Lyon without a charger. Ines posted a new one, which arrived."},
 {"id": "TH12", "cust": "Ella Morgan", "staff": "Callum Reid", "order": "TW-112777", "start": "2026-05-15", "subject": "Gift card email",
  "turns": "report ack detail resolve thanks",
  "problem": "I bought a 100 pound e-gift card for my brother's birthday on 15 May, but he never received the email.",
  "ask": "confirm the email address it should go to", "ack": "E-gift cards are sometimes caught by spam filters.",
  "detail": "His address is rhys.morgan@post.example. He has checked his spam folder.",
  "resolution": "I have resent the gift card to rhys.morgan@post.example. The code and balance of 100 pounds are unchanged.",
  "thanks": "He has it now. Thanks for the quick help.",
  "summary": "Ella's 100 pound e-gift card email never reached her brother. Callum resent it to the confirmed address and it arrived."},
 {"id": "TH13", "cust": "Arjun Sharma", "staff": "Callum Reid", "order": "TW-114402", "start": "2026-05-26", "subject": "Paying in instalments",
  "turns": "report ack detail resolve thanks",
  "problem": "I would like to buy a Volt 2 but pay monthly. I could not see the option at checkout.",
  "ask": "tell me what was in your basket", "ack": "Instalments are offered on orders over 500 pounds.",
  "detail": "Just the Volt 2, 2,199 pounds.",
  "resolution": "The option appears on the payment page, after the delivery step. You can spread the 2,199 pounds over 12 months with no interest, subject to the finance partner's credit check.",
  "thanks": "Found it and the order is placed.",
  "summary": "Arjun could not find the instalment option for a Volt 2. Callum explained that it appears on the payment page for orders over 500 pounds, and Arjun placed the order."},
 {"id": "TH14", "cust": "Lucy Ward", "staff": "Ines Duarte", "order": "TW-116087", "start": "2026-06-16", "subject": "Cancel order",
  "turns": "report ack detail resolve thanks",
  "problem": "I ordered a LockSure D-lock and a Beacon Light Set an hour ago, but I have found the same lock at home. Can I cancel just the lock?",
  "ask": "confirm you still want the lights", "ack": "Your order has not left the warehouse yet, so we can change it.",
  "detail": "Yes, please keep the lights.",
  "resolution": "I have removed the lock from your order and refunded 45 pounds. The lights will be sent today.",
  "thanks": "Perfect, thank you.",
  "summary": "Lucy cancelled the LockSure D-lock from an order before dispatch. Ines removed it, refunded 45 pounds and sent the lights."},
 {"id": "TH15", "cust": "Tom Hughes", "staff": "Dev Malhotra", "order": "TW-104951", "start": "2026-06-11", "subject": "Battery batch check",
  "turns": "report ack detail update resolve thanks",
  "problem": "After your email about the battery batch, I checked my Volt 2. My battery seems to run down faster than it did in spring.",
  "ask": "send a photo of the label under the battery", "ack": "Only packs from batch VB-0426 are being replaced.",
  "detail": "The label says VB-0319, not VB-0426.",
  "update": "Your pack is not in the affected batch. Some loss of range is normal as a battery ages, but we can test it.",
  "resolution": "The E-bike Diagnostic shows your battery at 94 percent of its new capacity, which is healthy. Keeping the tyres pumped up will help your range.",
  "thanks": "Good to know it is fine, thanks.",
  "summary": "Tom worried that his Volt 2 battery was in the faulty batch. His pack was batch VB-0319, and a diagnostic showed it at 94 percent of new capacity, so no replacement was needed."},
 {"id": "TH16", "cust": "Anna Schmidt", "staff": "Ines Duarte", "order": "TW-121346", "start": "2026-08-07", "subject": "Lock key stuck",
  "turns": "report ack detail resolve thanks",
  "problem": "The key of my LockSure D-lock sticks and I could not open it yesterday without spraying oil into it.",
  "ask": "tell me the number stamped on the key", "ack": "A lock that sticks is a fault and is covered for 1 year.",
  "detail": "The number on the key is 4471.",
  "resolution": "I have sent a new LockSure D-lock with three new keys. You do not need to return the old one.",
  "thanks": "The new lock opens easily.",
  "summary": "Anna's LockSure D-lock key stuck. Ines treated it as a warranty fault and sent a new lock with three keys."},
 {"id": "TH17", "cust": "Fatima Hussain", "staff": "Ines Duarte", "order": "TW-110224", "start": "2026-04-28", "subject": "Pannier clips",
  "turns": "report ack detail update resolve thanks",
  "problem": "The clips on the Commuter Pannier Set do not fit the rack on my old bike.",
  "ask": "tell me whether the bags have been used", "ack": "The clips fit racks with tubes up to 12 mm.",
  "detail": "They have not been used, they are back in the packaging.",
  "update": "I have emailed you a free returns label.",
  "resolution": "Your return has arrived and I have refunded 89 pounds.",
  "thanks": "Refund received, thanks.",
  "summary": "Fatima's Commuter Pannier Set did not fit her rack. She returned the unused bags with a free label and was refunded 89 pounds."},
 {"id": "TH18", "cust": "Kofi Asante", "staff": "Dev Malhotra", "order": "TW-106631", "start": "2026-03-30", "subject": "Puncture",
  "turns": "report ack detail resolve thanks",
  "problem": "I have a puncture in the back tyre of my Harbour and I need the bike for work tomorrow.",
  "ask": "bring it in before 10:00", "ack": "A Puncture Repair is done the same day if the bike is in before 10:00.",
  "detail": "I will drop it off at 9:15.",
  "resolution": "The puncture was a thorn, now removed, and the tube is repaired. The bike is ready, and the cost is 18 pounds.",
  "thanks": "Picked it up at lunch. Brilliant service.",
  "summary": "Kofi needed a same-day puncture repair on his Harbour. He dropped it off before 10:00 and Dev repaired it for 18 pounds the same day."},
 {"id": "TH19", "cust": "Mei Chen", "staff": "Callum Reid", "order": "TW-108803", "start": "2026-04-16", "subject": "Which size?",
  "turns": "report ack detail resolve thanks",
  "problem": "I am 171 cm tall and I want the Harbour. Should I order a Small or a Medium? (I have put my previous order number below in case it helps.)",
  "ask": "tell me how you like to ride", "ack": "171 cm is just inside the Medium range, which starts at 168 cm.",
  "detail": "Mostly short trips in town, and I like sitting upright.",
  "resolution": "For short, upright town riding the Small will feel more relaxed, but the Medium will fit you well too. You can try both in the shop, or exchange within 14 days if it has not been ridden outdoors.",
  "thanks": "I tried both in the shop and bought the Medium.",
  "summary": "Mei asked whether a Small or Medium Harbour suits her at 171 cm. Callum explained both could work and offered a test ride; she tried both and bought the Medium."},
 {"id": "TH20", "cust": "Javier García", "staff": "Amara Osei", "order": "TW-119034", "start": "2026-07-22", "subject": "My personal data",
  "turns": "report ack detail resolve thanks",
  "problem": "Please send me a copy of all the personal data you hold about me.",
  "ask": "confirm the email address you used for your orders", "ack": "You are entitled to a copy, and we reply within one month.",
  "detail": "I used javier.garcia@post.example for all my orders.",
  "resolution": "Attached is a copy of the personal data we hold: your contact details, two orders and four messages. Card details are never stored in messages.",
  "thanks": "Received, thank you.",
  "summary": "Javier asked for a copy of his personal data. Amara confirmed his email address and sent a copy of his contact details, two orders and four messages."},
 {"id": "TH21", "cust": "Ben Carter", "staff": "Ines Duarte", "order": "TW-118112", "start": "2026-07-23", "subject": "Sale order",
  "turns": "report ack detail update chase1 resolve thanks",
  "problem": "I ordered a Causeway in the summer sale on 16 July and it has not left your warehouse.",
  "ask": "confirm your delivery address", "ack": "The sale brought far more orders than usual, and some are taking a few extra days to leave the warehouse.",
  "detail": "The address on the order is right.",
  "update": "Your bike is packed and booked for collection by the courier on Monday.",
  "chase": "The tracking link still says label created.",
  "resolution": "Your Causeway left the warehouse today. To say sorry for the wait, I have refunded the 29 pounds delivery charge.",
  "thanks": "Arrived today. Thanks for the refund too.",
  "summary": "Ben's Causeway, ordered in the summer sale on 16 July, was delayed in the warehouse by the volume of sale orders. It was dispatched after he chased, and Ines refunded the 29 pound delivery charge."},
 {"id": "TH22", "cust": "Megan Price", "staff": "Dev Malhotra", "order": "TW-115540", "start": "2026-06-08", "subject": "Handlebars",
  "turns": "report ack detail resolve thanks",
  "problem": "I built my Harbour from the box but the handlebars point slightly to the left however much I tighten them.",
  "ask": "send a photo of the stem from above", "ack": "This is usually the stem bolts tightened before the bar was lined up.",
  "detail": "Photo attached.",
  "resolution": "From the photo, loosen the two stem bolts, line the bar up with the front wheel, then tighten each bolt a little at a time. If it still turns, our workshop will set it up for free.",
  "thanks": "That worked, it is straight now.",
  "summary": "Megan's handlebars were crooked after she built her Harbour. Dev explained how to loosen and realign the stem bolts, which fixed it."},
 {"id": "TH23", "cust": "Liam Walsh", "staff": "Dev Malhotra", "order": "TW-107004", "start": "2026-05-04", "subject": "Range",
  "turns": "report ack detail update resolve thanks",
  "problem": "My Volt 2 only goes about 35 km on a charge, and the advert said 40 to 80 km.",
  "ask": "tell me which assistance level you use and how hilly your ride is", "ack": "Range depends a lot on assistance level and hills.",
  "detail": "I use the highest level all the time, and my commute has two steep hills.",
  "update": "On the highest level with hills, 35 km is close to what we would expect.",
  "resolution": "We tested the battery and it is at 98 percent of new capacity. Using the middle assistance level on flat parts should give you nearer 50 km.",
  "thanks": "Tried the middle level and got 48 km today.",
  "summary": "Liam's Volt 2 managed only 35 km on the highest assistance level with hills. The battery tested at 98 percent of new capacity, and using a lower level raised his range to 48 km."},
 {"id": "TH24", "cust": "Zara Ahmed", "staff": "Ines Duarte", "order": "TW-117459", "start": "2026-07-06", "subject": "Wrong item",
  "turns": "report ack detail resolve thanks",
  "problem": "I ordered a LockSure D-lock but received a Beacon Light Set instead.",
  "ask": "send a photo of the label on the parcel", "ack": "It sounds like the parcels were swapped in our warehouse.",
  "detail": "Photo attached. The label has my name but the wrong item code.",
  "resolution": "I have sent the LockSure D-lock today and emailed a free returns label for the lights.",
  "thanks": "The lock has arrived and the lights are on their way back.",
  "summary": "Zara received a Beacon Light Set instead of the LockSure D-lock she ordered. Ines sent the lock and a free returns label for the lights."},
]


def make_threads(seed=20260831, quote=False):
    """quote=True makes every reply quote the email before it, as mail clients do (longer, repetitive text)."""
    rng = random.Random(seed)
    threads = []
    for s in THREAD_SPECS:
        first, staff_first = s["cust"].split()[0], s["staff"].split()[0]
        email = (s["cust"].lower().replace(" ", ".").replace("'", "")) + "@post.example"
        day, parts, last_sender, previous = date.fromisoformat(s["start"]), [], staff_first, ""
        for k, code in enumerate(s["turns"].split()):
            from_staff = code in ("ack", "update", "update2", "holding", "escalate", "resolve")
            sender = ("Rosa Mensah" if code == "escalate" else s["staff"]) + " <help@tidewell.example>" if from_staff else f"{s['cust']} <{email}>"
            fields = {f: s.get(f, s.get("chase", "")) for f in ["chase1", "chase2", "chase3"]}
            fields.update({f: s.get(f, "") for f in ["problem", "detail", "thanks", "ack", "ask", "update", "update2", "escalate", "resolution"]})
            text = TURNS[code].format(greet="Hello,", first=first, staff_first=staff_first, cust=s["cust"], staff=s["staff"],
                                      order=s["order"], last_sender=last_sender, **fields)
            own, last_sender = text, "Rosa" if code == "escalate" else staff_first if from_staff else last_sender
            if quote and previous:
                text += "\n\n" + "\n".join("> " + line for line in previous.split("\n"))
            previous = own
            subject = ("" if k == 0 else "Re: ") + s["subject"]
            parts.append(f"From: {sender}\nDate: {day.strftime('%A %d %B %Y').replace(' 0', ' ')}\nSubject: {subject}\n\n{text}")
            day += timedelta(s["gaps"][k] if "gaps" in s and k < len(s["gaps"]) else rng.randint(1, 3))
        threads.append({"id": s["id"], "customer": s["cust"], "order_id": s["order"], "subject": s["subject"],
                        "text": "\n\n".join(parts), "summary": s["summary"]})
    return threads


THREADS = make_threads()
words = [len(t["text"].split()) for t in THREADS]
print(len(THREADS), "threads,", min(words), "to", max(words), "words; the Hartley thread has", words[0])
print(THREADS[0]["summary"])

Code walkthrough

  • TURNS holds one email shape per step of a conversation: the customer reports a problem (report), Tidewell acknowledges it and asks for something (ack), the customer sends details (detail), Tidewell gives news (update, update2) or a vague holding reply (holding), the customer chases politely, then firmly, then angrily (chase1, chase2, chase3), Rosa steps in (escalate), Tidewell resolves it (resolve) and the customer says thanks (thanks).
  • Each spec in THREAD_SPECS gives the customer, the staff member, the order, the first date, the order of turns, and the hand-written sentences that make that thread different: the problem, the details, each reply. summary is the reference summary, written from those facts by hand.
  • make_threads fills the shapes in order and adds a From, Date and Subject header to each email. The days between emails come from the seeded generator, except for the Hartley thread, whose dates are fixed in gaps so that the story fits the calendar (her last email is on Sunday 19 July, before the courier delivery on Friday 24 July).
  • quote=True makes every reply quote the email before it, as mail clients do. The text gets much longer and repetitive (the Hartley thread grows to 1,293 words), which is what the lessons on cleaning quoted replies and on chunking long input need.
  • The output shows 24 threads of 213 to 706 words, the Hartley thread the longest at 706, and its reference summary.

Output:

text
24 threads, 213 to 706 words; the Hartley thread has 706
Mrs Hartley's Volt 2 battery is from the faulty batch VB-0426 and stopped charging in June. Tidewell confirmed the batch, but the replacement packs were delayed twice and she chased three times, more angrily each time. Rosa apologised, arranged a new pack by courier for Friday 24 July and added a free Full Service, which Mrs Hartley accepted.

10. The word2vec corpus

Word vectors need far more text than the inbox holds. This block makes 20,000 short sentences in the style of reviews and repair notes, used only to train word2vec and fastText in Module 6. This block works on its own.

python
import random

W2V = {   # word lists for the templated review and repair-note corpus (Module 6 only)
 "bike": ["volt", "harbour", "causeway", "ebike", "bike"],
 "part": [["battery", "motor", "display", "charger"], ["chain", "gears", "cassette", "pedals"], ["brakes", "pads", "levers"],
          ["tyre", "tube", "wheel", "spoke"], ["saddle", "handlebar", "stem", "grips"], ["lights", "lock", "pannier", "rack", "mudguard"]],
 "good": ["great", "brilliant", "smooth", "quiet", "comfy", "light", "solid", "reliable", "fast", "excellent"],
 "bad": ["noisy", "loose", "stiff", "heavy", "flimsy", "slow", "faulty", "broken", "worn", "dead"],
 "fix": ["replaced", "adjusted", "tightened", "cleaned", "trued", "greased", "bled", "updated", "fitted", "checked"],
 "who": ["dev", "the mechanic", "the workshop", "the team", "callum", "ines"],
 "when": ["after a week", "after two rides", "on the first ride", "after a month", "in the rain", "on hills", "in town"],
 "place": ["bristol", "bath", "clifton", "cardiff", "the harbour", "the hill", "work", "school"],
}
PATTERNS = [
 "the {part} on my {bike} is {good}", "the {part} on my {bike} is {bad}", "my {bike} {part} went {bad} {when}",
 "{who} {fix} the {part} on my {bike}", "{fix} the {part} and {fix} the {part2}", "really {good} {bike} for riding to {place}",
 "{part} feels {bad} {when} but the {part2} is {good}", "rode my {bike} to {place} and the {part} was {good}",
 "customer says {part} is {bad} {when} so {who} {fix} it", "{good} service from {who} who {fix} my {part}",
 "the {bike} {part} is {good} and the {part2} is {good2}", "after the service the {part} is {good} again",
]


def make_w2v_corpus(n=20000, seed=20260831):
    rng = random.Random(seed)
    out = []
    for _ in range(n):
        p = rng.choice(PATTERNS)
        group = rng.choice(W2V["part"])                      # parts from one group appear together most of the time
        part, part2 = rng.sample(group, 2) if rng.random() < 0.7 else (rng.choice(group), rng.choice(rng.choice(W2V["part"])))
        good, good2 = rng.sample(W2V["good"], 2)
        out.append(p.format(part=part, part2=part2, good=good, good2=good2, bad=rng.choice(W2V["bad"]),
                            bike=rng.choice(W2V["bike"]), fix=rng.choice(W2V["fix"]), who=rng.choice(W2V["who"]),
                            when=rng.choice(W2V["when"]), place=rng.choice(W2V["place"])))
    return out


W2V_CORPUS = make_w2v_corpus()
print(len(W2V_CORPUS), "sentences,", len({w for s in W2V_CORPUS for w in s.split()}), "distinct words")
print(W2V_CORPUS[:3])

Code walkthrough

  • W2V lists the words each slot can take. Parts are grouped (electrics, drivetrain, brakes, wheels, contact points, accessories).
  • make_w2v_corpus picks a sentence pattern and fills it. Seven times in ten both parts in a sentence come from the same group, so words such as battery, motor and charger share contexts, which is what word2vec learns from.
  • Everything is lower case with no punctuation, ready to split on spaces.
  • The output shows 20,000 sentences using 106 distinct words, and the first three.

Output:

text
20000 sentences, 106 distinct words
['the workshop adjusted the cassette on my ebike', 'customer says pedals is flimsy after two rides so dev bled it', 'brilliant service from the team who trued my chain']

11. Translation pairs

Every non-English message with its English reference, for the translation lessons in Module 9. It needs blocks 1 to 4.

python
def translation_pairs(messages, hard_test):
    """(id, language, source text, English reference) for every non-English message."""
    pairs = [(m["id"], m["language"], m["core"], m["en_ref"]) for m in messages
             if m["en_ref"] and m["duplicate_of"] is None]          # template messages: the reference is the paired template
    pairs += [(h["id"], h["language"], h["text"], h["en_ref"]) for h in hard_test if h["en_ref"]]   # hand-written
    return pairs


TRANSLATIONS = translation_pairs(MESSAGES, HARD_TEST)
from collections import Counter
print(len(TRANSLATIONS), "pairs:", dict(Counter(lang for _, lang, _, _ in TRANSLATIONS)))
print(TRANSLATIONS[-1][0], TRANSLATIONS[-1][2][:60], "|", TRANSLATIONS[-1][3][:60])

Code walkthrough

  • From MESSAGES it takes each non-English message's clean text (core) and its reference (en_ref), leaving out near-duplicates so that no sentence is counted twice.
  • From HARD_TEST it adds the 40 hand-written messages with their hand-written references.
  • The references are simulated: the template references are the English wording of the same template, and the hand-written ones are one person's translation. Module 9 says so wherever it quotes a BLEU or chrF score.
  • The output shows 403 pairs, with 160 German, 124 Spanish and 119 French, and the last pair, HT80.

Output:

text
403 pairs: {'fr': 119, 'de': 160, 'es': 124}
HT80 Bonjour, j'ai acheté un Volt 2 Step-Through le 16 juillet pe | Hello, I bought a Volt 2 Step-Through on 16 July during the 

12. Fingerprints: check you have the same data

A fingerprint is a short code computed from the data. If one character anywhere were different, the fingerprint would be completely different. Print it after pasting the blocks and compare it with the one below. It needs every block above.

python
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))
print("all      ", fingerprint(MESSAGES, HARD_TEST, OOS, HELP_CENTRE, SEARCH_QUERIES, QA_PAIRS, THREADS, POS_GOLD, W2V_CORPUS))

Code walkthrough

  • fingerprint turns the data into one long text with json.dumps (keys sorted, so the order is fixed), hashes it with SHA-256 and keeps the first 12 hexadecimal digits.
  • Lessons that paste only some blocks print only the fingerprints they can: fingerprint(MESSAGES) after blocks 1 to 3, fingerprint(HARD_TEST) after block 4.
  • The expected values are 294611361162 for the messages, 63c80b0396bc for the hand-written test set and a88ac4be3229 for everything. If yours differ, a block was changed when it was pasted: paste it again from this page.

Output:

text
messages  294611361162
hard_test 63c80b0396bc
all       a88ac4be3229

The name-swap set (built in Module 10)

name_swap is not on this page. Module 10 Lesson 4 builds it from HARD_TEST and CUSTOMERS: each message that contains a customer's name or a gendered pronoun is copied with the name replaced by one of a different origin or gender, and the pronouns swapped. A fair classifier gives every copy the same label. The lesson writes the swap rules in its own code and says "added in this lesson".

The numbers everyone quotes

All simulated, all measured by the blocks above.

FactValue
Messages1,800, from Monday 2 March to Sunday 30 August 2026
Before July / from July941 / 859 messages
English share95.3% before July, 61.8% from July
Intentsorder_status 448, warranty_fault 350, product_question 278, returns_refund 266, repair_booking 239, spam 124, praise 95
Channelsemail 945, web form 724, review 131
Splitstrain 1,262, dev 267, test 271
Warranty faults by monthMarch 26, April 28, May 33, June 71, July 117, August 75
Gold entitiesPRODUCT 1,465, DATE 809, ORDER_ID 751, PERSON 609, MONEY 276, LOC 120
Deliberate flaws54 near-duplicates, 36 wrong labels, 6 reviews whose stars contradict the text
HARD_TEST80 messages: 40 English, 14 German, 13 Spanish, 13 French
OOS60 messages
Help centre24 articles, 155 to 182 words each
Search and questions60 queries; 40 questions, 8 unanswerable
Threads24, of 213 to 706 words; Hartley (TH01) 706 words, 1,293 with quoted replies
Translation pairs403 (160 German, 124 Spanish, 119 French)
POS_GOLD20 sentences, 147 tokens
W2V_CORPUS20,000 sentences, 106 distinct words
Fingerprintsmessages 294611361162, hard test 63c80b0396bc, all a88ac4be3229

The people

See 03-the-tidewell-inbox.md for the full cast. In short: Rosa Mensah (owner), Ines Duarte (customer service, labels the data), Dev Malhotra (workshop lead), Callum Reid (web shop and help centre) and Amara Osei (bookkeeping and data protection).