← Work

RBC, Amplify

A patent-pending pipeline that reads contact-centre call transcripts, finds the calls where a client is being scammed, and turns them into fraud intelligence the threat hunting team can act on.

94%
recall on held-out calls
≈65%
fewer LLM calls
$1.5M/yr
projected LLM savings

Software Developer Intern · May to Aug 2026

Python, FastAPI, SQLAlchemy, PostgreSQL, scikit-learn, SentenceTransformers, React, Keycloak, Docker, OpenShift, Vault, MLflow, OpenTelemetry, Grafana

RBC Amplify ↗

What it is

About 23 million calls a year reach RBC's contact centre. In some of them, a real client is on the line while a scammer coaches them to move their money. Every security check passes on those calls: the voice matches, the number isn't spoofed, and the login is genuine. The only evidence is what the client says, and nothing was reading it.

We built a two-stage pipeline that reads the transcripts, finds the fraud calls, and pulls out what the threat hunting team needs: the fraud type, plus any URLs, phone numbers and payment methods, each linked to the line in the transcript that supports it. It's patent-pending, I'm a named inventor, and I presented it to 1,000+ people at RBC's internal expo.

The team was two developers, a data engineer and a business analyst. I owned the cascade design, the backend orchestration service, the transcript ingestion path, auth, and the OpenAPI contract between the three services.

RBC Amplify, the program I built this in

The gap

RBC's fraud tooling already covered fake websites and fraudsters pretending to be a client. Authorized push payment fraud, where the real client is tricked into sending the money, fell between the layers.

LayerWhat it covers
Call recordingRecords contact-centre calls and produces transcripts
Digital threat intelligencePhishing sites and malicious domains
Caller authenticationSpoofed numbers and voice biometrics
Advisor toolingWorkflow tools and call categories
This projectReal clients being coached by a scammer, found in what they say on the call
What each layer of the fraud stack covers, and where this project fits.

The threat hunting team had the opposite problem. They track phishing domains and scam campaigns, but nothing from the phone channel reached them. When a client said "I went to rbc-secure-verify.com", that domain never made it back.

Our two sponsors gave us a question, not a spec: how could the voice of clients help detect fraud faster? We spent the first weeks on stakeholder interviews and personas (client, advisor, threat hunter) and narrowed it to one buildable goal: turn call transcripts into structured fraud signal the threat team can act on.

Three services, one contract

The platform is three containerized services on OpenShift. The analyst console is React. The backend API, which I owned, is Python with FastAPI and async SQLAlchemy over PostgreSQL: it runs the pipeline, stores detections and analyst reviews, and serves the console. The ML service, also FastAPI, runs the two-stage cascade. Transcripts come from S3, Keycloak handles sign-in, and secrets come from Vault at deploy time.

Fraud detection platform: three services
Fraud detection platform: three servicesThree containerized services on OpenShift. Analysts use a React console, signed in through Keycloak, which calls the backend API inside an authenticated trust boundary. The backend API, in Python with FastAPI and PostgreSQL, runs the daily pipeline over transcripts from object storage, stores detections and reviews, and calls the ML service. The ML service runs a stage 1 scikit-learn classifier, which filters out about 65 percent of calls, and a stage 2 LLM that classifies the rest and extracts threat indicators; its evaluations are tracked in MLflow. OpenTelemetry traces from the services feed Grafana dashboards that watch for model drift. A written OpenAPI contract defines every call between the services.OpenShiftML service · Python, FastAPITrust boundary: authenticatedTranscriptsobject storageDaily pipeline runbatches, retriesAnalyst consoleReactBackend APIPython · FastAPImodelStage 1 classifierscikit-learnmodelStage 2 LLMthreat indicatorsindicators=20+KeycloakOIDC single sign-onPostgreSQLdetections, reviewsGrafanadriftMLflowevaluations1opens a job2batches3remaining calls4filtered (~65%)detections5SQL6HTTPS7sign inJWKS keystracesstopsevery call between services is defined in an OpenAPI contract I wrote
  • client
  • service
  • database or storage
  • model
  • scheduled job
  • external
  • bold outline = what I owned
  • request
  • async: event, message, webhook
  • telemetry
Dotted lines are OpenTelemetry traces, which span console → API → ML service and feed the Grafana dashboards.
  1. Transcripts arrive in S3, and the daily run picks them up.
  2. The backend opens a pipeline job, so every run is counted and failures can be traced.
  3. It sends the transcripts to the ML service in concurrent batches.
  4. The stage 1 classifier filters out about 65% of calls. Only the rest go on to the stage 2 LLM.
  5. The LLM's detections and threat indicators come back to the backend.
  6. They're stored in PostgreSQL, with related detections grouped into campaigns.
  7. Analysts review them in the console, signed in through Keycloak.

The ML service is separate from the backend because the two fail and scale differently. The ML service is CPU-heavy and calls a paid external API. The backend mostly waits on Postgres. With them apart, the model can be redeployed without touching the API analysts use, and a slow LLM call can't hold up a dashboard query.

Early in the term I wrote and froze an OpenAPI v2.1 contract for every call between the three services. The frontend was built against it with mock data while the backend was still in progress, and any change after the freeze needed a version bump. That let four people build three services at once and integrate them without rewrites.

Why a cascade

An LLM is good at reading a messy transcript and pulling out "investment scam, the client mentioned crypto-wallet-xyz.com and was told to send an e-transfer." It's also slow and costs about $0.10 a call. Most calls are address changes, card activations and balance questions, and paying LLM prices to confirm that is waste.

So a cheap classifier decides which calls are worth the LLM. It lets about 35% of calls through, which brings projected spend from $2.35M to $822K a year.

Per yearLLM on every callCascade
Calls reaching the LLM23M (100%)~8.05M (~35%)
Cost per LLM call~$0.10~$0.10
LLM spend~$2.35M~$822K
Projected yearly cost, over 23 million calls.

The gate's threshold is 0.15, set low on purpose, because its two mistakes cost very different amounts:

MistakeWhat happensCost
Fraud call dropped at stage 1Nobody ever looks at the call againA missed fraud
Normal call sent to stage 2The LLM reads it and says "not fraud"About $0.10
Why the gate is tuned for recall.

Stage 2 corrects the gate's false positives, but nothing corrects its false negatives. When the two stages disagree, a fixed rule decides:

  • The gate scores a call as clearly safe: it stops there, on the cheap path.
  • The gate passes it and the LLM says fraud: it's a detection.
  • The gate passes it with low confidence and the LLM says no: it's cleared.
  • The gate is highly confident and the LLM still says no: it isn't dropped. It's flagged as suspicious for a human to review.

In that last case neither model overrules the other; an analyst decides.

What the LLM does

Calls above the threshold are batched and sent to the LLM through RBC's OpenAI-compatible gateway. For each call it does three things:

  • Classifies it. A call can be more than one fraud type at once, such as impersonation and an investment scam, so this is multi-label.
  • Extracts indicators from the text: URLs and domains, phone numbers, payment methods, amounts and claimed organizations.
  • Points to the evidence: the exact span of the transcript behind each finding, so an analyst can check it in seconds.

Labels without ground truth

Nobody had ever marked which calls involved fraud, so there was nothing to train the gate on, and hand-labelling needs someone who knows the fraud taxonomy, which would have taken weeks of analyst time. So labels came from an LLM ensemble: several GPT model versions each labelled every transcript more than once, and a majority vote set the final label, so one inconsistent answer couldn't flip it.

Runs agreed with each other almost completely, so the process was stable. Stable isn't the same as correct, though: every run could be wrong in the same way. So we hand-labelled a separate set of transcripts and compared. The ensemble agreed with the human labels on the large majority of calls, and that's when we knew the labels were good enough to build on.

The gate is trained on LLM labels and stage 2 is an LLM, so the gate learns to predict what the LLM will say. That's intended: stage 1's job is to cheaply predict whether stage 2 will find something. The hand-labelled set is the independent check, and it's also the guard against the gate learning the LLM's blind spots.

Choosing the classifier

Keyword rules don't work here, because fraud calls don't share vocabulary. "He said he was from RBC security" and "the caller pretended to work for the fraud team" mean the same thing with no words in common. So every transcript is turned into a 384-dimension embedding with a SentenceTransformer model (all-MiniLM-L6-v2), which puts both phrasings in the same place. We tried shrinking the embeddings with PCA, but precision got worse, so we kept the full vectors.

We benchmarked SVM, XGBoost, CatBoost, LightGBM, random forest and logistic regression. The complex models were only marginally better. Logistic regression won on what the gate needs: its score is a real probability you can set a cutoff on, it runs in under a millisecond after embedding, and it's easy to explain to a bank's model-risk reviewers. With a small training set built on LLM-derived labels, extra model capacity mostly meant overfitting risk.

Fraud is a small minority of calls, so a model can look accurate by always saying "not fraud." We split train and test with stratified sampling so both kept the real fraud ratio, and handled the imbalance by moving the cutoff instead of resampling, since resampling distorts the probabilities the whole cascade depends on.

MetricValueWhat it means
Recall94%Of the fraud calls, 94% were caught
Precision77%Of the calls flagged, 77% were fraud
F185%The balance of the two
Subset accuracy95.4%The full set of predicted labels matched exactly
Jaccard95.5%Average overlap between predicted and true labels
End-to-end results on 499 held-out calls, tracked in MLflow.

77% precision was a deliberate trade: a false alarm costs an analyst a few seconds, while a missed fraud is never looked at again. One caveat I'd raise myself: fraud is rare, so the test set has only about 40 fraud calls, and 94% recall on 40 cases has a wide confidence interval.

The daily pipeline

The pipeline runs once a day. Each morning a job processes the previous day's calls. The threat hunting team works campaigns over days, so a daily batch matched how they work, was far simpler to run than streaming, and fit how transcripts already landed in S3.

Ingesting one day of calls
Ingesting one day of callsEach morning a job lists the previous day's transcript files in S3, parses them into calls, and sends each call to the backend. The backend inserts the transcript and skips it if that call ID was already processed. New calls go to the ML service in batches, and the detection, indicators and evidence are written in one transaction. A failed call is retried three times, 300 seconds apart.Daily jobS3Backend APIPostgreSQLML servicelist yesterday's files1transcriptsparse into calls2ingest call3insert if new4call ID already there: skip, return 200predict (batch)5labels, indicators, evidencewrite all in one transaction6error: retry 3x, 300s apart
  • service
  • database or storage
  • scheduled job
  • bold outline = what I owned
  • request
  • failure path
  1. The daily job lists the previous day's transcript files in S3.
  2. A dedicated parser turns each file into calls, speaker turns and timestamps, so the ML service never sees vendor formats.
  3. Each call is sent to the backend.
  4. The backend inserts the transcript with ON CONFLICT DO NOTHING on the call ID. A call it has already processed is skipped.
  5. New calls go to the ML service in batches.
  6. The detection, its indicators and its evidence are written in one transaction, so the console never shows a detection with missing evidence.

When it fails

  • A timeout or transient error is retried three times, 300 seconds apart.
  • A transcript that still fails goes to a dead-letter queue, parked for inspection instead of lost.

Every write is keyed on a natural key, such as the call ID, or the indicator value for indicators. If the job crashes halfway, or someone reruns yesterday by hand, nothing is written twice.

The daily pipeline
The daily pipelineA transcript arrives in object storage and ingestion opens a pipeline job that records what happens to every transcript. The backend sends transcripts to the ML service in concurrent batches; calls that time out are retried, and transcripts that still fail go to a dead-letter queue for inspection. The stage 1 classifier filters out about 65 percent of calls, which stop there. The stage 2 LLM classifies the rest and extracts threat indicators. Detections are stored and related ones grouped into campaigns, analysts review them in the console, and their decisions are stored.IngestionML serviceTranscriptsobject storagePipeline jobstate per callBatch callsconcurrentmodelStage 1classifiermodelStage 2LLMDetections+ campaignsAnalyst consolereview, decideDead-letter queuefor inspection1234filtered (~65%)store5review6decision7timeout: retrystill failing after retriesstops here
  • client
  • service
  • database or storage
  • queue or stream
  • model
  • scheduled job
  • bold outline = what I owned
  • request
  • async: event, message, webhook
  • failure path
The same run, end to end: from S3 through both stages to the analyst's decision.

Auth

Auth was designed in from day one. Analysts sign in through Keycloak single sign-on, and a silent check signs them in without a redirect if they already have a session. The console uses OIDC with PKCE, because a browser app can't keep a secret and PKCE stops a stolen authorization code from being used by anyone else.

Every API call carries a bearer token. The backend checks its signature, issuer, audience and expiry against Keycloak's published keys (JWKS), cached so it doesn't call Keycloak on every request. Then role-based access control decides what each role can see. The backend passes the same token on to the ML service, which checks it too, so no internal endpoint is left open.

Sign-in and access control
Sign-in and access controlThe analyst signs in to the console through Keycloak single sign-on, using OIDC with PKCE because a browser app can't keep a secret. The console sends the bearer token with every API call. The backend checks the token's signature against Keycloak's published keys, then role-based access control decides what that role can see and do. The token is passed along on calls to the ML service. A request without a valid token or the right role is rejected.ConsoleKeycloakBackend APIML servicesign in (OIDC + PKCE)1tokensAPI call + bearer token2published keys (JWKS)public keysverify signature3check role4call + same token5verify token toodata for this rolerejected: no valid token or role
  • client
  • service
  • external
  • bold outline = what I owned
  • request
  • failure path
  1. The analyst signs in through Keycloak with OIDC and PKCE.
  2. The console sends the bearer token with each API call.
  3. The backend verifies the token's signature against Keycloak's published keys (JWKS).
  4. Role-based access control decides what this role can see and do.
  5. The token is passed along on calls to the ML service, which verifies it again.

When it fails

  • A request without a valid token or the right role is rejected.

The API

The backend is async all the way down: FastAPI with async SQLAlchemy and asyncpg, so one worker handles many requests while it waits on Postgres and the ML service. Its routes are grouped by job:

RoutesPurpose
/healthLiveness and readiness checks for OpenShift
/pipelineWhere ingestion enters
/transcriptsRaw and parsed transcripts
/detectionsFlagged calls, with labels and scores
/indicatorsExtracted URLs, phone numbers and payment details, with counts across calls
/campaignsIndicators grouped across calls into likely scam campaigns
/reviewsAnalyst verdicts: confirm or dismiss
/analyticsTotals for the overview dashboard
Backend route groups.

The ML service has three endpoints, all behind the same JWT auth:

EndpointUse
POST /predictOne transcript, stage 1 only: a quick check
POST /predict/pipelineOne transcript through both stages
POST /predict/batchMany transcripts: stage 1 on all, stage 2 batched for the ones that pass
ML service endpoints.
{
  "call_id": "c-2026-07-14-00421",
  "model_version": "cascade-lr-v3",
  "stage1": { "score": 0.62, "escalated": true },
  "stage2": {
    "labels": [
      { "fraud_type": "impersonation", "confidence": 0.91 },
      { "fraud_type": "investment_scam", "confidence": 0.74 }
    ],
    "indicators": [
      { "kind": "domain", "value": "secure-rbc-verify.com" },
      { "kind": "payment_method", "value": "e-transfer" }
    ],
    "evidence": [
      { "span": [1180, 1264], "excerpt": "they said to go to secure-rbc-verify dot com and..." }
    ]
  }
}
What one prediction looks like. The values are illustrative.

The analyst console

  • Overview: how many calls were screened and escalated, and fraud types over time.
  • Detections: each flagged call with its labels, scores and indicators, and the transcript with the evidence highlighted.
  • Indicators: every extracted domain, phone number and payment detail, with how many calls mention it. This is the signal the threat team never had before.
  • Campaigns: indicators grouped across calls into likely scam campaigns.

Early on we also designed a view for advisors on live calls, with keyword highlights and suggested next steps. It shaped the product direction, but the version we shipped focused on the threat team.

Running it

Every service is a Docker image deployed to OpenShift, with secrets (database credentials, the LLM gateway key, Keycloak config) injected from Vault. OpenTelemetry traces follow a request from the console through the API to the ML service, and Grafana shows latency, error rates and score drift: if the share of calls passing the 0.15 gate shifts, either the calls or the model have changed.

MLflow tracks evaluation runs and model versions, and every detection stores the model version that produced it, so any finding can be traced back to the exact model. There are tests across all three services: pipeline helpers, the transcript parser, endpoints, API schemas, classifier utilities and the frontend's data hooks. Database changes ship as Alembic migrations.

Key decisions

DecisionInstead ofWhy
Two-stage cascadeAn LLM on every callAbout 65% fewer LLM calls, roughly $1.5M a year projected
Low threshold (0.15)A threshold of 0.5A dropped fraud call is never seen again; a false alarm costs one LLM call
Logistic regression on embeddingsBoosted trees or a fine-tuned transformerFast, calibrated, and easy to explain to model-risk reviewers
Daily batchReal-time streamingMatches how the threat team works and is much simpler to run
Contract-first APIIntegrating at the endFour people built three services in parallel
Separate ML serviceThe model inside the APISeparate deploys; slow LLM calls can't block analysts
Keycloak with PKCE and JWKSSession cookies or a shared secretThe bank's SSO standard, safe for a browser app
The main choices, and what they replaced.

Results

recall
94%
precision
77%
f1
85%
test set
499 held-out calls
calls filtered before the LLM
≈65%
LLM spend
$2.35M → $822K/yr (projected)
fraud loss mitigated
~$18M/yr (projected)
clients protected
12K+/yr (projected)
demo
1,000+ attendees

The model was evaluated on 499 held-out calls, not run on all 23 million. The cost figures project that sample over the annual volume. The fraud-loss and clients-protected figures come from a separate business-case model built on historical fraud volumes, average losses and how often earlier detection allows an intervention. The $18M is the conservative end of that model.

What I'd change

  • Label more fraud calls by hand. About 40 positives isn't enough for a tight recall estimate, and k-fold cross-validation would help too.
  • Mask personal information before transcripts reach the LLM gateway.
  • Add a model registry, so retraining and rollback aren't manual.
  • Close the loop on drift. We could see it, but nothing acted on it automatically.
  • Finish replacing the mock data fallbacks in the console with live data.
  • Connect straight to the call platform instead of exported transcripts, and add streaming for real-time alerts to advisors.
  • Finer-grained roles for each analyst team.