Agent Template

Contract Plain-Language Explainer

Upload a contract and get it explained in plain language — obligations, fees, deadlines, exit clauses, and red flags — with every answer grounded in the actual text, fully in your browser.

legal contracts documents rag plain-language privacy
ozzo Jul 13, 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

Contract Plain-Language Explainer 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 legal contracts documents rag plain-language privacy
Template Preview

Template Metadata

Slug
contract-explainer
Created By
ozzo
Created
Jul 13, 2026
Usage Count
1

Tags

legal contracts documents rag plain-language privacy

Code Statistics

HTML Lines
51
CSS Lines
65
JS Lines
118
Python Lines
81

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="ce">
    <header class="ce__head">
      <h1>⚖️ {{ agent_name }}</h1>
      <p class="ce__sub">{{ description }}</p>
    </header>

    <section class="ce__context">
      <input id="ce-type" type="text" class="ce__ctx-input"
             placeholder="Contract type (e.g. apartment lease)"
             aria-label="Contract type">
      <input id="ce-role" type="text" class="ce__ctx-input"
             placeholder="Your side (e.g. tenant)"
             aria-label="Your side of the contract">
    </section>

    <section class="ce__upload">
      <label class="ce__drop" for="contract-file" id="ce-drop">
        <input id="contract-file" type="file"
               accept=".pdf,.txt,.md,.markdown" multiple hidden>
        <span>📜 Upload the contract (PDF or text) — add amendments too</span>
      </label>
      <div id="ce-status" class="ce__status" role="status"></div>
    </section>

    <section id="ce-chips" class="ce__chips hidden" aria-label="Suggested questions">
      <button type="button" class="ce__chip">What are my main obligations?</button>
      <button type="button" class="ce__chip">How do I cancel or exit this contract?</button>
      <button type="button" class="ce__chip">What fees or penalties could I face?</button>
      <button type="button" class="ce__chip">Any red flags I should worry about?</button>
    </section>

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

    <form id="ce-form" class="ce__form" autocomplete="off">
      <input id="ce-input" type="text"
             placeholder="Ask about your contract…" required disabled>
      <button id="ce-send" type="submit" disabled>Ask</button>
    </form>
  </main>
  <script>{{ js_code }}</script>
</body>
</html>
:root {
  color-scheme: light dark;
  --ce-primary: #1e3a8a;   /* authority navy */
  --ce-accent: #b45309;    /* restrained trust gold */
  --ce-bg: #f8fafc;
  --ce-surface: #ffffff;
  --ce-text: #0f172a;
  --ce-muted: #64748b;
  --ce-border: #e2e8f0;
  --ce-radius: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: Georgia, 'Times New Roman', system-ui, serif;
       background: var(--ce-bg); color: var(--ce-text); line-height: 1.55; }
.hidden { display: none !important; }
.ce { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.ce__head h1 { font-size: 1.55rem; margin: 0 0 4px; color: var(--ce-primary); }
.ce__sub { margin: 0 0 18px; color: var(--ce-muted);
           font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.ce__context { display: flex; gap: 8px; margin: 0 0 10px; flex-wrap: wrap; }
.ce__ctx-input { flex: 1 1 220px; padding: 10px 12px; border: 1px solid var(--ce-border);
                 border-radius: 10px; font: inherit; font-size: .95rem;
                 background: var(--ce-surface); color: var(--ce-text); }
.ce__ctx-input:focus-visible, .ce__form input:focus-visible
  { outline: 2px solid var(--ce-primary); outline-offset: 1px; }
.ce__drop { display: block; border: 2px dashed #c8d2e4; border-radius: var(--ce-radius);
            padding: 22px; text-align: center; cursor: pointer;
            background: var(--ce-surface); color: #3c4a63;
            transition: border-color .15s, background .15s; }
.ce__drop:hover, .ce__drop--over { border-color: var(--ce-primary); background: #eef3fd; }
.ce__status { font-size: .85rem; color: var(--ce-muted); margin: 10px 2px;
              min-height: 1.2em;
              font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.ce__chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 12px; }
.ce__chip { border: 1px solid var(--ce-border); background: var(--ce-surface);
            color: var(--ce-primary); border-radius: 999px; padding: 7px 14px;
            font-size: .85rem; cursor: pointer; transition: background .15s;
            font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.ce__chip:hover { background: #eef3fd; }
.ce__chat { display: flex; flex-direction: column; gap: 10px; margin: 8px 0 16px; }
.ce__msg, .message { padding: 11px 14px; border-radius: var(--ce-radius);
                     max-width: 88%; white-space: pre-wrap; line-height: 1.55; }
.ce__msg--user { align-self: flex-end; background: var(--ce-primary); color: #fff; }
.ce__msg--assistant, .message-assistant { align-self: flex-start;
    background: var(--ce-surface); border: 1px solid var(--ce-border); }
.message { display: flex; gap: 8px; }
.message-icon { flex: 0 0 auto; }
.message-content { min-width: 0; }
.ce__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
            padding: 12px 16px; background: var(--ce-surface);
            border-top: 1px solid var(--ce-border); }
.ce__form input { flex: 1; padding: 12px 14px; border: 1px solid #c9d3e2;
                  border-radius: 10px; font-size: 1rem;
                  background: var(--ce-bg); color: var(--ce-text); }
.ce__form button { padding: 0 22px; border: 0; border-radius: 10px;
                   background: var(--ce-accent); color: #fff; font-weight: 700;
                   cursor: pointer; }
.ce__form button:disabled { background: #b9c2d0; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
  :root { --ce-bg: #101522; --ce-surface: #1a2233; --ce-text: #e7ecf5;
          --ce-muted: #9aa7bd; --ce-border: #2b3650; --ce-primary: #93b4f2; }
  .ce__drop { color: #b9c6dd; }
  .ce__drop:hover, .ce__drop--over { background: #202b42; }
  .ce__chip:hover { background: #202b42; }
}
// Contract Explainer UI wiring. The RAG/embeddings/wllama infrastructure is
// injected by the generator; this connects context, upload, chips, and chat.
(function () {
  const statusEl = () => document.getElementById('ce-status');
  const results = () => document.getElementById('results-container');
  const input = () => document.getElementById('ce-input');
  const sendBtn = () => document.getElementById('ce-send');
  const form = () => document.getElementById('ce-form');
  const chips = () => document.getElementById('ce-chips');
  const dropZone = () => document.getElementById('ce-drop');

  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 = 'ce__msg ce__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 syncContext() {
    window.pyodide.globals.set('__ce_type', document.getElementById('ce-type').value.trim());
    window.pyodide.globals.set('__ce_role', document.getElementById('ce-role').value.trim());
    await window.pyodide.runPythonAsync('_set_context(__ce_type, __ce_role)');
  }

  async function readFileText(file) {
    if (/\.pdf$/i.test(file.name)) {
      const buf = new Uint8Array(await file.arrayBuffer());
      window.pyodide.globals.set('__ce_pdf_bytes', buf);
      return await window.pyodide.runPythonAsync('_extract_pdf_text(bytes(__ce_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?)');
    await syncContext();
    window.pyodide.globals.set('__ce_src', file.name);
    window.pyodide.globals.set('__ce_text', text);
    const n = await window.pyodide.runPythonAsync('await _ingest_contract(__ce_src, __ce_text)');
    setStatus('Indexed ' + n + ' passages from "' + file.name + '". Load a model in the top bar, then ask away.');
    chips().classList.remove('hidden');
    enableChat(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('contract-file').addEventListener('change', (e) => {
    handleFiles(e.target.files);
  });
  dropZone().addEventListener('dragover', (e) => {
    e.preventDefault();
    dropZone().classList.add('ce__drop--over');
  });
  dropZone().addEventListener('dragleave', () => {
    dropZone().classList.remove('ce__drop--over');
  });
  dropZone().addEventListener('drop', (e) => {
    e.preventDefault();
    dropZone().classList.remove('ce__drop--over');
    handleFiles(e.dataTransfer.files);
  });

  chips().addEventListener('click', (e) => {
    const chip = e.target.closest('.ce__chip');
    if (!chip || input().disabled) return;
    input().value = chip.textContent;
    form().requestSubmit();
  });

  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 {
      await syncContext();
      window.pyodide.globals.set('__ce_q', q);
      const answer = await window.pyodide.runPythonAsync('await process_user_query(__ce_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', async () => {
    // Start each session with an empty index: the RAG store is shared per
    // origin, and answers about THIS contract must never come from an earlier
    // upload (or another document agent on the same host).
    try { await window.AgentOpRAG.clear(); } catch (e) {}
    pyReady = true;
    setStatus('Ready. Upload your contract to begin — it never leaves this browser.');
  });
})();
# How many contract passages to retrieve per question. Deliberately higher than
# a generic document agent: clause-heavy questions (termination, fees, renewal)
# often span several sections, and current local defaults (8K+ token context)
# leave room for the richer grounding.
RAG_TOP_K = [[[RAG_TOP_K|6]]]

# Session context set from the page: what kind of contract, and whose side the
# explanation should take. Both optional.
_CONTEXT = {"contract_type": "", "role": "", "sources": []}


def _set_context(contract_type, role):
    """(JS-callable) Remember the contract type and the user's side.

    Underscore-prefixed so it is never exposed to the LLM as a tool.
    """
    _CONTEXT["contract_type"] = (contract_type or "").strip()
    _CONTEXT["role"] = (role or "").strip()
    return "ok"


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 _ingest_contract(source, text):
    """(JS-callable) Chunk + embed + index one contract document or amendment.

    Returns the number of passages stored, as a string (for the UI).
    """
    count = await agentop_rag.add_document(source, text)
    _CONTEXT["sources"].append(source)
    return str(count)


def _framing():
    bits = []
    if _CONTEXT["contract_type"]:
        bits.append(f"The document is a {_CONTEXT['contract_type']}.")
    if _CONTEXT["role"]:
        bits.append(
            f"Explain everything from the perspective of the {_CONTEXT['role']}."
        )
    return " ".join(bits)


async def process_user_query(query):
    """Plain-language contract answering, grounded in retrieved clauses.

    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:
        return (
            "I don't have a contract to read yet — upload it above (PDF or "
            "text) and I'll explain it in plain language."
        )
    excerpts = "\n\n".join(
        f"[{i + 1}] (from {h['source']}) {h['text']}" for i, h in enumerate(hits)
    )
    grounded_prompt = (
        "You are explaining a contract to someone who is not a lawyer. "
        f"{_framing()} Use ONLY the CONTRACT EXCERPTS below.\n"
        "Answer in plain everyday language: give the short answer first, then "
        "what the contract actually says, citing excerpt numbers like [1]. "
        "If the excerpts mention deadlines, fees, penalties, or automatic "
        "renewal, point them out. If the excerpts do not contain the answer, "
        "say so plainly instead of guessing.\n\n"
        f"CONTRACT EXCERPTS:\n{excerpts}\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", "")
    )