diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39ddfbc..45f3213 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 }} diff --git a/.gitignore b/.gitignore index 3df3b8d..b074d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ wheels/ # Database analytics.db .venv/ + +node_modules/ diff --git a/Dockerfile b/Dockerfile index 7b0f753..d0e72cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index ef4bdde..eb772ea 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,33 @@ ## 🌟 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. --- @@ -27,9 +51,9 @@ It is built with a strictly asynchronous architecture to handle high throughput | 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` | diff --git a/pyproject.toml b/pyproject.toml index a51beab..dbd456e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/server.ts b/server.ts index 94ca74c..779b001 100644 --- a/server.ts +++ b/server.ts @@ -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 --- diff --git a/server/analytics.ts b/server/analytics.ts index f16b92d..5589557 100644 --- a/server/analytics.ts +++ b/server/analytics.ts @@ -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() { @@ -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 @@ -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++, @@ -101,6 +105,7 @@ class AnalyticsStore { escalation_rate: 0, fallbacks: 0, fallback_rate: 0, + sample_data: SAMPLE_METRICS, }; } @@ -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(); } } diff --git a/server/handoff.ts b/server/handoff.ts index 14819ac..ddd7d27 100644 --- a/server/handoff.ts +++ b/server/handoff.ts @@ -1,4 +1,5 @@ import { logger } from './logger'; +import { maskPII } from './pii'; import { SessionRecord } from './session'; export interface ZendeskTicketRecord { @@ -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() { @@ -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)}..."`; } } diff --git a/server/orchestrator.ts b/server/orchestrator.ts index 898ec98..62aebe3 100644 --- a/server/orchestrator.ts +++ b/server/orchestrator.ts @@ -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"; @@ -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; @@ -329,18 +331,17 @@ 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 @@ -348,9 +349,8 @@ export class Orchestrator { } 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 @@ -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 }; } @@ -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 }; } @@ -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 }; } diff --git a/server/session.ts b/server/session.ts index f75de0c..24bdc38 100644 --- a/server/session.ts +++ b/server/session.ts @@ -23,7 +23,8 @@ class SessionStore { private processedMessages: Map = 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() { diff --git a/src/components/KpiCards.tsx b/src/components/KpiCards.tsx index 9c23d8f..f1b1dd2 100644 --- a/src/components/KpiCards.tsx +++ b/src/components/KpiCards.tsx @@ -8,6 +8,12 @@ interface KpiCardsProps { export const KpiCards: React.FC = ({ summary }) => { return ( + <> + {summary.sample_data && ( +

+ Sample data: generated at start-up (SEED_SAMPLE_METRICS=true), not real traffic. +

+ )}
{/* KPI 1: Total Interactions */} @@ -22,7 +28,6 @@ export const KpiCards: React.FC = ({ summary }) => { {summary.total_messages.toLocaleString()} - 100% routed

Processed across WhatsApp webhooks

@@ -40,7 +45,6 @@ export const KpiCards: React.FC = ({ summary }) => { {summary.avg_latency.toLocaleString()} ms - Sub-second

Triage, masking & LLM response time

@@ -57,7 +61,7 @@ export const KpiCards: React.FC = ({ summary }) => { {summary.total_tokens.toLocaleString()} - GPT-4o / Mini + simulated model

Prompt & completion inference volume

@@ -82,5 +86,6 @@ export const KpiCards: React.FC = ({ summary }) => { + ); }; diff --git a/src/components/RoutingLedger.tsx b/src/components/RoutingLedger.tsx index cd3b6a2..5302f1d 100644 --- a/src/components/RoutingLedger.tsx +++ b/src/components/RoutingLedger.tsx @@ -38,7 +38,7 @@ export const RoutingLedger: React.FC = ({ metrics, onSelectS {filteredMetrics.length} Records -

Complete immutable record of all incoming WhatsApp interactions and routing decisions

+

Every incoming message and its routing decision (kept in memory; cleared on restart)

diff --git a/src/handoff/payload.py b/src/handoff/payload.py index c2d822e..7bc90c0 100644 --- a/src/handoff/payload.py +++ b/src/handoff/payload.py @@ -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__) @@ -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: diff --git a/src/orchestrator/engine.py b/src/orchestrator/engine.py index 7e1c97e..fd329ce 100644 --- a/src/orchestrator/engine.py +++ b/src/orchestrator/engine.py @@ -14,6 +14,7 @@ from src.intelligence.tracing import trace_llm_call from src.orchestrator.fallback import FallbackEngine from src.orchestrator.triggers import EscalationTriggerEngine +from src.utils import pii_masker from src.utils.logger import get_logger logger = get_logger(__name__) @@ -280,9 +281,12 @@ async def handle_message( # 3. CALL LLM (Wrapped with Tracing and Graceful Degradation) try: - async with trace_llm_call(session_id, text) as span: + # The model step gets the masked text: card numbers, emails and phone numbers are + # replaced before anything leaves the service (they used to be masked in logs only). + masked = pii_masker.mask(text) + async with trace_llm_call(session_id, masked) as span: response_text, tool_calls, model_used, tokens_used = await self.call_llm( - text, + masked, session, media_bytes=media_bytes, mime_type=mime_type, diff --git a/src/types.ts b/src/types.ts index 2f6548f..5474752 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,8 @@ export interface KpiSummary { escalation_rate: number; fallbacks: number; fallback_rate: number; + /** True when the history was generated at start-up (SEED_SAMPLE_METRICS=true). */ + sample_data?: boolean; } export interface SessionData { diff --git a/tests/test_model_input_masked.py b/tests/test_model_input_masked.py new file mode 100644 index 0000000..57cf11e --- /dev/null +++ b/tests/test_model_input_masked.py @@ -0,0 +1,27 @@ +import pytest + +from src.handoff.client import MockZendeskClient +from src.orchestrator.engine import Orchestrator + + +@pytest.mark.asyncio +async def test_model_step_receives_masked_text(monkeypatch: pytest.MonkeyPatch) -> None: + """Card numbers, emails and phone numbers never reach the model step (they used to be masked in logs only).""" + seen: list[str] = [] + orchestrator = Orchestrator(handoff_client=MockZendeskClient()) + real_call = orchestrator.call_llm + + async def spy(text: str, *args, **kwargs): # type: ignore[no-untyped-def] + seen.append(text) + return await real_call(text, *args, **kwargs) + + monkeypatch.setattr(orchestrator, "call_llm", spy) + await orchestrator.handle_message( + "test-mask-session", + "My email is john.doe@acme.com and my card is 4532-1188-9922-3344.", + ) + + assert seen, "the model step was not reached" + assert "4532-1188-9922-3344" not in seen[0] + assert "john.doe@acme.com" not in seen[0] + assert "[CARD]" in seen[0] and "[EMAIL]" in seen[0] diff --git a/tests/test_summary_masked.py b/tests/test_summary_masked.py new file mode 100644 index 0000000..1632ead --- /dev/null +++ b/tests/test_summary_masked.py @@ -0,0 +1,24 @@ +import json + +import httpx +import pytest +import respx + +from src.handoff.payload import generate_summary + + +@pytest.mark.asyncio +@respx.mock +async def test_summary_request_to_openai_is_masked(monkeypatch: pytest.MonkeyPatch) -> None: + """The handoff summary is the one real model call; it must not carry card numbers or emails.""" + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + route = respx.post("https://api.openai.com/v1/chat/completions").mock( + return_value=httpx.Response(200, json={"choices": [{"message": {"content": "Summary."}}]}) + ) + await generate_summary( + [{"role": "user", "content": "My email is john.doe@acme.com and my card is 4532-1188-9922-3344."}] + ) + sent = json.loads(route.calls.last.request.content)["messages"][1]["content"] + assert "4532-1188-9922-3344" not in sent + assert "john.doe@acme.com" not in sent + assert "[CARD]" in sent and "[EMAIL]" in sent