Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# Image names must be lowercase; the repository is "ThreadHarbor".
- name: Image name
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"

- name: Log in to GitHub Container Registry
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
Expand All @@ -91,12 +95,12 @@ jobs:
with:
context: .
load: true
tags: ghcr.io/${{ github.repository }}:latest
tags: ${{ env.IMAGE }}:latest

- name: Run Trivy Vulnerability Scan
uses: aquasecurity/trivy-action@master
with:
image-ref: 'ghcr.io/${{ github.repository }}:latest'
image-ref: '${{ env.IMAGE }}:latest'
format: 'table'
exit-code: '1'
ignore-unfixed: true
Expand All @@ -110,7 +114,7 @@ jobs:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}


2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ wheels/
# Database
analytics.db
.venv/

node_modules/
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ ENV PYTHONUNBUFFERED=1 \
COPY --from=builder /app/.venv /app/.venv
COPY src/ /app/src/

# The service never installs anything at run time. pip carries its own copies of msgpack, urllib3
# and setuptools, older than the fixed versions, and the image scan fails on them.
RUN /app/.venv/bin/python -m pip uninstall -y pip && /usr/local/bin/python -m pip uninstall -y pip

RUN chown -R appuser:appuser /app

USER appuser
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,43 @@

## 🌟 Executive Summary

Customer support scaling on WhatsApp can quickly become unmanageable without robust automation. **OmniRouter** is an enterprise-grade scaffolding that intelligently triages user requests, utilizes LLM-driven knowledge retrieval (RAG), and seamlessly hands off unresolvable queries to human agents.
Customer support scaling on WhatsApp can quickly become unmanageable without robust automation. **OmniRouter** (repository: ThreadHarbor) triages WhatsApp messages, masks card numbers, emails and phone numbers, and hands conversations to a human agent when a customer asks for one, when sentiment or a loop says so, or when a policy limit is hit.

It is built with a strictly asynchronous architecture to handle high throughput with minimal resource overhead, ensuring sub-second latencies and strict data privacy.
It is written as an asynchronous FastAPI service (Python) with a matching Express server and React dashboard (TypeScript) for the demo.

---

## What runs today

- **The reply step is simulated.** No model is called for replies: a set of rules returns fixed answers,
reported as model `simulated` with 0 tokens. The one real model call is the optional handoff summary
(OpenAI, only when `OPENAI_API_KEY` is set); its input is masked first.
- **Masking** covers cards (13–19 digits), emails and phone numbers. Names are not masked. The human
agent's ticket transcript keeps the original text, because the agent needs it.
- **Handoff** goes to a mock helpdesk client (`HANDOFF_PROVIDER=mock`) unless one is configured.

## Run the demo (no keys)

```bash
npm install
npx tsx server.ts # http://localhost:3000
```

Open the **WhatsApp Simulator** tab and press the presets: business hours, the PII masking test, a refund
over $500 (escalated by the policy limit and turned into a ticket), then reply as the agent. The
**CCaaS Tickets** and **Analytics** tabs show what happened in this session only. Set
`SEED_SAMPLE_METRICS=true` to start with generated example history instead; the dashboard then says it
is sample data.

---

## 🚀 Key Features

| Feature | Description | Stack |
|:---|:---|:---|
| 🧠 **Intelligent Triage** | Intent recognition and natural language processing. | `OpenAI` `LangChain` |
| 🧠 **Triage** | Rule-based triggers for handoff requests, negative sentiment, loops and policy limits. The reply step is simulated (see below). | `Python` `TypeScript` |
| 📚 **RAG Pipeline** | Semantic search across company knowledge bases. | `Qdrant` `Embeddings` |
| 🛡️ **Privacy-First** | Zero-trust PII masking (names, cards, emails) before LLM exposure. | `Presidio` / `Regex` |
| 🛡️ **PII masking** | Card numbers, emails and phone numbers are replaced before the reply step, in handoff summaries and in logs. | `Regex` |
| 🤝 **Human Handoff** | Context-aware escalation to live human agents. | `Redis` `Orchestrator` |
| 📊 **Deep Analytics** | Complete traceability, conversation logs, and monitoring. | `Prometheus` `SQLite` |
| 🐳 **Docker Native** | Out-of-the-box containerization for instant deployment. | `Docker Compose` |
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ dashboard = [

[tool.pytest.ini_options]
asyncio_mode = "auto"
# Tests import the application as `src.…`; editable installs do not put the repository root on the path.
pythonpath = ["."]
testpaths = ["tests"]

[tool.ruff]
Expand Down
2 changes: 1 addition & 1 deletion server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ app.post('/api/simulate/agent-reply', async (req, res) => {

app.post('/api/simulate/reset', (_req, res) => {
analyticsStore.resetMetrics();
res.json({ status: 'success', message: 'Metrics reseeded successfully' });
res.json({ status: 'success', message: 'Metrics cleared' });
});

// --- VITE MIDDLEWARE / PRODUCTION STATIC SERVING ---
Expand Down
14 changes: 10 additions & 4 deletions server/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ export interface MetricRecord {
escalated: boolean;
}

// Random history makes the dashboard look busy, but none of it happened. It is off unless
// SEED_SAMPLE_METRICS=true, and the dashboard then says it is sample data.
const SAMPLE_METRICS = process.env.SEED_SAMPLE_METRICS === 'true';

class AnalyticsStore {
private metrics: MetricRecord[] = [];
private idCounter = 1;

constructor() {
this.seedHistoricalMetrics();
if (SAMPLE_METRICS) this.seedHistoricalMetrics();
}

private seedHistoricalMetrics() {
Expand All @@ -32,7 +36,7 @@ class AnalyticsStore {
'success_bot_chat'
];

const models = ['gpt-4o-mini', 'gpt-4o', null];
const models = ['simulated', null];
const now = Date.now();

// Generate ~60 realistic telemetry events over the past 7 days
Expand All @@ -50,7 +54,7 @@ class AnalyticsStore {
: Math.floor(Math.random() * 450) + 180;

const tokens = isFallback ? 0 : Math.floor(Math.random() * 120) + 35;
const model = isFallback ? null : (tokens > 100 ? 'gpt-4o' : 'gpt-4o-mini');
const model = isFallback ? null : 'simulated';

this.metrics.push({
id: this.idCounter++,
Expand Down Expand Up @@ -101,6 +105,7 @@ class AnalyticsStore {
escalation_rate: 0,
fallbacks: 0,
fallback_rate: 0,
sample_data: SAMPLE_METRICS,
};
}

Expand All @@ -119,13 +124,14 @@ class AnalyticsStore {
escalation_rate,
fallbacks,
fallback_rate,
sample_data: SAMPLE_METRICS,
};
}

public resetMetrics() {
this.metrics = [];
this.idCounter = 1;
this.seedHistoricalMetrics();
if (SAMPLE_METRICS) this.seedHistoricalMetrics();
}
}

Expand Down
7 changes: 5 additions & 2 deletions server/handoff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logger } from './logger';
import { maskPII } from './pii';
import { SessionRecord } from './session';

export interface ZendeskTicketRecord {
Expand All @@ -22,7 +23,8 @@ class MockZendeskHandoffClient {
private ticketCounter = 101;

constructor() {
this.seedDefaultTickets();
// Example sessions/tickets only with SEED_SAMPLE_METRICS=true; otherwise the desk starts empty.
if (process.env.SEED_SAMPLE_METRICS === 'true') this.seedDefaultTickets();
}

private seedDefaultTickets() {
Expand Down Expand Up @@ -97,7 +99,8 @@ class MockZendeskHandoffClient {
}

private generateSummary(transcript: Array<{ role: string; content: string }>, reason: string): string {
const lastMsg = transcript.slice(-3).map(m => m.content).join('; ');
// The summary is described as PII-masked; it used to quote the raw messages, card numbers included.
const lastMsg = transcript.slice(-3).map(m => maskPII(m.content)).join('; ');
return `Customer escalated due to: ${reason}. Recent context: "${lastMsg.slice(0, 150)}..."`;
}
}
Expand Down
35 changes: 20 additions & 15 deletions server/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class Orchestrator {
const startTime = Date.now();
let usedFallback = false;
let escalated = false;
let modelUsed: string | null = "gpt-4o-mini";
let modelUsed: string | null = "simulated";
let tokensUsed = 0;
let intent = "success_bot_chat";

Expand Down Expand Up @@ -149,7 +149,9 @@ export class Orchestrator {
let toolCalls: Array<{ name: string; arguments?: any }> | null = null;

try {
const llmResult = await this.callLLM(rawText, mediaBytesBase64, mimeType);
// The model step gets the masked text (it used to receive the raw message; masking only
// covered the logs). Triggers and the human agent still see the original.
const llmResult = await this.callLLM(maskedText, mediaBytesBase64, mimeType);
responseText = llmResult.text;
toolCalls = llmResult.toolCalls;
modelUsed = llmResult.model;
Expand Down Expand Up @@ -329,28 +331,26 @@ export class Orchestrator {
mimeType?: string
): Promise<{ text: string; toolCalls: Array<{ name: string; arguments?: any }> | null; model: string; tokens: number }> {
const textLower = text.toLowerCase();
let model = "gpt-4o-mini";
let tokens = 45;
// No model is called: replies come from the rules below. Reported as "simulated", 0 tokens.
const model = "simulated";
const tokens = 0;

if (textLower.includes("force error")) {
throw new Error("Simulated internal service error (LLM/RAG failure)");
}

if (mediaBytesBase64 && mimeType?.startsWith("image/")) {
model = "gpt-4o";
tokens = 110;
return {
text: "I have analyzed your uploaded image using GPT-4o Vision. The receipt and order details look valid.",
text: "Image received. In this demo the model step is simulated, so the image is not analysed.",
toolCalls: null,
model,
tokens
};
}

if (mediaBytesBase64 && mimeType === "application/pdf") {
tokens = 85;
return {
text: `I have parsed your uploaded PDF document. Extracted content has been cross-referenced with your account.`,
text: `PDF received. In this demo the model step is simulated, so the document is not read.`,
toolCalls: null,
model,
tokens
Expand All @@ -362,16 +362,21 @@ export class Orchestrator {
text: "I don't know the exact answer to this specific inquiry.",
toolCalls: null,
model,
tokens: 30
tokens
};
}

if (textLower.includes("force refund trigger") || (textLower.includes("refund") && (textLower.includes("500") || textLower.includes("600") || textLower.includes("1000")))) {
// A refund request becomes an initiate_refund tool call for the amount in the message; the
// policy trigger then decides (over 500 escalates). It used to look for the strings "500",
// "600" or "1000", so the "$650.00" preset never reached the policy.
const amountMatch = text.match(/\$?\s?(\d+(?:[.,]\d{1,2})?)/);
const amount = amountMatch ? parseFloat(amountMatch[1].replace(",", ".")) : NaN;
if (textLower.includes("force refund trigger") || (textLower.includes("refund") && !isNaN(amount))) {
return {
text: "Initiating refund review with supervisor authorization.",
toolCalls: [{ name: "initiate_refund", arguments: { amount: 650.0 } }],
toolCalls: [{ name: "initiate_refund", arguments: { amount: textLower.includes("force refund trigger") ? 650.0 : amount } }],
model,
tokens: 60
tokens
};
}

Expand All @@ -380,7 +385,7 @@ export class Orchestrator {
text: "Your order #89421 is currently in transit with express carrier. Estimated delivery is tomorrow by 4:00 PM.",
toolCalls: null,
model,
tokens: 52
tokens
};
}

Expand All @@ -389,7 +394,7 @@ export class Orchestrator {
text: "Our customer support team is available Monday through Friday from 9 AM to 5 PM EST. How can I assist you further?",
toolCalls: null,
model,
tokens: 44
tokens
};
}

Expand Down
3 changes: 2 additions & 1 deletion server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ class SessionStore {
private processedMessages: Map<string, number> = new Map();

constructor() {
this.seedDefaultSessions();
// Example sessions/tickets only with SEED_SAMPLE_METRICS=true; otherwise the desk starts empty.
if (process.env.SEED_SAMPLE_METRICS === 'true') this.seedDefaultSessions();
}

private seedDefaultSessions() {
Expand Down
11 changes: 8 additions & 3 deletions src/components/KpiCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ interface KpiCardsProps {

export const KpiCards: React.FC<KpiCardsProps> = ({ summary }) => {
return (
<>
{summary.sample_data && (
<p className="mb-3 inline-block rounded bg-amber-400/15 border border-amber-400/30 px-2 py-1 text-xs font-semibold text-amber-300">
Sample data: generated at start-up (SEED_SAMPLE_METRICS=true), not real traffic.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">

{/* KPI 1: Total Interactions */}
Expand All @@ -22,7 +28,6 @@ export const KpiCards: React.FC<KpiCardsProps> = ({ summary }) => {
<span className="text-3xl font-extrabold text-white tracking-tight">
{summary.total_messages.toLocaleString()}
</span>
<span className="text-xs text-emerald-400 font-medium">100% routed</span>
</div>
<p className="text-xs text-zinc-500 mt-1">Processed across WhatsApp webhooks</p>
</div>
Expand All @@ -40,7 +45,6 @@ export const KpiCards: React.FC<KpiCardsProps> = ({ summary }) => {
{summary.avg_latency.toLocaleString()}
<span className="text-lg font-normal text-zinc-400 ml-1">ms</span>
</span>
<span className="text-xs text-sky-400 font-medium">Sub-second</span>
</div>
<p className="text-xs text-zinc-500 mt-1">Triage, masking & LLM response time</p>
</div>
Expand All @@ -57,7 +61,7 @@ export const KpiCards: React.FC<KpiCardsProps> = ({ summary }) => {
<span className="text-3xl font-extrabold text-white tracking-tight">
{summary.total_tokens.toLocaleString()}
</span>
<span className="text-xs text-purple-400 font-medium">GPT-4o / Mini</span>
<span className="text-xs text-purple-400 font-medium">simulated model</span>
</div>
<p className="text-xs text-zinc-500 mt-1">Prompt & completion inference volume</p>
</div>
Expand All @@ -82,5 +86,6 @@ export const KpiCards: React.FC<KpiCardsProps> = ({ summary }) => {
</div>

</div>
</>
);
};
2 changes: 1 addition & 1 deletion src/components/RoutingLedger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const RoutingLedger: React.FC<RoutingLedgerProps> = ({ metrics, onSelectS
{filteredMetrics.length} Records
</span>
</h3>
<p className="text-xs text-zinc-400">Complete immutable record of all incoming WhatsApp interactions and routing decisions</p>
<p className="text-xs text-zinc-400">Every incoming message and its routing decision (kept in memory; cleared on restart)</p>
</div>

<div className="flex flex-wrap items-center gap-3">
Expand Down
7 changes: 6 additions & 1 deletion src/handoff/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import BaseModel

from src.bot.session import SessionData
from src.utils import pii_masker
from src.utils.logger import get_logger

logger = get_logger(__name__)
Expand All @@ -31,7 +32,11 @@ async def generate_summary(transcript: list[dict[str, Any]]) -> str:
A 2-sentence string summary of the conversation.
"""
last_10 = transcript[-10:]
formatted_chat = "\n".join([f"{msg.get('role', 'unknown')}: {msg.get('content', '')}" for msg in last_10])
# Masked before it leaves the service: this text is sent to OpenAI when a key is configured,
# and it used to include card numbers, emails and phone numbers as typed.
formatted_chat = "\n".join(
[f"{msg.get('role', 'unknown')}: {pii_masker.mask(str(msg.get('content', '')))}" for msg in last_10]
)

api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
Expand Down
Loading
Loading