| <!doctype html> |
| <html lang="en" data-theme="light"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Message Router — an inbox that reasons</title> |
| <meta name="description" content="An interactive WhatsApp replica showing a real AI notification-routing pipeline: 93.3% accuracy, 32 API calls for 110 messages, $0 cost. Ranked 18/1983 at HackerRank Orchestrate." /> |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧭</text></svg>" /> |
| <link rel="stylesheet" href="styles.css" /> |
| </head> |
| <body> |
|
|
| <div class="blobs" aria-hidden="true"> |
| <div class="blob b1"></div><div class="blob b2"></div><div class="blob b3"></div><div class="blob b4"></div> |
| </div> |
|
|
| <header class="topbar"> |
| <div class="brand"><span class="mark">🧭</span><span>Message Router</span></div> |
| <button id="themeToggle" class="theme-toggle" aria-label="Toggle dark mode"></button> |
| </header> |
|
|
| <section class="hero"> |
| <div class="hero-copy reveal"> |
| <p class="eyebrow">HackerRank Orchestrate · Ranked <span id="statRank">18 / 1,983</span></p> |
| <h1>Every WhatsApp message,<br>routed like <em>a person</em> would.</h1> |
| <p class="lede">An AI pipeline that decides what interrupts you now, what waits for later, and what gets muted — reasoning over text, image posters and voice notes, with hard safety rules a language model can't be talked out of. This isn't a mockup: every verdict below is a real, precomputed output of the actual routing run. Tap in.</p> |
| <div class="stat-row"> |
| <div class="stat-chip" style="--rot:-4deg"><b id="statAccuracy">93.3%</b><span>action accuracy</span></div> |
| <div class="stat-chip" style="--rot:3deg"><b id="statCalls">32 / 110</b><span>API calls for messages</span></div> |
| <div class="stat-chip" style="--rot:-2deg"><b id="statCost">$0</b><span>cost, free tier only</span></div> |
| <div class="stat-chip" style="--rot:5deg"><b id="statCaught">15</b><span>false claims self-caught</span></div> |
| </div> |
| </div> |
| <div class="hero-phone-wrap reveal"> |
| <div class="callout c1">Tap any dashed message → see the full 5-phase trace<span class="arrow">↳ real signals, not vibes</span></div> |
| <div class="callout c2">Switch inboxes — same sender, different verdict, on purpose<span class="arrow">↳ personalization is the whole point</span></div> |
| <div class="phone-frame-outer"> |
| <div class="stage-controls"> |
| <label for="userSwitch">Viewing inbox as</label> |
| <select id="userSwitch" aria-label="Switch device owner"></select> |
| </div> |
|
|
| <div class="phone" id="phone"> |
| <div class="notch"></div> |
| <div class="screen"> |
| <div class="statusbar"> |
| <span id="clock">9:41</span> |
| <span class="icons">📶 🔋</span> |
| </div> |
|
|
| <div id="viewChatList" class="view chat-list-view"> |
| <div class="wa-header list-header"> |
| <span class="wa-title">Chats</span> |
| <div class="header-icons"> |
| <button id="searchIconBtn" aria-label="Search"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> |
| </button> |
| <button id="menuBtn" aria-label="Menu"> |
| <svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg> |
| </button> |
| <div class="dropdown-menu" id="menuDropdown"> |
| <button data-action="theme">🌓 <span>Toggle theme</span></button> |
| <button data-action="shuffle">🔀 <span>Shuffle inbox</span></button> |
| <button data-action="router">🧭 <span>Open Router HQ</span></button> |
| <div class="dm-divider"></div> |
| <button data-action="diagram">📐 <span>View architecture</span></button> |
| </div> |
| </div> |
| </div> |
| <div class="filter-row" id="filterRow"> |
| <button class="filter-pill active" data-filter="all">All</button><button class="filter-pill" data-filter="unread">Unread</button><button class="filter-pill" data-filter="group">Groups</button> |
| </div> |
| <div class="search-bar" id="searchBar"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> |
| <input id="searchInput" type="text" placeholder="Search chats" autocomplete="off" /> |
| <button class="search-clear" id="searchClear" aria-label="Clear search">✕</button> |
| </div> |
| <div class="chat-list" id="chatList"></div> |
| <div class="nav-stub hidden" id="navStub"></div> |
| <div class="bottom-nav"> |
| <button class="nav-item active" data-tab="chats"><span class="nav-ico">💬</span>Chats</button> |
| <button class="nav-item" data-tab="updates"><span class="nav-ico">🔄</span>Updates</button> |
| <button class="nav-item" data-tab="communities"><span class="nav-ico">👥</span>Communities</button> |
| <button class="nav-item" data-tab="calls"><span class="nav-ico">📞</span>Calls</button> |
| </div> |
| </div> |
|
|
| <div id="viewThread" class="view thread-view hidden"> |
| <div class="wa-header thread-header"> |
| <button class="back-btn" id="backBtn" aria-label="Back">‹</button> |
| <button class="avatar" id="threadAvatar" aria-label="Contact info" style="border:none;cursor:pointer;padding:0"></button> |
| <button class="thread-title" id="threadTitleBtn" aria-label="Contact info" style="background:none;border:none;text-align:left;cursor:pointer;padding:0;color:inherit"> |
| <span class="t-name" id="threadName"></span> |
| <span class="t-sub" id="threadSubtitle"></span> |
| </button> |
| <div class="header-icons"> |
| <button id="videoBtn" aria-label="Video call"><svg viewBox="0 0 24 24"><path d="M17 10.5V7a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1v-3.5l4 4v-11l-4 4z"/></svg></button> |
| <button id="callBtn" aria-label="Voice call"><svg viewBox="0 0 24 24"><path d="M6.6 10.8c1.4 2.7 3.6 4.9 6.3 6.3l2.1-2.1c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.5.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.4 21 3 13.6 3 4.5c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.2.2 2.4.6 3.5.1.4 0 .8-.2 1L6.6 10.8z"/></svg></button> |
| </div> |
| <div class="info-pop" id="infoPop"></div> |
| </div> |
| <div class="thread-messages" id="threadMessages"></div> |
| <div class="composer" id="composer"></div> |
| </div> |
| <div class="toast-stack" id="toastStack"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| <section class="pipeline-overview"> |
| <h2>How a message actually gets routed</h2> |
| <p class="sub">Five phases, most of them deterministic. The model only speaks once it's the last honest option.</p> |
| <figure class="diagram-card" id="archCard"> |
| <svg viewBox="0 0 1100 170" role="img" aria-label="Pipeline flow: dataset feeds a per-user bundle, then media OCR/transcription, then rules. Rules run deterministically; an override branches above the rules-to-router step and can force the verdict, while a separate short path shows the router still receives the override's evidence. The router calls the LLM once per user, then normalize validates and semantically checks the output before it becomes a CSV row." font-family="ui-monospace, Consolas, monospace" font-size="11.5"> |
| <defs> |
| <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> |
| <path d="M0 0L10 5L0 10z" fill="currentColor" /> |
| </marker> |
| </defs> |
|
|
| <g class="arch-node" id="an-source" fill="currentColor"> |
| <text x="55" y="108" text-anchor="middle" font-size="18">📄</text> |
| <text x="55" y="130" text-anchor="middle" font-size="9.5" opacity="0.7">dataset/*.csv</text> |
| </g> |
| <line class="arch-edge" id="ae-source-bundle" x1="95" y1="109" x2="130" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" /> |
|
|
| <g class="arch-node" id="an-bundle" fill="currentColor"> |
| <rect x="130" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/> |
| <text x="205" y="106" text-anchor="middle" font-weight="700">1 · bundle.py</text> |
| <text x="205" y="120" text-anchor="middle" opacity="0.6">one block per user</text> |
| </g> |
| <line class="arch-edge" id="ae-bundle-media" x1="280" y1="109" x2="314" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" /> |
|
|
| <g class="arch-node" id="an-media" fill="currentColor"> |
| <rect x="314" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/> |
| <text x="389" y="106" text-anchor="middle" font-weight="700">2 · media.py</text> |
| <text x="389" y="120" text-anchor="middle" opacity="0.6">OCR + transcript, cached</text> |
| </g> |
| <line class="arch-edge" id="ae-media-rules" x1="464" y1="109" x2="498" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" /> |
|
|
| <g class="arch-node" id="an-rules" fill="currentColor"> |
| <rect x="498" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/> |
| <text x="573" y="106" text-anchor="middle" font-weight="700">3 · rules.py</text> |
| <text x="573" y="120" text-anchor="middle" opacity="0.6">4 hard overrides</text> |
| </g> |
|
|
| |
| <line class="arch-edge" id="ae-override-stub" x1="665" y1="50" x2="665" y2="85" stroke="currentColor" stroke-width="1.6" stroke-dasharray="3 3" marker-end="url(#arrow)" fill="none" /> |
| <g class="arch-node hot" id="an-override" fill="currentColor"> |
| <rect x="580" y="8" width="170" height="42" rx="10" fill="none" stroke="var(--danger, currentColor)" stroke-width="2"/> |
| <text x="665" y="26" text-anchor="middle" font-weight="700">override fires?</text> |
| <text x="665" y="40" text-anchor="middle" opacity="0.6">verdict skips the LLM</text> |
| </g> |
|
|
| <line class="arch-edge" id="ae-rules-router" x1="648" y1="109" x2="682" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" /> |
|
|
| <g class="arch-node" id="an-router" fill="currentColor"> |
| <rect x="682" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/> |
| <text x="757" y="106" text-anchor="middle" font-weight="700">4 · router.py</text> |
| <text x="757" y="120" text-anchor="middle" opacity="0.6">1 call / user, T=0</text> |
| </g> |
| <line class="arch-edge" id="ae-router-normalize" x1="832" y1="109" x2="866" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" /> |
|
|
| <g class="arch-node" id="an-normalize" fill="currentColor"> |
| <rect x="866" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/> |
| <text x="941" y="106" text-anchor="middle" font-weight="700">5 · normalize.py</text> |
| <text x="941" y="120" text-anchor="middle" opacity="0.6">semantic check</text> |
| </g> |
| <line class="arch-edge" id="ae-normalize-output" x1="1016" y1="109" x2="1050" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" /> |
|
|
| <g class="arch-node" id="an-output" fill="currentColor"> |
| <text x="1063" y="108" text-anchor="middle" font-size="18">📤</text> |
| <text x="1063" y="130" text-anchor="middle" font-size="9.5" opacity="0.7">output.csv</text> |
| </g> |
|
|
| <path class="arch-edge" id="ae-evidence" d="M720 50 Q745 68 757 87" stroke="currentColor" stroke-width="1.6" stroke-dasharray="4 4" marker-end="url(#arrow)" fill="none" /> |
| <g class="arch-node" id="an-evidence-note" fill="currentColor"> |
| <text x="855" y="20" text-anchor="middle" font-size="10" opacity="0.75">override's own evidence</text> |
| <text x="855" y="33" text-anchor="middle" font-size="10" opacity="0.75">still reaches router.py</text> |
| </g> |
| </svg> |
| <figcaption>Rules can force the verdict on 4 hard-override conditions (OTP/credential asks, lookalike-domain payments, router-injection attempts, chain forwards) — but the router's evidence selection survives the override, since evidence is scored output, not a safety decision.</figcaption> |
| <button class="diagram-replay" id="diagramReplay">🔁 Replay</button> |
| </figure> |
| </section> |
|
|
| <footer class="site-footer"> |
| <p>Built for HackerRank Orchestrate, August 2026 · ranked 18 / 1,983 · every number on this page comes from the committed run, nothing is fabricated for the demo.</p> |
| </footer> |
|
|
| <div id="sheetOverlay" class="sheet-overlay hidden"></div> |
| <div id="pipelineSheet" class="pipeline-sheet hidden" role="dialog" aria-modal="true" aria-label="Routing trace"> |
| <div class="sheet-grab"></div> |
| <div class="sheet-head"> |
| <div class="sh-top"> |
| <div> |
| <h3 id="sheetTitle">How this got routed</h3> |
| <div class="sh-msg" id="sheetMsgPreview"></div> |
| </div> |
| <button class="sheet-close" id="sheetClose" aria-label="Close">✕</button> |
| </div> |
| <div class="action-badge" id="actionBadge"></div> |
| <div class="conf-gauge" id="confGauge"></div> |
| </div> |
| <div class="sheet-body"> |
| <div class="flow"> |
| <div class="flow-rail" id="flowRail"></div> |
| <div class="flow-steps" id="flowSteps"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <script src="app.js"></script> |
| <script> |
| var _clk = document.getElementById("clock"); |
| function _tick(){ if(_clk){ _clk.textContent = new Date().toLocaleTimeString([], {hour:"2-digit", minute:"2-digit", hour12:false}); } } |
| _tick(); setInterval(_tick, 30000); |
| </script> |
| </body> |
| </html> |
|
|