Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Customer Complaint Routing Agent

AI agents as controlled workflows, not chatbots.

A customer submits a complaint. A LangGraph workflow classifies it with an LLM, runs a deterministic risk check, routes it to one of 7 departments, creates a ticket in SQLite, mock-sends a department email, and returns a confirmation to the customer.

The key idea: only one step uses AI. The LLM classifies the complaint — and that's it. Every other step (risk rules, routing, ticketing, email, the customer reply) is plain, predictable Python. The AI advises; the workflow decides. That's what makes this an agent you can trust in production rather than a chatbot you hope behaves.


Table of contents


How it works (the big picture)

flowchart LR
    User([Customer]) -->|fills form| UI[frontend/index.html]
    UI -->|POST /tickets JSON| API[FastAPI<br/>backend/main.py]
    API -->|run_workflow| G[LangGraph workflow<br/>backend/graph.py]

    subgraph Graph [The workflow - backend/nodes.py]
        direction TB
        N[7 nodes run in order]
    end

    G --> Graph
    Graph -->|one LLM call| LLM[Gemini 2.5 Flash<br/>structured output]
    Graph -->|save ticket| DB[(SQLite<br/>tickets.db)]
    Graph -->|mock send| Mail[Console email<br/>backend/email_sender.py]
    Graph -->|TicketOutput JSON| API
    API -->|result card| UI
Loading

There are three moving parts:

  1. Frontend — one static HTML file with a form and a result card. No build step.
  2. API — a FastAPI app with a single meaningful endpoint, POST /tickets.
  3. Workflow — a LangGraph state machine of 7 nodes. It calls Gemini once (to classify), writes to SQLite, and "sends" an email by printing it.

The workflow

The heart of the project. Seven nodes run in a fixed order, with exactly one branch — after the risk check, the complaint is routed either normally or down a human-review path. Both paths converge again on ticket creation.

flowchart TD
    START([START]) --> A[receive_complaint<br/><i>log the incoming complaint</i>]
    A --> B[classify_complaint<br/><b>the only LLM call</b><br/>structured output]
    B --> C{risk_check<br/><i>apply deterministic rules</i>}

    C -->|needs_human_review| D2[route_human_review<br/><i>flag + route</i>]
    C -->|normal| D1[route_department<br/><i>look up dept email</i>]

    D1 --> E[create_ticket<br/><i>TCK-XXXXX, save to SQLite</i>]
    D2 --> E

    E --> F[send_email<br/><i>mock: print payload</i>]
    F --> G[customer_response<br/><i>compose reply</i>]
    G --> END([END])

    style B fill:#6366f1,color:#fff
    style C fill:#f59e0b,color:#000
Loading
# Node What it does Uses AI?
1 receive_complaint Logs the incoming complaint
2 classify_complaint Asks Gemini to classify it into a department, type, priority, confidence + required info
3 risk_check Deterministic rules: low confidence → fallback; fraud → escalate
4 route_department / route_human_review Looks up the department email (the one conditional branch)
5 create_ticket Generates TCK-XXXXX, saves to SQLite
6 send_email Mock-sends the department email (prints the payload)
7 customer_response Composes the confirmation message back to the customer

Every node prints one trace line when it runs, so the whole decision is visible live in the terminal — ideal for a talk or a screen-share.


Request lifecycle

What happens end-to-end when a customer hits "Submit":

sequenceDiagram
    actor C as Customer
    participant UI as index.html
    participant API as FastAPI
    participant WF as LangGraph
    participant AI as Gemini 2.5 Flash
    participant DB as SQLite

    C->>UI: fill form + submit
    UI->>API: POST /tickets (JSON)
    API->>WF: run_workflow(complaint)
    WF->>WF: receive_complaint
    WF->>AI: classify_complaint (prompt + schema)
    AI-->>WF: {department, priority, confidence, ...}
    WF->>WF: risk_check (rules)
    WF->>WF: route (normal | human review)
    WF->>DB: create_ticket → save TCK-XXXXX
    WF->>WF: send_email (mock, prints payload)
    WF->>WF: customer_response
    WF-->>API: TicketOutput
    API-->>UI: JSON result
    UI-->>C: render result card
Loading

The 7 departments

Defined once in backend/departments.py — the single source of truth for routing. The scopes are also injected into the LLM prompt so the model knows what each team covers.

Department Email Handles
Payments & Settlement payments@example.com failed transfers, refunds, reversals, duplicate charges, debited-but-merchant-not-credited
Technical Support technical@example.com app errors, API errors, failed webhooks, dashboard bugs, downtime
Account Access account@example.com login, password reset, OTP, locked accounts, 2FA
Merchant Support merchant@example.com merchant onboarding, POS, settlement reports, merchant dashboard, payment acceptance
Risk & Security risk@example.com suspicious transactions, unauthorized debits, suspected fraud, account compromise — always High + human review
Customer Success success@example.com product questions, how-to, usage guidance, education
General Support general@example.com unclear/incomplete — the fallback

The code, file by file

backend/
  main.py             FastAPI app, CORS, the POST /tickets endpoint
  graph.py            wires the 7 nodes into a LangGraph + run_workflow()
  nodes.py            the 7 node functions (classify_complaint calls the LLM)
  models.py           Pydantic models + the GraphState TypedDict
  departments.py      the 7 departments: names, emails, scopes
  email_sender.py     mock sender — pretty-prints the email payload
  storage.py          SQLite: create table, generate TCK ids, save/read tickets
  test_complaints.py  fires 4 sample complaints for a live demo
frontend/
  index.html          the Create Ticket form + result card (vanilla JS)
requirements.txt      pinned dependencies
.env.example          template for your GOOGLE_API_KEY
  • main.py — Creates the FastAPI app, enables CORS for all origins (so the static HTML can call it from file://), and exposes POST /tickets. It loads .env before importing the LLM, and wraps the workflow in a try/except so any failure returns a clean 500 with the message.

  • graph.py — Builds the StateGraph, adds the 7 nodes, and wires the edges. The single add_conditional_edges after risk_check chooses route_department vs route_human_review. run_workflow() invokes the compiled graph and maps the final state into a TicketOutput.

  • nodes.py — Each node takes the GraphState and returns a partial update. classify_complaint builds the prompt (including the department scopes), calls ChatGoogleGenerativeAI(...).with_structured_output(...), and is defensive: an unrecognized department name falls back to General Support.

  • models.pyComplaintInput (the form), ComplaintClassification (what the LLM must return), TicketOutput (the API response), and GraphState (the data passed between nodes).

  • storage.py — A flat tickets table. next_ticket_id() produces TCK-00001, TCK-00002, … from the row count. save_ticket() stores the record (the required_information list is JSON-encoded).

  • email_sender.pysend_department_email() builds the payload, pretty-prints it to the console, and returns True. Swap this one function for a real provider and the rest of the app is unchanged.

  • departments.py — The departments dict plus helpers: get_email(), department_names(), scopes_for_prompt().


The classification contract

classify_complaint is the only place AI is used, and it's constrained to a strict Pydantic schema via structured output — the model must return exactly these fields, validated before anything downstream runs:

class ComplaintClassification(BaseModel):
    department: str            # one of the 7 names, exactly
    complaint_type: str        # short label
    priority: str              # Low | Medium | High
    confidence: float          # 0.0 - 1.0
    required_information: list  # what the dept still needs
    reason: str                # one-sentence justification

This is why the agent is reliable: the LLM can't return free-form prose or call arbitrary tools. It fills in a form. The deterministic code does the rest.


The risk rules

After classification, risk_check applies two hard rules in plain Python — the LLM does not get to override these:

flowchart TD
    A[classification result] --> B{confidence < 0.5?}
    B -->|yes| C[force department =<br/>General Support]
    B -->|no| D[keep department]
    C --> E{Risk & Security<br/>OR fraud keywords?}
    D --> E
    E -->|yes| F[priority = High<br/>needs_human_review = true]
    E -->|no| G[keep priority<br/>no review needed]
Loading
  • Low confidence (< 0.5) → forced to General Support. If the AI isn't sure, a human triages it rather than mis-routing.
  • Risk & Security department, or fraud/unauthorized keywords → priority becomes High and needs_human_review = true. Security never gets silently auto-handled.

Setup

Requires Python 3.11+.

# 1. clone
git clone https://github.com/prewsh/customer-routing-agent.git
cd customer-routing-agent

# 2. virtual environment
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate

# 3. dependencies
pip install -r requirements.txt

# 4. API key
cp .env.example .env             # then edit .env and paste your key

Get a free Gemini API key from Google AI Studio and put it in .env:

GOOGLE_API_KEY=your_key_here

.env is gitignored — your key stays on your machine and is never committed.


Running it

Start the backend (from the project root, not inside backend/):

uvicorn backend.main:app --reload

The API runs at http://localhost:8000. Then open the frontend:

open frontend/index.html         # macOS — or just double-click it

Fill in the form and submit. The result card fills in, and the terminal shows the live node trace:

----- workflow start: 'Debited but merchant not credited' -----
[receive_complaint] -> 'Debited but merchant not credited' from ada@example.com
[classify_complaint] -> Payments & Settlement, High (conf 0.95)
[risk_check] no escalation -> Payments & Settlement, High
[route_department] -> Payments & Settlement (payments@example.com)
[create_ticket] -> TCK-00001 saved
📧  MOCK EMAIL -> payments@example.com    { ...full payload... }
[send_email] -> Payments & Settlement email_sent=True
[customer_response] -> ticket TCK-00001 ready for ada@example.com
----- workflow end -----

The demo script

For a live talk, skip the browser and fire four representative complaints at once:

python backend/test_complaints.py

It runs: (1) a payments dispute, (2) a fraud case (→ flags human review), (3) a login/OTP issue, and (4) a vague "your app is bad" (→ falls back to General Support). The output is color-coded per complaint and ends with a routing scoreboard:

  #1  TCK-00001  ->  Payments & Settlement
  #2  TCK-00002  ->  Risk & Security  ⚠ human review
  #3  TCK-00003  ->  Account Access
  #4  TCK-00004  ->  General Support

API reference

POST /tickets

Request body:

{
  "name": "Ada Okafor",
  "email": "ada@example.com",
  "title": "Debited but merchant not credited",
  "description": "I was debited but the merchant got nothing.",
  "reference_id": "TXN-4412"
}

reference_id is optional.

Response:

{
  "ticket_id": "TCK-00001",
  "department": "Payments & Settlement",
  "priority": "High",
  "complaint_type": "failed transfer",
  "reason": "Customer was debited but the merchant was not credited.",
  "required_information": ["transaction reference", "amount", "date"],
  "needs_human_review": false,
  "department_email_sent": true,
  "customer_response": "Hi Ada Okafor, thanks for reaching out..."
}

GET /

Health check — returns {"status": "ok"}.


Tech stack

Layer Choice
Language Python 3.11+
API FastAPI + uvicorn
Workflow LangGraph
LLM Gemini 2.5 Flash via langchain-google-genai
Validation Pydantic
Storage SQLite (stdlib)
Frontend Plain HTML + vanilla JS (no build step)

About

A simple customer routing agent with HTML page and email notification built with lang graph and python for inter-switch developer community.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages