// agent

Private Quote & Material Estimator

by ozzo · Jul 12, 2026 Public

Choose how to run this agent

⚡ Local runs on your GPU. For usable speed it needs a WebGPU-capable browser — Chrome or Edge on a machine with a graphics card, or an Apple Silicon Mac. Without a supported GPU, pick OpenAI or Anthropic above instead. Check your machine
Try it now

Requires an API key and an AgentOp account.

83 downloads
0 forks
0.0 rating

Description

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

What this agent can do

Private Quote & Material Estimator is built from the Private Quote & Material Estimator template and loads pypdf in the browser. Runs fully on your own device: llama.cpp compiled to WebAssembly, GPU-accelerated through WebGPU, with no API key and no server. After the one-time model download it works offline. Can run on OpenAI models with your own API key, encrypted in your browser. Can run on Anthropic Claude models with your own API key, encrypted in your browser.

Runs in the browser

  • Local embeddings. Computes text embeddings inside the browser with Transformers.js, the basis for searching your own text by meaning rather than keywords.
  • Document Q&A (local RAG). Answers from documents you drop in: the text is chunked and indexed locally in the browser (IndexedDB), the relevant passages are retrieved for each question, and nothing is uploaded.

Source Code

agent.py
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", "")
    )

More by ozzo

Receipt & Invoice Extractor

Based on the Receipt & Invoice Extractor template.

New Hire Handbook Q&A

Based on the New Hire Handbook Q&A template.

Contract Plain-Language Explainer

Upload a contract and get it explained in plain language — obligations, fees, deadlines, exit claus…

WhatsApp Sales Copilot

Turn a raw WhatsApp chat export into a mini CRM — typed quotes, bookings, payments and boarding pas…

Messy Itinerary Travel Planner

Drop your messy pile of booking PDFs and trip notes and get a clean day-by-day itinerary — plus war…

Semantic Search

Search your own notes or documents by meaning, not keywords — instant results with no LLM download.