Case 1 — AI-Powered Banking Dispute Automation Pipeline Tencent Cloud × UTM Hackathon 2026, AI Agent Track
Self-auditing AI Expert Team that resolves bank disputes in minutes, not hours.
TrustLoop automates the full lifecycle of a Malaysian bank complaint — from email intake to compliant customer communication — using six specialized AI Experts orchestrated behind a state machine that an independent Compliance Guardian Expert can veto at every step. Every automated decision is written to a tamper-evident, SHA-256 hash-chained ledger.
Demo - https://youtu.be/VEk_3OrWTtw
| # | Expert | Module | Skill | MCP / Data |
|---|---|---|---|---|
| 1 | Intake & Security | backend/app/experts/intake.py |
Regex + OCR field extraction, AES-256-GCM PII encryption | Mailtrap (mailbox), Tencent Cloud OCR |
| 2 | Classification & Governance | backend/app/experts/classification.py |
7-category classification, BNM urgency/SLA rules | Gemini (structured JSON output) |
| 3 | Core Verification | backend/app/experts/verification.py |
Deterministic account / transaction / NRIC / CRM name matching | Mock Core Banking + CRM (Supabase tables) via MCP, Gemini (narrative only) |
| 4 | Financial Resolution | backend/app/experts/resolution.py |
Journal-entry posting with boundary checks | Supabase (journal_entries) |
| 5 | Compliance Guardian | backend/app/experts/compliance_guardian.py |
State-machine enforcement, hash-chain ledger, similarity veto | Supabase pgvector (experience_repository) |
| 6 | Customer Communication | backend/app/experts/communication.py |
BNM/FMOS-compliant templating | Mailtrap (send), WeasyPrint (PDF certificate) |
In banking disputes, getting a decision wrong is worse than being a bit
slow — a wrong credit damages the bank's books; a wrong rejection damages
the customer relationship and can trigger FMOS escalation. Case 1's surface
ask is speed (90 min → stretch target under 5 min); TrustLoop's bet is that
regulators and banks actually need faster + safe to supervise. So the
pipeline defaults conservative: when confidence is low, similar past cases
were overturned, or identity/core checks disagree, the Guardian forces
MANUAL_REVIEW instead of gambling on an auto-PASS.
Most dispute-automation prototypes treat "audit trail" as a passive database table. Here it's an active agent with veto power:
- State machine enforcement (
backend/app/core/state_machine.py, built ontransitions) — a case can never skip stages (e.g. jump straight toFINANCIALLY_RESOLVEDfromINTAKE). Illegal transitions are rejected and logged asVIOLATIONentries, not silently ignored. - Memory Flywheel read-before-write (
experience_repository+ pgvector) — before letting any decision through, the Guardian embeds the case summary, retrieves similar historical cases, and forcesMANUAL_REVIEWif similarity confidence is low or if similar past decisions were frequently overturned on appeal. Every closed case writes its outcome back (Share × Iterate: build once, reuse N times — Expert skills and settled experience are reused across the 7 dispute categories instead of re-deriving judgment from scratch each time). Move 3: INSTALL — "read first, write back, a closed loop". - Tamper-evident decision ledger (
backend/app/core/ledger.py) — every decision is SHA-256-chained to the previous one.GET /cases/{id}/ledgerrecomputes the whole chain and returnschain_intact: true/false— this is the dashboard's "decision ledger browser" demo feature.
| Layer | Technology | Where |
|---|---|---|
| AI agent build tool | CodeBuddy | Entire backend was built inside CodeBuddy — see submitted conversation history / screenshots for proof of usage. |
| Agent orchestration / NL ops console | WorkBuddy (custom MCP server) | workbuddy-mcp/server.py — investigators ask natural-language questions about live cases directly from WorkBuddy; see §5. |
| Core banking/CRM verification | Custom MCP server (core-banking-mcp/, built with fastmcp) |
core-banking-mcp/server.py + backend/app/services/core_banking_client.py. Case 1 explicitly names MCP for this step; see §6 for why it's a subprocess bridge, not an in-process import. |
| OCR | Tencent Cloud OCR — GeneralBasicOCR, MLIDCardOCR |
backend/app/services/ocr.py. MLIDCardOCR natively recognizes Malaysian ID documents (MyKad/MyPR/MyTentera/MyKAS/POLIS/i-Kad), a direct fit for NRIC extraction. |
| LLM | Google Gemini (google-genai) |
backend/app/services/llm.py. Structured JSON output (not free text) for classification; free-tier friendly. |
| Embeddings | Gemini gemini-embedding-001 |
Memory Flywheel similarity search. |
| Database | Supabase (Postgres + pgvector + Vault) |
backend/app/db.py, backend/db/schema.sql. Chosen because CodeBuddy IDE's own docs list Supabase as a first-class BaaS target. |
| Email (intake and outbound) | Mailtrap (official Python SDK, sandbox mode) | backend/app/services/mailer.py. One tool covers both directions — no domain verification needed for a hackathon demo. |
| PII encryption | AES-256-GCM (cryptography) |
backend/app/security/encryption.py. Envelope encryption for NRIC/account numbers before they ever reach the database. |
| State machine | transitions |
backend/app/core/state_machine.py. |
| PDF generation | WeasyPrint | Compliance certificate (GET /cases/{id}/certificate.pdf). |
| Backend framework | FastAPI | backend/app/main.py. |
| Frontend | Next.js + React + Tailwind | frontend/ — management dashboard, case detail, ledger browser, Ops Console. |
| Deploy | Current: Render (Docker). Target: Tencent Serverless Cloud Function + KMS + TencentDB + VectorDB (see §7). | Root Dockerfile for the hackathon demo runtime (bundles Pango/Cairo for WeasyPrint). |
Note on the WorkBuddy Agent Mode naming: WorkBuddy's documented
capability is called Work Mode (plus Skills / MCP / Expert Center), not
"Agent Mode" — this README and the codebase use the correct terminology
throughout.
Every LLM call is structured, not free-form chat, and lives in a single reviewable place per Expert:
- Classification (
backend/app/experts/classification.py): system instruction pins the model to the 7 official BNM categories and instructs conservative confidence scoring;response_schemaforces machine-parsable JSON (category,confidence,reasoning) — verified live against the real Gemini API before integration. - Verification narrative (
backend/app/experts/verification.py): the PASS/FAIL/MANUAL_REVIEW decision itself is rule-based, not LLM-based — the LLM is only asked to phrase a one-sentence audit-trail explanation of a decision that was already made deterministically. This keeps the highest-stakes decision reproducible. - Ops Console intent parsing (
backend/app/services/ops_query.py): natural language ("this week's high-urgency mis-selling cases") is parsed into a structured intent (list_cases/explain_case/sla_status/general_stats) viaresponse_schema; the actual data retrieval is a deterministic Supabase query — the LLM never invents numbers, only narrates real query results back in plain English.
External accounts needed: Supabase, Google AI Studio (Gemini), Tencent Cloud
International (OCR), Mailtrap. Full credential setup steps are in
backend/.env.example / frontend/.env.example comments.
- Backend: copy
backend/.env.example→backend/.envand fill in values. - Core banking MCP: copy
core-banking-mcp/.env.example→core-banking-mcp/.env(sameSUPABASE_URL/SUPABASE_SECRET_KEYas the backend). - WorkBuddy MCP (optional NL console): copy
workbuddy-mcp/.env.example→workbuddy-mcp/.env. - Frontend: copy
frontend/.env.example→frontend/.env.local.
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in real values, see `.env.example` comments
# ONE-TIME: open Supabase Dashboard -> SQL Editor -> paste db/schema.sql -> Run
# REQUIRED: set up the core-banking MCP server in its OWN venv (must stay
# separate from backend/.venv — see §6 for why mixing them breaks FastAPI)
cd ../core-banking-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # same SUPABASE_URL / SUPABASE_SECRET_KEY as backend/.env
deactivate
cd ../backend
# macOS only: WeasyPrint needs Pango/Cairo, not bundled with the Python wheel
brew install pango
# macOS only: point the dynamic linker at Homebrew's lib folder
export DYLD_LIBRARY_PATH="/opt/homebrew/lib"
source .venv/bin/activate # back to the backend's own venv
uvicorn app.main:app --reload --port 8000
# -> http://localhost:8000/docs for interactive API docsLinux/Render (Docker) does not need the DYLD_LIBRARY_PATH step — the
Dockerfile installs the equivalent apt packages instead. The Dockerfile
also sets up core-banking-mcp's venv inside the image.
# 1. Simulate an incoming complaint email straight into the Mailtrap sandbox inbox
# (or send one for real via the Mailtrap dashboard / API)
curl -X POST http://localhost:8000/cases/intake/poll
# 2. Run a case through the full Expert Team
curl -X POST http://localhost:8000/cases/<case_id>/process
# 3. Inspect its tamper-evident decision trail
curl http://localhost:8000/cases/<case_id>/ledger
# 4. Ask the Ops Console a natural-language question
curl -X POST http://localhost:8000/ops/query \
-H "X-Internal-Ops-Key: <INTERNAL_OPS_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"question": "how many cases are close to breaching SLA?"}'Case 1 explicitly requires the solution to be "operable through natural
language commands without requiring programming expertise". workbuddy-mcp/
is a small MCP server (built with fastmcp) exposing one tool,
ask_ops_console, that forwards a question to this backend's /ops/query
endpoint — the exact same endpoint the web dashboard's search box calls.
To wire it into WorkBuddy: add it as a local MCP server (stdio) pointing at
workbuddy-mcp/server.py, after filling workbuddy-mcp/.env. An
investigator can then ask WorkBuddy things like:
- "What are this week's high-urgency mis-selling cases?"
- "Why was case 4521 auto-approved?"
- "How many cases are close to breaching their SLA?"
and get answers computed from the live case database — not a canned dev-time demo.
Build notes from iterating on the pipeline — integrations that took a few tries, hardening that landed once the dashboard needed real KPIs and auth, and bugs that only showed up on a live end-to-end run against real Supabase / Gemini / Tencent OCR / Mailtrap (not mocks).
Verification talks to core banking / CRM through a real MCP boundary, not
an in-process DB query. A standalone core-banking-mcp/ server (built
with fastmcp, own venv) exposes get_account, get_disputed_transactions,
and verify_dispute. backend/app/experts/verification.py calls it via
backend/app/services/core_banking_client.py.
verify_dispute also cross-checks intake NRIC and complainant name against
CRM fields on the mock account (matched_nric, matched_customer_name). A
mismatched identity forces MANUAL_REVIEW instead of a silent PASS.
Dependency wrinkle: importing fastmcp into the backend venv as an
in-process MCP client broke FastAPI — fastmcp's dependencies
(starlette>=1.0, websockets>=15) hard-conflict with fastapi==0.115.6
(confirmed live: Router.__init__() got an unexpected keyword argument 'on_startup' after starlette was silently upgraded). Fix: keep
core-banking-mcp/ in its own isolated venv and shell out via
core-banking-mcp/mcp_client_cli.py (stdin/stdout JSON bridge). The MCP
protocol call is real; the dependency trees never collide. That is also why
backend/requirements.txt does NOT list fastmcp.
While wiring this, MCP-unreachable used to look identical to "account not
found" (both returned matched_account: False) and would have wrongly
returned FAIL on an infra outage. Those paths are now distinguished —
outage → MANUAL_REVIEW.
Added backend/app/core/metrics.py so every case response carries
processing_seconds (intake → communication sent) and age_seconds (still-
open cases). GET /metrics/summary aggregates average processing time,
% of PASS cases under the 5-minute stretch target,
avg_processing_seconds_pass_under_5min (stretch-target cohort — excludes
overnight queue-wait outliers), average classification confidence,
SLA-at-risk/overdue counts, investigator workload distribution, and estimated
investigator-minutes-freed (baseline 90 min/case). Feeds the frontend
dashboard KPI cards, Before/After impact, and SLA alert deep-links.
Additive, read-only endpoint (does not change /metrics/summary or
perform assigns). backend/app/core/workload_rebalance.py compares open-case
loads across investigators, picks move candidates from the heaviest desk
(preferring overdue / at-risk / High urgency), and returns:
- Odi-facing summary (
odi_summary) from_investigator→to_investigatorwith open counts- concrete
move_cases(id, short id, SLA bucket) expected_sla_improvement_pct(heuristic headroom estimate)
GET /metrics/workload-rebalance powers the dashboard Workload card’s info
icon hover tip. Actual reassignment still goes through the existing
POST /cases/{id}/assign.
Auth is enforced on the API, not just stored as a profiles.role field:
- Every
/cases/*and/metrics/*endpoint requiresDepends(require_identity)(Supabase JWT or internal-service key) — no credentials →401. - Role is looked up from
profilesby the JWTsubclaim, not trusted from a custom claim (Supabase does not populate one without an Auth Hook we have not configured). - The load-bearing boundary:
POST /cases/{id}/manual-reviewrequirescompliance_officerormanager—investigator→403; compliance officer succeeds, posts the journal entry, and writes aMANUAL_REVIEW_DECISIONledger entry with who and why. backend/scripts/seed_test_users.pyseeds investigator / compliance_officer Auth users + matchingprofilesrows for the login flow.
Read endpoints authenticate the caller but do not yet scope rows by role (e.g. investigator → only assigned cases) — deferred as a product decision, not missing plumbing.
BNM SLA windows are working days, not calendar days — skipping only
Sat/Sun under-counts Malaysian public holidays and can stamp an optimistic
sla_due_at. Fixed by wiring a gazette-backed Malaysia Calendar API
into the deterministic rules engine (not the LLM, not MCP):
backend/app/services/malaysia_calendar.pyfetches and caches public holidays for the configured state (defaultKL), including state weekend patterns where they differ.backend/app/core/urgency_rules.pyadvances High/Medium/Low windows only on days the calendar marks as working days.- Verified live against the API (e.g. Fri 2026-03-20 + 1 WD → Mon 2026-03-23, skipping Aidilfitri on Sat 21).
- If the API is unreachable, falls back to Sat/Sun-only so classification
never blocks; toggle via
SLA_USE_MALAYSIA_CALENDAR/SLA_CALENDAR_STATEin env.
Dashboard SLA countdown / overdue / 48h-risk cards keep using sla_due_at
— no UI change; newly classified cases simply get accurate deadlines.
BNM Low urgency is “20 working days + extension”. TrustLoop models that as a two-step workflow (not a silent date edit):
- Investigator
POST /cases/{id}/sla-extension/requestwith reason + extra working days (capped bySLA_EXTENSION_MAX_DAYS) — only whenurgency=Low, case still open, and no pending request. - Compliance officer / manager
POST /cases/{id}/sla-extension/decideapprove/reject. Approve recalculatessla_due_atvia holiday-awareextend_sla_due_at; reject leaves the deadline unchanged. - Both steps write
case_events+ hash-chained ledger entries (SLA_EXTENSION_REQUEST/SLA_EXTENSION_DECISION). Pending state is exposed onGET /cases/{id}aspending_sla_extension.
Case detail UI: investigators see “Request extension”; officers see Approve/Reject when a request is pending. SLA countdown updates after approval with no extra dashboard wiring.
Full path on the real stack: Mailtrap sandbox email → intake → classify → verify → resolve → communicate → PDF certificate. Issues that only showed up under that run:
transitionsauto-transitions footgun: the library auto-generates ato_<STATE>()method per state that jumps there from ANY current state — would have silently let any Expert bypass the Compliance Guardian entirely. Fixed withauto_transitions=False.- Hash-chain timestamp drift risk: hashing a
timestamptzcolumn's round-tripped value (instead of the exact string used to compute the hash) would causeverify_chain_integrity()to false-negative after a Postgres round trip. Fixed by storing the exact hashed string verbatim in a dedicatedhashed_at textcolumn. - Official
mailtrapPython SDK bug (reproduced on both 2.6.0 and 2.6.1):testing_api.messages.get_list()/ related typed methods crash with a PydanticValidationErrorbecause Mailtrap's live API sometimes returnsblacklists_report_info: {"result": "error"}, a shape the SDK's model doesn't cover. Worked around by reading inbox messages via direct, schema-tolerant HTTP calls (backend/app/services/mailer.py) instead of the SDK's typed models; sending still uses the SDK (unaffected). - Mailtrap testing-plan rate limit: rapid-fire sends during testing hit
"Too many emails per second". Added retry-with-backoff around
send_compliance_email. - Postgres
uuidhas noILIKEoperator: the Ops Console's "explain case X" query originally tried partial-UUID matching viailike, which Postgres rejects outright foruuidcolumns (42883). Fixed by matching the prefix in Python over a bounded recent-cases window instead. - PII leakage in an API response: the Ops Console's
explain_casepath was returning the full case row, including the encrypted NRIC/account ciphertext blobs, to the caller. Stripped before returning — least- privilege by default even for ciphertext. - Regex over-capture: complainant-name extraction (
backend/app/experts/intake.py) originally captured up to 60 characters after "my name is", swallowing the rest of the sentence. Fixed to stop at the first clause boundary. - Memory Flywheel cold start: the very first case in a category will
always have zero similar-case history, and the Guardian conservatively
forces
MANUAL_REVIEWin that situation — correct behavior, but it means a from-scratch demo needs a few historical entries seeded first to show the full auto-resolution happy path.backend/scripts/seed_experience_repository.pydoes this (kept as a permanent demo utility, not a throwaway script).
7. Documented simplifications (hackathon scope, not hidden gaps)
- Mock Core Banking/CRM: no real bank system is available for a
hackathon;
mock_bank_accounts/mock_bank_transactionsinbackend/db/schema.sqlis a seeded, controllable stand-in with the exact samePASS/FAIL/MANUAL_REVIEWcontract Case 1 asks for. CRM identity fields (nric,customer_name) are cross-checked via MCP when intake supplies them. - Tencent Cloud–native target stack (not wired for this demo): the
intended production shape is Serverless Cloud Function (API/runtime),
KMS (PII key custody), TencentDB (relational case store), and
VectorDB (Memory Flywheel embeddings). The hackathon build uses
Render + Supabase Postgres/
pgvector+ app-level AES-256-GCM instead, because those Tencent managed services are not free-tier friendly for a short contest window. Interfaces are already isolated (db.py,encryption.py, OCR already on Tencent Cloud), so swapping onto the full Tencent stack is an infra cutover, not a redesign. - Bilingual (BM/EN) templates: only English templates are implemented
in
backend/app/templates/; Bahasa Malaysia translation via Gemini is a straightforward follow-up (LlmService.generatealready supports it).
This project is licensed under the MIT License.
