Agent Template

Private Quote & Material Estimator

Drop competing contractor quotes (PDFs) and compare them side by side — totals, inclusions, exclusions — then ask questions across all of them, fully private and in your browser.

quotes renovation comparison documents 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

Private Quote & Material Estimator 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 quotes renovation comparison documents rag privacy
Template Preview

Template Metadata

Slug
quote-comparator
Created By
ozzo
Created
Jul 12, 2026
Usage Count
1

Tags

quotes renovation comparison documents rag privacy

Code Statistics

HTML Lines
44
CSS Lines
70
JS Lines
143
Python Lines
148

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="qc">
    <header class="qc__head">
      <h1>🧰 {{ agent_name }}</h1>
      <p class="qc__sub">{{ description }}</p>
    </header>

    <section class="qc__upload">
      <label class="qc__drop" for="quote-file" id="qc-drop">
        <input id="quote-file" type="file"
               accept=".pdf,.txt,.md,.markdown" multiple hidden>
        <span>📋 Drop the vendor quotes here (PDF or text) — one file per vendor</span>
      </label>
      <div id="qc-cards" class="qc__cards" aria-label="Uploaded quotes"></div>
      <div id="qc-status" class="qc__status" role="status"></div>
      <div class="qc__compare-row">
        <input id="qc-focus" type="text" class="qc__focus"
               placeholder="Optional focus (e.g. flooring, plumbing, cabinetry)"
               aria-label="Comparison focus">
        <button id="qc-compare" type="button" class="qc__compare" disabled>
          ⚖️ Compare quotes
        </button>
      </div>
    </section>

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

    <form id="qc-form" class="qc__form" autocomplete="off">
      <input id="qc-input" type="text"
             placeholder="Ask across the quotes — inclusions, prices, gaps…" required disabled>
      <button id="qc-send" type="submit" disabled>Ask</button>
    </form>
  </main>
  <script>{{ js_code }}</script>
</body>
</html>
:root {
  color-scheme: light dark;
  --qc-primary: #475569;   /* industrial slate */
  --qc-accent: #f97316;    /* safety orange */
  --qc-bg: #f8fafc;
  --qc-surface: #ffffff;
  --qc-text: #334155;
  --qc-muted: #64748b;
  --qc-border: #e2e8f0;
  --qc-radius: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
       background: var(--qc-bg); color: var(--qc-text); line-height: 1.55; }
.hidden { display: none !important; }
.qc { max-width: 800px; margin: 0 auto; padding: 24px 16px 96px; }
.qc__head h1 { font-size: 1.55rem; margin: 0 0 4px; color: #1e293b; }
.qc__sub { margin: 0 0 18px; color: var(--qc-muted); }
.qc__drop { display: block; border: 2px dashed #c4cedb; border-radius: var(--qc-radius);
            padding: 22px; text-align: center; cursor: pointer;
            background: var(--qc-surface); color: #46586e;
            transition: border-color .15s, background .15s; }
.qc__drop:hover, .qc__drop--over { border-color: var(--qc-accent); background: #fff4ec; }
.qc__cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
             gap: 8px; margin: 10px 0 0; }
.qc__card { background: var(--qc-surface); border: 1px solid var(--qc-border);
            border-left: 4px solid var(--qc-accent); border-radius: 10px;
            padding: 10px 12px; }
.qc__card-name { font-weight: 700; font-size: .85rem; margin: 0 0 4px;
                 overflow-wrap: anywhere; }
.qc__card-total { font-size: .8rem; margin: 0 0 2px; overflow-wrap: anywhere; }
.qc__card-meta { font-size: .75rem; color: var(--qc-muted); margin: 0; }
.qc__status { font-size: .85rem; color: var(--qc-muted); margin: 10px 2px;
              min-height: 1.2em; }
.qc__compare-row { display: flex; gap: 8px; margin: 4px 0 0; flex-wrap: wrap; }
.qc__focus { flex: 1 1 240px; padding: 10px 12px; border: 1px solid var(--qc-border);
             border-radius: 10px; font: inherit; font-size: .95rem;
             background: var(--qc-surface); color: var(--qc-text); }
.qc__focus:focus-visible, .qc__form input:focus-visible
  { outline: 2px solid var(--qc-accent); outline-offset: 1px; }
.qc__compare { padding: 11px 18px; border: 0; border-radius: 10px;
               background: var(--qc-accent); color: #fff; font-weight: 700;
               cursor: pointer; }
.qc__compare:disabled { background: #c3c9d2; cursor: not-allowed; }
.qc__chat { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 16px; }
.qc__msg, .message { padding: 11px 14px; border-radius: var(--qc-radius);
                     max-width: 88%; white-space: pre-wrap; line-height: 1.55; }
.qc__msg--user { align-self: flex-end; background: var(--qc-primary); color: #fff; }
.qc__msg--assistant, .message-assistant { align-self: flex-start;
    background: var(--qc-surface); border: 1px solid var(--qc-border); }
.message { display: flex; gap: 8px; }
.message-icon { flex: 0 0 auto; }
.message-content { min-width: 0; }
.qc__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
            padding: 12px 16px; background: var(--qc-surface);
            border-top: 1px solid var(--qc-border); }
.qc__form input { flex: 1; padding: 12px 14px; border: 1px solid #cbd5e1;
                  border-radius: 10px; font-size: 1rem;
                  background: var(--qc-bg); color: var(--qc-text); }
.qc__form button { padding: 0 22px; border: 0; border-radius: 10px;
                   background: var(--qc-primary); color: #fff; font-weight: 700;
                   cursor: pointer; }
.qc__form button:disabled { background: #c3c9d2; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
  :root { --qc-bg: #12161d; --qc-surface: #1b212b; --qc-text: #dde5ee;
          --qc-muted: #93a1b3; --qc-border: #2c3644; --qc-primary: #7b8ca3; }
  .qc__head h1 { color: #e8eef5; }
  .qc__drop { color: #a9b8ca; border-color: #3a4757; }
  .qc__drop:hover, .qc__drop--over { background: #26210f; }
}
// Quote Comparator UI wiring. The RAG/embeddings/wllama infrastructure is
// injected by the generator; this connects upload, compare, and chat controls.
(function () {
  const statusEl = () => document.getElementById('qc-status');
  const results = () => document.getElementById('results-container');
  const input = () => document.getElementById('qc-input');
  const sendBtn = () => document.getElementById('qc-send');
  const compareBtn = () => document.getElementById('qc-compare');
  const cards = () => document.getElementById('qc-cards');
  const dropZone = () => document.getElementById('qc-drop');

  let pyReady = false;
  let quoteCount = 0;

  // Render helper the wllama query bridge also calls for streamed responses.
  window.addMessage = function (type, content) {
    const el = document.createElement('div');
    el.className = 'qc__msg qc__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;
    compareBtn().disabled = !on;
  }

  // Vendor card built from the deterministic ingest facts (textContent only).
  function addCard(info) {
    const card = document.createElement('div');
    card.className = 'qc__card';
    const name = document.createElement('p');
    name.className = 'qc__card-name';
    name.textContent = '📋 ' + info.source;
    const total = document.createElement('p');
    total.className = 'qc__card-total';
    total.textContent = info.total_line
      ? '💰 ' + info.total_line
      : '💰 No total line detected';
    const meta = document.createElement('p');
    meta.className = 'qc__card-meta';
    meta.textContent = info.priced_lines + ' priced line' +
      (info.priced_lines === 1 ? '' : 's') + ' found';
    card.append(name, total, meta);
    cards().appendChild(card);
  }

  async function readFileText(file) {
    if (/\.pdf$/i.test(file.name)) {
      const buf = new Uint8Array(await file.arrayBuffer());
      window.pyodide.globals.set('__qc_pdf_bytes', buf);
      return await window.pyodide.runPythonAsync('_extract_pdf_text(bytes(__qc_pdf_bytes.to_py()))');
    }
    return await file.text();
  }

  async function ingest(file) {
    setStatus('Reading "' + file.name + '"…');
    const text = await readFileText(file);
    if (!text.trim()) throw new Error('no selectable text found (scanned PDF?)');
    window.pyodide.globals.set('__qc_src', file.name);
    window.pyodide.globals.set('__qc_text', text);
    const raw = await window.pyodide.runPythonAsync('await _ingest_quote(__qc_src, __qc_text)');
    addCard(JSON.parse(raw));
    quoteCount++;
    setStatus(quoteCount + ' quote' + (quoteCount === 1 ? '' : 's') +
      ' in. Add more, or load a model in the top bar and compare.');
    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('quote-file').addEventListener('change', (e) => {
    handleFiles(e.target.files);
  });
  dropZone().addEventListener('dragover', (e) => {
    e.preventDefault();
    dropZone().classList.add('qc__drop--over');
  });
  dropZone().addEventListener('dragleave', () => {
    dropZone().classList.remove('qc__drop--over');
  });
  dropZone().addEventListener('drop', (e) => {
    e.preventDefault();
    dropZone().classList.remove('qc__drop--over');
    handleFiles(e.dataTransfer.files);
  });

  compareBtn().addEventListener('click', async () => {
    if (!pyReady || !quoteCount) return;
    const focus = document.getElementById('qc-focus').value.trim();
    window.addMessage('user', focus ? 'Compare quotes — focus: ' + focus : 'Compare quotes');
    enableControls(false);
    try {
      window.pyodide.globals.set('__qc_focus', focus);
      const out = await window.pyodide.runPythonAsync('await _compare_quotes(__qc_focus)');
      if (out) window.addMessage('assistant', out);
    } catch (err) {
      window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
    } finally {
      enableControls(true);
    }
  });

  document.getElementById('qc-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('__qc_q', q);
      const answer = await window.pyodide.runPythonAsync('await process_user_query(__qc_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 comparison must only see this batch of quotes (never
    // an earlier batch or another document agent's files).
    try { await window.AgentOpRAG.clear(); } catch (e) {}
    pyReady = true;
    setStatus('Ready. Drop the vendor quotes to begin — they never leave this browser.');
  });
})();
import json
import re

# How many quote passages to retrieve per free-form question.
RAG_TOP_K = [[[RAG_TOP_K|6]]]
# How many retrieved passages per vendor go into the comparison prompt.
QUOTE_SNIPPETS = [[[QUOTE_SNIPPETS|3]]]
# How many priced lines per vendor go into the comparison prompt.
QUOTE_PRICED_LINES = [[[QUOTE_PRICED_LINES|8]]]

# One entry per ingested quote:
# {"source": name, "amount_lines": [...], "total_line": str or None}
_QUOTES = []

# Currency-ish amounts: symbol/code prefixed, or bare decimals like 1.234,56 / 249.00
_AMOUNT_RE = re.compile(
    r"(?:[$€£₺]|\b(?:USD|EUR|GBP|TRY|CHF)\b)\s?[0-9][0-9.,]*"
    r"|\b[0-9]{1,3}(?:[.,][0-9]{3})+(?:[.,][0-9]{2})?\b"
    r"|\b[0-9]+[.,][0-9]{2}\b"
)
_TOTAL_RE = re.compile(r"grand total|total|amount due|balance due|subtotal|sum", re.I)


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

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


def _scan_amounts(text):
    """Return (priced lines, best total-ish line) found in one quote's text."""
    amount_lines, total_line = [], None
    for raw in text.splitlines():
        line = " ".join(raw.split())
        if line and _AMOUNT_RE.search(line):
            amount_lines.append(line[:160])
            if _TOTAL_RE.search(line):
                total_line = line[:160]  # keep the last one — grand total is usually last
    return amount_lines, total_line


async def _ingest_quote(source, text):
    """(JS-callable) Index one vendor quote and scan its priced lines.

    Returns JSON {"source", "chunks", "priced_lines", "total_line"} for the
    vendor card UI (deterministic — never LLM output).
    """
    chunks = await agentop_rag.add_document(source, text)
    amount_lines, total_line = _scan_amounts(text)
    _QUOTES.append(
        {"source": source, "amount_lines": amount_lines, "total_line": total_line}
    )
    return json.dumps(
        {
            "source": source,
            "chunks": chunks,
            "priced_lines": len(amount_lines),
            "total_line": total_line or "",
        }
    )


async def _vendor_evidence(query):
    """Per-vendor evidence blocks: detected totals, priced lines, relevant passages."""
    hits = await agentop_rag.search(query, QUOTE_SNIPPETS * len(_QUOTES) + 4)
    by_vendor = {q["source"]: [] for q in _QUOTES}
    for h in hits:
        bucket = by_vendor.get(h["source"])
        if bucket is not None and len(bucket) < QUOTE_SNIPPETS:
            bucket.append(h["text"][:700])
    blocks = []
    for q in _QUOTES:
        lines = [f"VENDOR FILE: {q['source']}"]
        lines.append(f"Detected total line: {q['total_line'] or '(none detected)'}")
        if q["amount_lines"]:
            lines.append("Priced lines:")
            lines.extend(f"  - {a}" for a in q["amount_lines"][:QUOTE_PRICED_LINES])
        for i, snippet in enumerate(by_vendor[q["source"]]):
            lines.append(f"Relevant excerpt {i + 1}: {snippet}")
        blocks.append("\n".join(lines))
    return "\n\n".join(blocks)


async def _compare_quotes(focus):
    """(JS-callable) Build the normalized quote comparison via wllama."""
    if not _QUOTES:
        return "Drop at least one vendor quote first, then compare."
    focus = (focus or "").strip()
    query = focus or "scope of work, what is included and excluded, prices"
    evidence = await _vendor_evidence(query)
    focus_line = (
        f"Pay particular attention to: {focus}.\n" if focus else ""
    )
    prompt = (
        "You are comparing vendor quotes for the same job, using ONLY the "
        "EVIDENCE below (one block per vendor file).\n"
        f"{focus_line}"
        "Format exactly:\n"
        "## Quote by quote\n"
        "One '### <vendor file>' section per vendor: the bottom-line price if "
        "stated, what the quote includes, and what it excludes or leaves "
        "unclear.\n\n"
        "## Head-to-head\n"
        "Bullets: which stated total is lowest, scope differences, and items "
        "only some vendors cover. Quote prices exactly as written; if you "
        "compute a difference, show the numbers you used.\n\n"
        "## Questions to ask before deciding\n"
        "Bullets: what to clarify with each vendor (missing prices, unclear "
        "scope).\n\n"
        "Never invent prices — write 'not stated' when something is missing.\n\n"
        f"EVIDENCE:\n{evidence}"
    )
    # 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):
    """Free-form Q&A across all uploaded quotes (RAG, vendor-labelled).

    Overrides the default router so retrieval is deterministic (small local
    models are unreliable at deciding to call a search tool themselves).
    """
    if not _QUOTES:
        return (
            "I don't have any quotes yet — drop the vendor PDFs above and "
            "I'll compare them for you."
        )
    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 relevant found)"
    grounded_prompt = (
        "Answer the QUESTION using ONLY the QUOTE EXCERPTS below. Name the "
        "vendor file for every fact or price you use, cite excerpts like [1], "
        "and quote prices exactly as written — if you compute a difference, "
        "show the numbers you used. If the answer is not in the excerpts, say "
        "the quotes do not state it.\n\n"
        f"QUOTE EXCERPTS:\n{excerpts}\n\nQUESTION: {query}"
    )
    return await process_user_query_wllama(
        grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )