Agent Template

Document Q&A

Upload a PDF or text file and ask questions answered only from that document — fully in your browser, nothing uploaded to a server.

documents pdf rag q&a privacy
ozzo Jul 10, 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

Document Q&A 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 documents pdf rag q&a privacy
Template Preview

Template Metadata

Slug
document-qa
Created By
ozzo
Created
Jul 10, 2026
Usage Count
1

Tags

documents pdf rag q&a privacy

Code Statistics

HTML Lines
34
CSS Lines
29
JS Lines
78
Python Lines
50

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="doc-qa">
    <header class="doc-qa__head">
      <h1>{{ agent_name }}</h1>
      <p class="doc-qa__sub">{{ description }}</p>
    </header>

    <section class="doc-qa__upload">
      <label class="doc-qa__drop" for="doc-file">
        <input id="doc-file" type="file" accept=".pdf,.txt,.md,.markdown" multiple hidden>
        <span>📄 Upload a PDF or text file, then ask questions about it</span>
      </label>
      <div id="doc-status" class="doc-qa__status" role="status"></div>
    </section>

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

    <form id="doc-qa-form" class="doc-qa__form" autocomplete="off">
      <input id="doc-qa-input" type="text"
             placeholder="Ask a question about your document…" required disabled>
      <button id="doc-qa-send" type="submit" disabled>Ask</button>
    </form>
  </main>
  <script>{{ js_code }}</script>
</body>
</html>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
       background: #f7f7f8; color: #1a1a1a; }
.doc-qa { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.doc-qa__head h1 { font-size: 1.6rem; margin: 0 0 4px; }
.doc-qa__sub { margin: 0 0 20px; color: #666; }
.doc-qa__drop { display: block; border: 2px dashed #c7c7cc; border-radius: 12px;
                padding: 22px; text-align: center; cursor: pointer; background: #fff;
                color: #444; transition: border-color .15s, background .15s; }
.doc-qa__drop:hover { border-color: #6366f1; background: #f5f5ff; }
.doc-qa__status { font-size: .85rem; color: #555; margin: 10px 2px; min-height: 1.2em; }
.doc-qa__chat { display: flex; flex-direction: column; gap: 10px; margin: 8px 0 16px; }
.doc-qa__msg { padding: 10px 14px; border-radius: 12px; max-width: 85%;
               white-space: pre-wrap; line-height: 1.5; }
.doc-qa__msg--user { align-self: flex-end; background: #6366f1; color: #fff; }
.doc-qa__msg--assistant { align-self: flex-start; background: #fff; border: 1px solid #eee; }
.doc-qa__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
                padding: 12px 16px; background: #fff; border-top: 1px solid #eee; }
.doc-qa__form input { flex: 1; padding: 12px 14px; border: 1px solid #d0d0d5;
                      border-radius: 10px; font-size: 1rem; }
.doc-qa__form button { padding: 0 20px; border: 0; border-radius: 10px;
                       background: #6366f1; color: #fff; font-weight: 600; cursor: pointer; }
.doc-qa__form button:disabled { background: #b7b7c6; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
  body { background: #16161a; color: #ececf1; }
  .doc-qa__drop, .doc-qa__msg--assistant, .doc-qa__form { background: #202028; border-color: #33333c; }
  .doc-qa__form input { background: #16161a; color: #ececf1; border-color: #33333c; }
}
// Document Q&A UI wiring. The RAG/embeddings/wllama infrastructure is injected
// by the generator; this only connects the upload + chat controls to it.
(function () {
  const statusEl = () => document.getElementById('doc-status');
  const results = () => document.getElementById('results-container');
  const input = () => document.getElementById('doc-qa-input');
  const sendBtn = () => document.getElementById('doc-qa-send');

  let pyReady = false;

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

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

  async function ingest(file) {
    setStatus('Reading "' + file.name + '"…');
    const text = await readFileText(file);
    window.pyodide.globals.set('__doc_src', file.name);
    window.pyodide.globals.set('__doc_text', text);
    const n = await window.pyodide.runPythonAsync('await _ingest_document(__doc_src, __doc_text)');
    setStatus('Indexed ' + n + ' passages from "' + file.name + '". Ask away!');
    enableChat(true);
  }

  document.getElementById('doc-file').addEventListener('change', async (e) => {
    if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
    for (const file of e.target.files) {
      try { await ingest(file); }
      catch (err) { setStatus('Could not read "' + file.name + '": ' + err); }
    }
  });

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

  // Pyodide is ready after initAgent(); the orchestrator fires this on document.
  document.addEventListener('pyodide-ready', () => {
    pyReady = true;
    setStatus('Ready. Upload a document to begin.');
  });
})();
import json

# How many document passages to retrieve per question (tunable at generation).
RAG_TOP_K = [[[RAG_TOP_K|4]]]


async def _ingest_document(source, text):
    """(JS-callable) Chunk + embed + index one document's text.

    Underscore-prefixed so it is never exposed to the LLM as a tool.
    Returns the number of chunks stored, as a string (for the UI).
    """
    count = await agentop_rag.add_document(source, text)
    return str(count)


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)


async def process_user_query(query):
    """RAG-first answering: retrieve the most relevant passages from the uploaded
    document(s), then answer grounded ONLY in them.

    Overrides the default router so retrieval is deterministic (small local
    models are unreliable at deciding to call a search tool themselves).
    """
    hits = await agentop_rag.search(query, RAG_TOP_K)
    if not hits:
        context = "(No document has been uploaded yet, or nothing relevant was found.)"
    else:
        context = "\n\n".join(
            f"[{i + 1}] (from {h['source']}) {h['text']}" for i, h in enumerate(hits)
        )
    grounded_prompt = (
        "Answer the QUESTION using ONLY the CONTEXT passages below. "
        "If the answer is not in the context, say you could not find it in the "
        "document. Cite passage numbers like [1] where relevant.\n\n"
        f"CONTEXT:\n{context}\n\nQUESTION: {query}"
    )
    # 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(
        grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
    )