Agent Template

WhatsApp Sales Copilot

Turn a raw WhatsApp chat export into a mini CRM — quotes, bookings, payments and boarding passes typed automatically, a sales-pipeline stage, the next action to take, and grounded Q&A — entirely in your browser, so client data never leaves.

whatsapp crm sales travel rag privacy
ozzo Jul 12, 2026 1 use

Preview Mode

This is a preview with sample data. The template uses placeholders like which will be replaced with actual agent data.

About This Template

WhatsApp Sales Copilot is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.

Topics whatsapp crm sales travel rag privacy
Template Preview

Template Metadata

Slug
whatsapp-sales-copilot
Created By
ozzo
Created
Jul 12, 2026
Usage Count
1

Tags

whatsapp crm sales travel rag privacy

Code Statistics

HTML Lines
47
CSS Lines
84
JS Lines
228
Python Lines
329

Source Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ agent_name }}</title>
  <style>{{ css_code }}</style>
</head>
<body>
  <main class="wa">
    <header class="wa__head">
      <h1>💼 {{ agent_name }}</h1>
      <p class="wa__sub">{{ description }}</p>
    </header>

    <section class="wa__upload">
      <label class="wa__drop" for="export-file" id="wa-drop">
        <input id="export-file" type="file" accept=".zip,.txt" multiple hidden>
        <span>📦 Drop a WhatsApp chat export — the .zip with media, or just the chat .txt</span>
      </label>
      <div class="wa__demo-row">
        <button id="wa-demo" type="button" class="wa__demo">🎭 No export handy? Load the demo conversation</button>
      </div>
      <div id="wa-status" class="wa__status" role="status"></div>
    </section>

    <section id="wa-kpis" class="wa__kpis" aria-label="Client overview"></section>
    <section id="wa-pipeline" class="wa__pipeline" aria-label="Sales pipeline"></section>
    <section id="wa-artifacts" class="wa__artifacts" aria-label="Artifacts"></section>

    <div class="wa__actions">
      <button id="wa-suggest" type="button" class="wa__suggest" disabled>
        💡 Suggest next action
      </button>
    </div>

    <section id="results-container" class="wa__chat" aria-live="polite"></section>

    <form id="wa-form" class="wa__form" autocomplete="off">
      <input id="wa-input" type="text"
             placeholder="Ask about this client — who paid, what's booked, what's pending…" required disabled>
      <button id="wa-send" type="submit" disabled>Ask</button>
    </form>
  </main>
  <script>{{ js_code }}</script>
</body>
</html>
:root {
  color-scheme: light dark;
  --wa-primary: #075e54;   /* WhatsApp deep teal */
  --wa-accent: #128c7e;
  --wa-cta: #f59e0b;       /* travel-desk amber */
  --wa-bg: #f2f0eb;        /* chat-wallpaper tone */
  --wa-surface: #ffffff;
  --wa-text: #1f2a28;
  --wa-muted: #64716e;
  --wa-border: #dfe3de;
  --wa-radius: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
       background: var(--wa-bg); color: var(--wa-text); line-height: 1.55; }
.hidden { display: none !important; }
.wa { max-width: 820px; margin: 0 auto; padding: 24px 16px 96px; }
.wa__head h1 { font-size: 1.55rem; margin: 0 0 4px; color: var(--wa-primary); }
.wa__sub { margin: 0 0 18px; color: var(--wa-muted); }
.wa__drop { display: block; border: 2px dashed #b7c6c0; border-radius: var(--wa-radius);
            padding: 22px; text-align: center; cursor: pointer;
            background: var(--wa-surface); color: #3f524d;
            transition: border-color .15s, background .15s; }
.wa__drop:hover, .wa__drop--over { border-color: var(--wa-accent); background: #eaf5f2; }
.wa__demo-row { margin: 8px 0 0; text-align: center; }
.wa__demo { border: 0; background: none; color: var(--wa-accent); font-size: .85rem;
            cursor: pointer; text-decoration: underline; padding: 4px; }
.wa__status { font-size: .85rem; color: var(--wa-muted); margin: 10px 2px;
              min-height: 1.2em; }
.wa__kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
            gap: 8px; margin: 12px 0 0; }
.wa__kpi { background: var(--wa-surface); border: 1px solid var(--wa-border);
           border-radius: 10px; padding: 10px 12px; }
.wa__kpi-value { font-weight: 700; font-size: .95rem; overflow-wrap: anywhere; }
.wa__kpi-label { font-size: .75rem; color: var(--wa-muted); }
.wa__pipeline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
                margin: 12px 0 0; font-size: .8rem; color: var(--wa-muted); }
.wa__stage { border: 1px solid var(--wa-border); background: var(--wa-surface);
             border-radius: 999px; padding: 5px 12px; }
.wa__stage--active { background: var(--wa-primary); border-color: var(--wa-primary);
                     color: #fff; font-weight: 700; }
.wa__artifacts { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
                 gap: 8px; margin: 12px 0 0; }
.wa__card { background: var(--wa-surface); border: 1px solid var(--wa-border);
            border-left: 4px solid var(--wa-accent); border-radius: 10px;
            padding: 9px 12px; }
.wa__card-name { font-weight: 700; font-size: .8rem; margin: 0 0 3px;
                 overflow-wrap: anywhere; }
.wa__card-meta { font-size: .75rem; color: var(--wa-muted); margin: 0;
                 overflow-wrap: anywhere; }
.wa__actions { margin: 12px 0 0; }
.wa__suggest { padding: 11px 18px; border: 0; border-radius: 10px;
               background: var(--wa-cta); color: #fff; font-weight: 700;
               cursor: pointer; }
.wa__suggest:disabled { background: #c6c2b8; cursor: not-allowed; }
.wa__chat { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 16px; }
.wa__msg, .message { padding: 11px 14px; border-radius: var(--wa-radius);
                     max-width: 88%; white-space: pre-wrap; line-height: 1.55; }
.wa__msg--user { align-self: flex-end; background: #d7f8c8; color: #143d2b;
                 border: 1px solid #bfe8ae; }
.wa__msg--assistant, .message-assistant { align-self: flex-start;
    background: var(--wa-surface); border: 1px solid var(--wa-border); }
.message { display: flex; gap: 8px; }
.message-icon { flex: 0 0 auto; }
.message-content { min-width: 0; }
.wa__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
            padding: 12px 16px; background: var(--wa-surface);
            border-top: 1px solid var(--wa-border); }
.wa__form input { flex: 1; padding: 12px 14px; border: 1px solid #c3cdc8;
                  border-radius: 10px; font-size: 1rem;
                  background: var(--wa-bg); color: var(--wa-text); }
.wa__form input:focus-visible { outline: 2px solid var(--wa-accent); outline-offset: 1px; }
.wa__form button { padding: 0 22px; border: 0; border-radius: 10px;
                   background: var(--wa-primary); color: #fff; font-weight: 700;
                   cursor: pointer; }
.wa__form button:disabled { background: #c6c2b8; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
  :root { --wa-bg: #101614; --wa-surface: #1a2320; --wa-text: #e2ebe8;
          --wa-muted: #93a39e; --wa-border: #2b3833; --wa-primary: #7fd0c4; }
  .wa__drop { color: #a8bcb5; border-color: #3a4b45; }
  .wa__drop:hover, .wa__drop--over { background: #1d2b27; }
  .wa__msg--user { background: #1f3d2a; color: #d7f8c8; border-color: #2c5138; }
  .wa__stage--active { color: #101614; }
}
// WhatsApp Sales Copilot UI wiring. The RAG/embeddings/STT/vision/wllama
// infrastructure is injected by the generator; this connects the export
// pipeline, dashboard rendering, and chat controls.
(function () {
  const statusEl = () => document.getElementById('wa-status');
  const results = () => document.getElementById('results-container');
  const input = () => document.getElementById('wa-input');
  const sendBtn = () => document.getElementById('wa-send');
  const suggestBtn = () => document.getElementById('wa-suggest');
  const dropZone = () => document.getElementById('wa-drop');

  const KIND_EMOJI = {
    'quote': '📝', 'reservation': '📑', 'boarding pass': '🎫',
    'payment proof': '💳', 'invoice': '🧾', 'accommodation': '🏨',
    'identity document': '🪪', 'voice note': '🎙️', 'uncategorized': '📄'
  };

  let pyReady = false;
  let hasData = false;

  // Render helper the wllama query bridge also calls for streamed responses.
  window.addMessage = function (type, content) {
    const el = document.createElement('div');
    el.className = 'wa__msg wa__msg--' + (type === 'user' ? 'user' : 'assistant');
    el.textContent = content;
    results().appendChild(el);
    el.scrollIntoView({ behavior: 'smooth', block: 'end' });
    return el;
  };

  function setStatus(msg) { statusEl().textContent = msg; }
  function enableControls(on) {
    input().disabled = !on;
    sendBtn().disabled = !on;
    suggestBtn().disabled = !on;
  }

  function addKpi(container, value, label) {
    const card = document.createElement('div');
    card.className = 'wa__kpi';
    const v = document.createElement('div');
    v.className = 'wa__kpi-value';
    v.textContent = value;
    const l = document.createElement('div');
    l.className = 'wa__kpi-label';
    l.textContent = label;
    card.append(v, l);
    container.appendChild(card);
  }

  async function refreshDashboard() {
    const d = JSON.parse(await window.pyodide.runPythonAsync('_dashboard()'));
    const kpis = document.getElementById('wa-kpis');
    kpis.innerHTML = '';
    addKpi(kpis, String(d.messages), 'messages');
    addKpi(kpis, String(d.artifacts.length), 'artifacts');
    addKpi(kpis, d.payments.length ? d.payments.join(' · ') : 'none spotted', 'payments');
    addKpi(kpis, d.next_action, 'next action');

    const pipeline = document.getElementById('wa-pipeline');
    pipeline.innerHTML = '';
    d.stages.forEach((stage, i) => {
      if (i) pipeline.appendChild(document.createTextNode('→'));
      const chip = document.createElement('span');
      chip.className = 'wa__stage' + (stage === d.stage ? ' wa__stage--active' : '');
      chip.textContent = stage;
      pipeline.appendChild(chip);
    });

    const grid = document.getElementById('wa-artifacts');
    grid.innerHTML = '';
    for (const a of d.artifacts) {
      const card = document.createElement('div');
      card.className = 'wa__card';
      const name = document.createElement('p');
      name.className = 'wa__card-name';
      name.textContent = (KIND_EMOJI[a.kind] || '📄') + ' ' + a.name;
      const meta = document.createElement('p');
      meta.className = 'wa__card-meta';
      meta.textContent = a.kind + (a.amount ? ' · ' + a.amount : '') +
        (a.kind === 'identity document' ? ' · 🔒 not indexed' : '');
      card.append(name, meta);
      grid.appendChild(card);
    }
    if (d.skipped.length) {
      setStatus('Skipped ' + d.skipped.length + ' file(s) the pipeline could not use.');
    }
    hasData = d.messages > 0 || d.artifacts.length > 0;
  }

  function b64ToBytes(b64) {
    return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  }

  // Decode any browser-supported audio to mono 16 kHz Float32Array samples.
  async function decodeToSamples(bytes) {
    const ctx = new AudioContext({ sampleRate: 16000 });
    try {
      const buf = await ctx.decodeAudioData(bytes.buffer);
      if (buf.numberOfChannels === 1) return new Float32Array(buf.getChannelData(0));
      const mono = new Float32Array(buf.length);
      for (let c = 0; c < buf.numberOfChannels; c++) {
        const ch = buf.getChannelData(c);
        for (let i = 0; i < buf.length; i++) mono[i] += ch[i] / buf.numberOfChannels;
      }
      return mono;
    } finally {
      ctx.close();
    }
  }

  async function drainAudio(names) {
    for (const name of names) {
      try {
        setStatus('Transcribing voice note "' + name + '"… (first run downloads the speech model)');
        window.pyodide.globals.set('__wa_audio_name', name);
        const b64 = await window.pyodide.runPythonAsync('_audio_b64(__wa_audio_name)');
        const samples = await decodeToSamples(b64ToBytes(b64));
        window.pyodide.globals.set('__wa_samples', samples);
        await window.pyodide.runPythonAsync('await _transcribe_voice_note(__wa_audio_name, __wa_samples)');
      } catch (err) {
        setStatus('Could not transcribe "' + name + '": ' + err);
      }
    }
  }

  async function ingest(file) {
    setStatus('Reading "' + file.name + '"… (images are OCRed locally — this can take a moment)');
    let pending = [];
    if (/\.zip$/i.test(file.name)) {
      const buf = new Uint8Array(await file.arrayBuffer());
      window.pyodide.globals.set('__wa_zip', buf);
      window.pyodide.globals.set('__wa_name', file.name);
      const raw = await window.pyodide.runPythonAsync('await _ingest_export(__wa_name, __wa_zip.to_py())');
      pending = JSON.parse(raw).pending_audio;
    } else {
      window.pyodide.globals.set('__wa_name', file.name);
      window.pyodide.globals.set('__wa_text', await file.text());
      await window.pyodide.runPythonAsync('await _ingest_chat_text(__wa_name, __wa_text)');
    }
    await refreshDashboard();
    if (pending.length) {
      await drainAudio(pending);
      await refreshDashboard();
    }
    setStatus('Export processed — nothing left this browser. Load a model in the top bar, then ask away.');
    enableControls(true);
  }

  async function handleFiles(files) {
    if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
    for (const file of files) {
      try { await ingest(file); }
      catch (err) { setStatus('Could not read "' + file.name + '": ' + err); }
    }
  }

  document.getElementById('export-file').addEventListener('change', (e) => {
    handleFiles(e.target.files);
  });
  dropZone().addEventListener('dragover', (e) => {
    e.preventDefault();
    dropZone().classList.add('wa__drop--over');
  });
  dropZone().addEventListener('dragleave', () => {
    dropZone().classList.remove('wa__drop--over');
  });
  dropZone().addEventListener('drop', (e) => {
    e.preventDefault();
    dropZone().classList.remove('wa__drop--over');
    handleFiles(e.dataTransfer.files);
  });

  document.getElementById('wa-demo').addEventListener('click', async () => {
    if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
    try {
      await window.pyodide.runPythonAsync('await _load_demo()');
      await refreshDashboard();
      setStatus('Demo loaded — a fictional client and trip. Load a model above, then ask away.');
      enableControls(true);
    } catch (err) {
      setStatus('Could not load the demo: ' + err);
    }
  });

  suggestBtn().addEventListener('click', async () => {
    if (!pyReady || !hasData) return;
    window.addMessage('user', 'Suggest the next action');
    enableControls(false);
    try {
      const out = await window.pyodide.runPythonAsync('await _suggest_next_action()');
      if (out) window.addMessage('assistant', out);
    } catch (err) {
      window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
    } finally {
      enableControls(true);
    }
  });

  document.getElementById('wa-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const q = input().value.trim();
    if (!q || !pyReady) return;
    window.addMessage('user', q);
    input().value = '';
    enableControls(false);
    try {
      window.pyodide.globals.set('__wa_q', q);
      const answer = await window.pyodide.runPythonAsync('await process_user_query(__wa_q)');
      if (answer) window.addMessage('assistant', answer);
    } catch (err) {
      window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
    } finally {
      enableControls(true);
      input().focus();
    }
  });

  // Pyodide is ready after initAgent(); the orchestrator fires this on document.
  document.addEventListener('pyodide-ready', async () => {
    // Start each session with an empty index: the RAG store is shared per
    // origin, and this client's answers must never come from another export
    // (or another document agent on the same host).
    try { await window.AgentOpRAG.clear(); } catch (e) {}
    pyReady = true;
    setStatus('Ready. Drop a WhatsApp export to begin — it never leaves this browser.');
  });
})();
import base64
import io
import json
import re
import zipfile

# How many passages to retrieve per question.
RAG_TOP_K = [[[RAG_TOP_K|6]]]
# Chat messages per indexed sliding window.
CHAT_WINDOW = [[[CHAT_WINDOW|12]]]
# Cap on images OCR'd per export (each OCR pass costs seconds on CPU).
MAX_MEDIA = [[[MAX_MEDIA|40]]]

_STATE = {"messages": [], "participants": [], "artifacts": [], "skipped": []}
_PENDING_AUDIO = {}

# One WhatsApp message line, tolerant across exports:
#   iOS:     [01/07/2026, 09:12:03] Ana Souza: text
#   Android: 01/07/2026, 09:12 - Ana Souza: text     (also 12h "9:12 PM")
_MSG_RE = re.compile(
    r"^\[?([0-9]{1,2}[./-][0-9]{1,2}[./-][0-9]{2,4}),?\s+"
    r"([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?(?:\s?[APap][Mm])?)\]?"
    r"\s*(?:-\s*)?([^:]{1,40}):\s(.*)$"
)

# Artifact typing (filename + extracted text), first match wins — EN + PT-BR.
_KIND_RULES = [
    ("boarding pass", ("boarding pass", "cartão de embarque", "cartao de embarque",
                       "boarding", "gate", "assento", "seat")),
    ("payment proof", ("comprovante", "payment", "pagamento", "paid", "pago",
                       "pix", "transfer", "transferência", "transferencia", "receipt")),
    ("invoice", ("invoice", "fatura", "nota fiscal")),
    ("quote", ("quote", "quotation", "orçamento", "orcamento", "cotação", "cotacao")),
    ("reservation", ("reservation", "reserva", "booking", "locator", "localizador",
                     "pnr", "e-ticket", "eticket", "confirmation")),
    ("accommodation", ("hotel", "hospedagem", "hostel", "check-in", "checkin",
                       "accommodation")),
    ("identity document", ("passport", "passaporte", "identity", "identidade",
                           "cpf", "carteira de motorista", "driver's license")),
]

# Stage signals read from conversation text — stricter than _KIND_RULES
# (word-bounded) so ordinary chat words can't fake a stage.
_CHAT_STAGE_RULES = [
    ("boarding pass", re.compile(r"boarding pass|cart[ãa]o de embarque")),
    ("payment proof", re.compile(r"\bpaid\b|\bpago\b|comprovante|\bpix\b|receipt")),
    ("reservation", re.compile(r"\bbooked\b|reservation|reserva|locator|localizador|\bpnr\b")),
    ("quote", re.compile(r"\bquote\b|or[çc]amento|cota[çc][ãa]o")),
]

_STAGES = ["inquiry", "quoted", "booked", "paid", "ticketed"]
_NEXT_ACTION = {
    "inquiry": "Send a quote — no quote has gone out yet.",
    "quoted": "Follow up on the quote and confirm the booking.",
    "booked": "Request payment for the confirmed reservation.",
    "paid": "Issue the ticket and send it to the client.",
    "ticketed": "Send a pre-flight reminder with a documents checklist.",
}

_AMOUNT_RE = re.compile(
    r"(?:[$€£]|R\$|\bUSD\b|\bEUR\b|\bBRL\b)\s?[0-9][0-9.,]*"
    r"|\b[0-9]+[.,][0-9]{2}\b"
)

_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
_AUDIO_EXTS = (".opus", ".ogg", ".m4a", ".mp3", ".wav", ".aac")

_DEMO_CHAT = """\
[01/07/2026, 09:12:03] Ana Souza: Hi! Do you have fares to Lisbon around Aug 14?
[01/07/2026, 09:15:40] Viva Agency: Good morning Ana! Yes — I will send a quote today.
[01/07/2026, 11:02:19] Viva Agency: Quote: GRU-LIS on 14 Aug, flight TP88, R$ 3.480,00 round trip. Valid for 48h.
[01/07/2026, 11:30:05] Ana Souza: Great, please book it!
[02/07/2026, 10:05:55] Viva Agency: Reservation confirmed — locator XKC9QD. Payment by pix, ok?
[02/07/2026, 10:20:31] Ana Souza: Paid! Just sent the receipt, R$ 3.480,00.
[02/07/2026, 10:44:12] Viva Agency: Received. I will issue the ticket and send it here.
"""


def _extract_pdf_text(pdf_bytes):
    """(JS-callable) Extract plain text from a PDF's raw bytes via pypdf."""
    from pypdf import PdfReader

    reader = PdfReader(io.BytesIO(pdf_bytes))
    return "\n\n".join((page.extract_text() or "") for page in reader.pages)


def _parse_chat(text):
    """Parse a WhatsApp chat export into message dicts (continuations merged)."""
    msgs = []
    for raw in text.splitlines():
        line = raw.replace("\u200e", "").strip()  # WhatsApp sprinkles LTR marks
        if not line:
            continue
        m = _MSG_RE.match(line)
        if m:
            date, time, sender, body = m.groups()
            msgs.append(
                {"date": date, "time": time, "sender": sender.strip(),
                 "text": body.strip()}
            )
        elif msgs:
            msgs[-1]["text"] += " " + line
    return msgs


def _classify_artifact(filename, text):
    """Type one media file from its name + extracted text (keyword rules)."""
    hay = (filename + " " + (text or "")[:2000]).lower()
    for kind, needles in _KIND_RULES:
        if any(n in hay for n in needles):
            return kind
    return "uncategorized"


def _stage():
    """Sales-pipeline stage from artifacts, plus conversation-text signals."""
    kinds = {a["kind"] for a in _STATE["artifacts"]}
    hay = " ".join(m["text"] for m in _STATE["messages"]).lower()
    for kind, rx in _CHAT_STAGE_RULES:
        if rx.search(hay):
            kinds.add(kind)
    for stage, kind in (
        ("ticketed", "boarding pass"),
        ("paid", "payment proof"),
        ("booked", "reservation"),
        ("quoted", "quote"),
    ):
        if kind in kinds:
            return stage
    return "inquiry"


async def _add_artifact(name, kind, text, indexed=True):
    entry = {"name": name, "kind": kind}
    m = _AMOUNT_RE.search(text or "")
    if m and kind in ("payment proof", "quote", "invoice"):
        entry["amount"] = m.group(0)
    _STATE["artifacts"].append(entry)
    if indexed and (text or "").strip():
        await agentop_rag.add_document(f"{kind} — {name}", text)


async def _index_chat(msgs, source):
    """Index the conversation as sliding windows of CHAT_WINDOW messages."""
    for start in range(0, len(msgs), CHAT_WINDOW):
        window = msgs[start : start + CHAT_WINDOW]
        lines = [
            f"{m['sender']} ({m['date']} {m['time']}): {m['text']}" for m in window
        ]
        label = f"chat {source} — {window[0]['date']} {window[0]['time']}"
        await agentop_rag.add_document(label, "\n".join(lines))


async def _ingest_chat_text(source, text):
    """(JS-callable) Parse + index one chat .txt. Returns the message count."""
    msgs = _parse_chat(text)
    if msgs:
        _STATE["messages"].extend(msgs)
        for m in msgs:
            if m["sender"] not in _STATE["participants"]:
                _STATE["participants"].append(m["sender"])
        await _index_chat(msgs, source)
    elif text.strip():
        # Unrecognized format — still make it searchable.
        await agentop_rag.add_document(f"chat {source}", text)
    return len(msgs)


async def _ocr_image(lowname, data):
    if lowname.endswith(".png"):
        mime = "image/png"
    elif lowname.endswith(".webp"):
        mime = "image/webp"
    else:
        mime = "image/jpeg"
    url = f"data:{mime};base64,{base64.b64encode(data).decode()}"
    return (await agentop_ml.ocr(url)) or ""


async def _ingest_export(name, data):
    """(JS-callable) Ingest one WhatsApp export .zip (chat + media).

    Returns JSON {"pending_audio": [names]} — voice notes must be decoded by
    the browser (AudioContext) before Python can transcribe them.
    """
    zf = zipfile.ZipFile(io.BytesIO(bytes(data)))
    media_done = 0
    for info in zf.infolist():
        if info.is_dir():
            continue
        base = info.filename.rsplit("/", 1)[-1]
        low = base.lower()
        try:
            if low.endswith(".txt"):
                await _ingest_chat_text(base, zf.read(info).decode("utf-8", "replace"))
            elif low.endswith(".pdf"):
                text = _extract_pdf_text(zf.read(info))
                kind = _classify_artifact(base, text)
                # Identity documents are counted, never indexed (privacy).
                await _add_artifact(
                    base, kind, text, indexed=(kind != "identity document")
                )
            elif low.endswith(_IMAGE_EXTS):
                if media_done >= MAX_MEDIA:
                    _STATE["skipped"].append(base)
                    continue
                media_done += 1
                text = await _ocr_image(low, zf.read(info))
                kind = _classify_artifact(base, text)
                await _add_artifact(
                    base, kind, text, indexed=(kind != "identity document")
                )
            elif low.endswith(_AUDIO_EXTS):
                _PENDING_AUDIO[base] = zf.read(info)
            else:
                _STATE["skipped"].append(base)
        except Exception as exc:  # one bad file must not kill the export
            _STATE["skipped"].append(f"{base} ({exc})")
    return json.dumps({"pending_audio": list(_PENDING_AUDIO)})


def _audio_b64(name):
    """(JS-callable) Pending voice-note bytes as base64 for browser decoding."""
    return base64.b64encode(_PENDING_AUDIO[name]).decode()


async def _transcribe_voice_note(name, samples):
    """(JS-callable) Transcribe decoded 16 kHz mono samples; index the text."""
    _PENDING_AUDIO.pop(name, None)
    text = (await agentop_ml.transcribe(samples)).strip()
    await _add_artifact(name, "voice note", text)
    return text


async def _load_demo():
    """(JS-callable) Run a small fictional conversation through the pipeline."""
    return str(await _ingest_chat_text("demo-chat.txt", _DEMO_CHAT))


def _dashboard():
    """(JS-callable) Deterministic CRM snapshot as JSON — no LLM involved."""
    stage = _stage()
    return json.dumps(
        {
            "messages": len(_STATE["messages"]),
            "participants": _STATE["participants"],
            "artifacts": _STATE["artifacts"],
            "payments": [
                a["amount"]
                for a in _STATE["artifacts"]
                if a["kind"] == "payment proof" and a.get("amount")
            ],
            "stage": stage,
            "stages": _STAGES,
            "next_action": _NEXT_ACTION[stage],
            "skipped": _STATE["skipped"],
        }
    )


def _facts_sheet():
    stage = _stage()
    lines = [
        f"Pipeline stage: {stage}. Rule-suggested next action: {_NEXT_ACTION[stage]}",
        f"Participants: {', '.join(_STATE['participants']) or '(unknown)'}",
        f"Messages in export: {len(_STATE['messages'])}",
        "Artifacts:",
    ]
    for a in _STATE["artifacts"][:30]:
        amount = f" (amount: {a['amount']})" if a.get("amount") else ""
        lines.append(f"  - {a['kind']}: {a['name']}{amount}")
    if not _STATE["artifacts"]:
        lines.append("  - (none)")
    return "\n".join(lines)


async def _suggest_next_action():
    """(JS-callable) Stage rules + LLM: next move and a ready-to-send reply."""
    if not _STATE["messages"] and not _STATE["artifacts"]:
        return "Drop a WhatsApp chat export first (or load the demo)."
    recent = _STATE["messages"][-CHAT_WINDOW:]
    convo = "\n".join(f"{m['sender']}: {m['text']}"[:200] for m in recent)
    prompt = (
        "You are helping a travel agent decide their next move with this "
        "client.\n"
        f"CLIENT FACTS:\n{_facts_sheet()}\n\n"
        f"RECENT MESSAGES:\n{convo}\n\n"
        "Reply with exactly two sections:\n"
        "## Next action\n(one sentence, consistent with the pipeline stage)\n\n"
        "## Draft message\n(a short, friendly WhatsApp message the agent can "
        "send as-is, in the same language the client writes in)"
    )
    # TEMPLATE_SYSTEM_PROMPT only exists as a JS global; the query bridge falls
    # back to it when custom_prompt is empty (same idiom as the base template).
    return await process_user_query_wllama(
        prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )


async def process_user_query(query):
    """Hybrid grounding: the deterministic facts sheet ALWAYS accompanies the
    retrieved excerpts, so the model reads a small, precise context instead of
    the whole conversation.

    Overrides the default router so retrieval is deterministic (small local
    models are unreliable at deciding to call a search tool themselves).
    """
    if not _STATE["messages"] and not _STATE["artifacts"]:
        return (
            "I don't have a conversation yet — drop a WhatsApp chat export "
            "above (or load the demo), then ask me who paid, what's booked, "
            "or what to do next."
        )
    hits = await agentop_rag.search(query, RAG_TOP_K)
    excerpts = "\n\n".join(
        f"[{i + 1}] (from {h['source']}) {h['text']}" for i, h in enumerate(hits)
    ) or "(nothing retrieved)"
    grounded_prompt = (
        "You are a CRM copilot for a small travel agency that sells over "
        "WhatsApp. Answer the QUESTION using ONLY the CLIENT FACTS and "
        "CONVERSATION EXCERPTS below. Quote names, amounts, dates, flight "
        "numbers, and booking codes exactly as written; cite excerpts like "
        "[1]. If the answer is not there, say the export does not show it.\n\n"
        f"CLIENT FACTS:\n{_facts_sheet()}\n\n"
        f"CONVERSATION EXCERPTS:\n{excerpts}\n\nQUESTION: {query}"
    )
    return await process_user_query_wllama(
        grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )