Contract Plain-Language Explainer
The Contract Plain-Language Explainer is a privacy-first AI agent that helps you understand legal contracts before you sign them. Paste any contract text — or just a single clause — and the agent delivers a structured four-part analysis in plain English: a summary of what you are committing to, a clause-by-clause explanation with direct quotes from the contract, a risk assessment flagged as HIGH or MEDIUM based on your specific role, and a list of missing protections that could expose you legally. The agent supports seven contract types — employment, lease/rental, NDA, freelance/services, partnership/shareholder, purchase/sale, and general agreements — and eight role perspectives including employee, employer, tenant, landlord, freelancer, and client. Optionally enter your jurisdiction (e.g. Netherlands, EU, US-CA, UK) for more contextually relevant analysis. Under the hood, Python clause detection (running via Pyodide in the browser) identifies which of 13 standard clause categories are present — including termination, IP ownership, non-compete, liability cap, governing law, force majeure, data privacy (GDPR), severance, and non-solicitation — and which are conspicuously absent. Risk levels are mapped to your role: for example, a non-compete clause is flagged HIGH RISK for employees and contractors, while a missing liability clause is flagged for both parties. Because this template uses a fully local WebLLM model (Hermes-2-Pro-Mistral-7B via WebGPU), no contract text is ever sent to OpenAI, Anthropic, or any external server. This makes it suitable for reviewing sensitive employment offers, commercial NDAs, real estate leases, or any document you would not want to upload to a cloud service. The agent runs entirely in the browser after the initial model download.
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.
Template Metadata
- Slug
- contract-plain-language-explainer
- Created By
- ozzo
- Created
- Feb 28, 2026
- Usage Count
- 3
Tags
Code Statistics
- HTML Lines
- 680
- CSS Lines
- 378
- JS Lines
- 325
- Python Lines
- 451
Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contract Plain Language Explainer</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.bubble-user {
background: #0f172a; color: white;
border-radius: 1.25rem 1.25rem 0.25rem 1.25rem;
}
.bubble-assistant {
background: white; color: #1e293b;
border: 1px solid #e2e8f0;
border-radius: 1.25rem 1.25rem 1.25rem 0.25rem;
}
.typing-dot {
width: 7px; height: 7px; background: #94a3b8;
border-radius: 50%;
animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-6px); opacity: 1; }
}
.tab-btn.active { background: white; color: #0f172a; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.tab-btn:not(.active) { background: transparent; color: #64748b; }
#drop-zone.dragover { border-color: #10b981; background: #ecfdf5; }
.risk-high { color: #dc2626; font-weight: 700; }
.risk-medium { color: #d97706; font-weight: 700; }
.risk-low { color: #16a34a; font-weight: 700; }
.suggest-btn {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 9999px; padding: 0.35rem 0.85rem;
font-size: 0.75rem; color: #475569; cursor: pointer;
transition: all 0.15s; white-space: nowrap;
}
.suggest-btn:hover { background: #e2e8f0; color: #0f172a; }
.prose-bubble h2 { font-size: 0.95rem; font-weight: 700; margin: 0.85rem 0 0.3rem; color: #0f172a; }
.prose-bubble h3 { font-size: 0.875rem; font-weight: 600; margin: 0.6rem 0 0.25rem; color: #1e293b; }
.prose-bubble ul { list-style: disc; padding-left: 1.25rem; margin: 0.35rem 0; }
.prose-bubble ol { list-style: decimal; padding-left: 1.25rem; margin: 0.35rem 0; }
.prose-bubble li { margin: 0.2rem 0; }
.prose-bubble strong { font-weight: 700; }
.prose-bubble p { margin: 0.35rem 0; }
.prose-bubble blockquote {
border-left: 3px solid #e2e8f0; margin: 0.5rem 0;
padding: 0.25rem 0.75rem; color: #64748b; font-style: italic;
}
.prose-bubble hr { border-color: #e2e8f0; margin: 0.75rem 0; }
.prose-bubble em { font-style: italic; color: #475569; }
#chat-messages::-webkit-scrollbar { width: 4px; }
#chat-messages::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; }
#chat-input { resize: none; min-height: 44px; max-height: 140px; overflow-y: auto; }
select, input[type="text"] {
appearance: none;
background-color: white;
}
</style>
</head>
<body class="bg-slate-100 h-screen overflow-hidden flex flex-col">
<!-- ── Header ── -->
<header class="bg-white border-b border-slate-200 px-4 py-3 flex items-center gap-3 flex-shrink-0">
<div class="w-9 h-9 bg-emerald-100 rounded-xl flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h1 class="text-base font-semibold text-slate-900 leading-tight">Contract Plain Language Explainer</h1>
<p class="text-xs text-slate-400 truncate">Upload or paste a contract, then ask anything about it</p>
</div>
<button id="reset-btn" onclick="resetAll()"
class="hidden text-xs text-slate-400 hover:text-red-500 flex items-center gap-1 transition-colors flex-shrink-0">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
New contract
</button>
</header>
<!-- ── Contract loaded bar ── -->
<div id="contract-loaded-bar"
class="hidden bg-emerald-600 text-white text-xs px-4 py-1.5 flex items-center gap-2 flex-shrink-0">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"/>
</svg>
<span id="contract-loaded-label">Contract loaded</span>
<span class="ml-auto opacity-70">Ask anything below ↓</span>
</div>
<!-- ── Main layout ── -->
<div class="flex-1 flex flex-col md:flex-row overflow-hidden max-w-6xl w-full mx-auto min-h-0">
<!-- LEFT: Input Panel -->
<div id="input-panel"
class="w-full md:w-96 flex-shrink-0 bg-white border-b md:border-b-0 md:border-r border-slate-200 flex flex-col overflow-y-auto">
<div class="p-4 space-y-5">
<!-- ── Section: About This Contract ── -->
<div>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">About This Contract</p>
<!-- Contract Type -->
<div class="mb-3">
<label class="block text-xs font-medium text-slate-600 mb-1">Contract Type</label>
<div class="relative">
<select id="contract-type"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-800
focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent pr-8">
<option value="employment">Employment</option>
<option value="lease">Lease / Rental</option>
<option value="NDA">NDA</option>
<option value="freelance">Freelance / Service</option>
<option value="purchase">Purchase Agreement</option>
<option value="partnership">Partnership</option>
<option value="SaaS">SaaS / Subscription</option>
<option value="other">Other</option>
</select>
<svg class="w-4 h-4 text-slate-400 absolute right-2.5 top-2.5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- My Role -->
<div class="mb-3">
<label class="block text-xs font-medium text-slate-600 mb-1">My Role in This Contract</label>
<div class="relative">
<select id="your-role"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-800
focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent pr-8">
<option value="employee">Employee</option>
<option value="employer">Employer</option>
<option value="tenant">Tenant</option>
<option value="landlord">Landlord</option>
<option value="contractor">Contractor / Freelancer</option>
<option value="client">Client</option>
<option value="signer">General Signer</option>
<option value="founder">Founder / Director</option>
<option value="buyer">Buyer</option>
<option value="seller">Seller</option>
</select>
<svg class="w-4 h-4 text-slate-400 absolute right-2.5 top-2.5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Jurisdiction -->
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">
Jurisdiction
<span class="text-slate-400 font-normal">(optional)</span>
</label>
<input id="jurisdiction" type="text"
placeholder="e.g. Netherlands, UK, US-CA"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-800
focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent
placeholder-slate-400" />
</div>
</div>
<!-- ── Section: Load Contract ── -->
<div>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Load Contract</p>
<!-- Tab Toggle -->
<div class="flex gap-1 mb-4 bg-slate-100 rounded-xl p-1">
<button id="tab-pdf" onclick="switchTab('pdf')"
class="tab-btn active flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
Upload PDF
</button>
<button id="tab-paste" onclick="switchTab('paste')"
class="tab-btn flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Paste Text
</button>
</div>
<!-- PDF Panel -->
<div id="panel-pdf">
<div id="drop-zone"
class="border-2 border-dashed border-slate-300 rounded-xl p-8 text-center cursor-pointer hover:border-emerald-400 hover:bg-emerald-50 transition-all"
onclick="document.getElementById('pdf-file-input').click()"
ondragover="handleDragOver(event)" ondragleave="handleDragLeave()" ondrop="handleDrop(event)">
<input type="file" id="pdf-file-input" accept=".pdf" class="hidden" onchange="handlePdfUpload(event)" />
<div id="drop-default">
<svg class="w-10 h-10 mx-auto text-slate-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
<p class="text-slate-500 text-sm font-medium">Drop PDF or <span class="text-emerald-600 underline">browse</span></p>
<p class="text-slate-400 text-xs mt-1">Max 20MB</p>
</div>
<div id="drop-active" class="hidden">
<svg class="w-10 h-10 mx-auto text-emerald-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<p class="text-emerald-600 font-medium text-sm">Release to upload</p>
</div>
</div>
<div id="pdf-status-row" class="hidden mt-3 flex items-center gap-2 p-2.5 bg-slate-50 rounded-lg">
<svg class="w-6 h-6 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 1.5L18.5 9H13V3.5z"/>
</svg>
<div class="flex-1 min-w-0">
<p id="pdf-filename" class="text-xs font-medium text-slate-800 truncate"></p>
<p id="pdf-extract-status" class="text-xs text-slate-400 mt-0.5"></p>
</div>
<button onclick="clearPdf()" class="text-slate-300 hover:text-red-400 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Paste Panel -->
<div id="panel-paste" class="hidden">
<textarea id="contract-text"
placeholder="Paste your contract text here..."
class="w-full h-44 p-3 border border-slate-300 rounded-xl text-xs text-slate-800 resize-y
focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent
placeholder-slate-400 leading-relaxed"></textarea>
<div class="flex justify-between items-center mt-1">
<span id="char-count" class="text-xs text-slate-400">0 characters</span>
<button onclick="clearText()" class="text-xs text-slate-400 hover:text-red-500 transition-colors">Clear</button>
</div>
</div>
<!-- Load Button -->
<button id="load-btn" onclick="loadContract()"
class="mt-4 w-full py-2.5 bg-slate-900 hover:bg-slate-700 disabled:bg-slate-300 disabled:cursor-not-allowed
text-white text-sm font-semibold rounded-xl transition-colors flex items-center justify-center gap-2">
<svg id="load-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
<svg id="load-spinner" class="hidden w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span id="load-btn-text">Load & Analyse Contract</span>
</button>
<div id="status-banner" class="hidden mt-3 px-3 py-2 rounded-lg text-xs font-medium"></div>
</div>
</div>
</div>
<!-- RIGHT: Chat Panel -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-slate-50">
<!-- Messages -->
<div id="chat-messages" class="flex-1 overflow-y-auto p-4 space-y-4">
<div id="welcome-msg" class="flex items-start gap-3">
<div class="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="bubble-assistant px-4 py-3 max-w-lg shadow-sm prose-bubble text-sm">
<p class="font-semibold text-slate-800 mb-1">Hello! I'm your contract expert.</p>
<p class="text-slate-600">On the left, fill in your role and contract details, then upload a PDF or paste the contract text. I'll give you a full plain-language breakdown — then you can ask me anything.</p>
<div class="mt-3 space-y-1 text-slate-500">
<p class="text-xs font-medium text-slate-400 uppercase tracking-wide">Example questions</p>
<p>💬 "Are there any red flags I should know about?"</p>
<p>💬 "What happens if I want to terminate early?"</p>
<p>💬 "Explain the liability clause in simple terms"</p>
<p>💬 "What are my obligations under this contract?"</p>
</div>
</div>
</div>
</div>
<!-- Suggested questions -->
<div id="suggestions-row"
class="hidden px-4 py-2 flex gap-2 overflow-x-auto flex-shrink-0 border-t border-slate-200 bg-white">
<button class="suggest-btn" onclick="sendSuggestion('Give me a full plain-language summary of this contract')">📋 Full summary</button>
<button class="suggest-btn" onclick="sendSuggestion('What are my key obligations?')">✅ My obligations</button>
<button class="suggest-btn" onclick="sendSuggestion('Are there any red flags or unusual terms I should be aware of?')">🚩 Red flags</button>
<button class="suggest-btn" onclick="sendSuggestion('What are the key dates and deadlines?')">📅 Key dates</button>
<button class="suggest-btn" onclick="sendSuggestion('What happens if I want to terminate this contract early?')">🚪 Termination</button>
<button class="suggest-btn" onclick="sendSuggestion('What are the payment terms?')">💰 Payment terms</button>
<button class="suggest-btn" onclick="sendSuggestion('What questions should I ask my lawyer before signing?')">⚖️ Lawyer questions</button>
</div>
<!-- Chat input -->
<div class="flex-shrink-0 border-t border-slate-200 bg-white p-3">
<div class="flex items-end gap-2 bg-slate-50 border border-slate-300 rounded-2xl px-4 py-2
focus-within:ring-2 focus-within:ring-emerald-500 focus-within:border-transparent transition-all">
<textarea
id="chat-input"
placeholder="Load a contract to start asking questions…"
rows="1"
disabled
class="flex-1 bg-transparent text-sm text-slate-800 placeholder-slate-400 focus:outline-none
leading-relaxed py-1 disabled:cursor-not-allowed"
onkeydown="handleKeyDown(event)"
oninput="autoResize(this)"
></textarea>
<button id="send-btn" onclick="sendMessage()" disabled
class="w-8 h-8 bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed
rounded-full flex items-center justify-center flex-shrink-0 transition-colors self-end mb-0.5">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</div>
<p class="text-center text-xs text-slate-400 mt-2">
⚠️ Not legal advice. Always consult a qualified lawyer before signing any contract.
</p>
</div>
</div>
</div>
<script>
let currentTab = 'pdf';
let extractedPdfText = '';
let contractLoaded = false;
let isProcessing = false;
// ── Tabs ──────────────────────────────────────────────────────────────────
function switchTab(tab) {
currentTab = tab;
document.getElementById('panel-pdf').classList.toggle('hidden', tab !== 'pdf');
document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste');
document.getElementById('tab-pdf').classList.toggle('active', tab === 'pdf');
document.getElementById('tab-paste').classList.toggle('active', tab === 'paste');
}
// ── Drag & Drop ───────────────────────────────────────────────────────────
function handleDragOver(e) {
e.preventDefault();
document.getElementById('drop-zone').classList.add('dragover');
document.getElementById('drop-default').classList.add('hidden');
document.getElementById('drop-active').classList.remove('hidden');
}
function handleDragLeave() {
document.getElementById('drop-zone').classList.remove('dragover');
document.getElementById('drop-default').classList.remove('hidden');
document.getElementById('drop-active').classList.add('hidden');
}
function handleDrop(e) {
e.preventDefault(); handleDragLeave();
const file = e.dataTransfer.files[0];
if (file && file.type === 'application/pdf') processPdfFile(file);
else showBanner('⚠️ Please drop a valid PDF file.', 'warning');
}
function handlePdfUpload(e) {
const file = e.target.files[0];
if (file) processPdfFile(file);
}
// ── PDF Processing ────────────────────────────────────────────────────────
async function processPdfFile(file) {
if (file.size > 20 * 1024 * 1024) { showBanner('❌ File too large (max 20MB).', 'error'); return; }
document.getElementById('pdf-status-row').classList.remove('hidden');
document.getElementById('pdf-filename').textContent = file.name;
document.getElementById('pdf-extract-status').textContent = '⏳ Extracting text…';
extractedPdfText = '';
try {
const uint8Array = new Uint8Array(await file.arrayBuffer());
const pyodide = window.pyodide;
if (!pyodide) throw new Error('Pyodide not ready yet — please wait a moment.');
pyodide.globals.set('_pdf_bytes_raw', uint8Array);
const result = await pyodide.runPythonAsync(`
import js as _js
_pdf_bytes_clean = bytes(_pdf_bytes_raw.to_py())
await extract_pdf_text(_pdf_bytes_clean)
`);
extractedPdfText = result;
if (result.startsWith('❌') || result.startsWith('⚠️')) {
document.getElementById('pdf-extract-status').textContent = result;
showBanner(result, 'error');
} else {
const words = result.split(/\s+/).filter(Boolean).length;
document.getElementById('pdf-extract-status').textContent = `✅ ${words.toLocaleString()} words extracted`;
showBanner('✅ PDF ready — click "Load & Analyse Contract" to continue.', 'success');
}
} catch (err) {
document.getElementById('pdf-extract-status').textContent = `❌ ${err.message}`;
showBanner(`❌ PDF extraction failed: ${err.message}`, 'error');
}
}
function clearPdf() {
extractedPdfText = '';
document.getElementById('pdf-file-input').value = '';
document.getElementById('pdf-status-row').classList.add('hidden');
document.getElementById('drop-default').classList.remove('hidden');
document.getElementById('drop-active').classList.add('hidden');
}
// ── Paste ─────────────────────────────────────────────────────────────────
function clearText() {
const t = document.getElementById('contract-text');
if (t) { t.value = ''; document.getElementById('char-count').textContent = '0 characters'; }
}
document.addEventListener('DOMContentLoaded', () => {
const ta = document.getElementById('contract-text');
if (ta) ta.addEventListener('input', () => {
document.getElementById('char-count').textContent = `${ta.value.length.toLocaleString()} characters`;
});
switchTab('pdf');
});
// ── Load Contract ─────────────────────────────────────────────────────────
async function loadContract() {
let text = '';
if (currentTab === 'pdf') {
text = extractedPdfText;
if (!text) { showBanner('⚠️ Upload and extract a PDF first.', 'warning'); return; }
} else {
text = document.getElementById('contract-text').value.trim();
if (!text) { showBanner('⚠️ Paste your contract text first.', 'warning'); return; }
}
if (text.startsWith('❌') || text.startsWith('⚠️')) {
showBanner('⚠️ PDF extraction had an error. Please try again.', 'warning'); return;
}
if (text.split(/\s+/).filter(Boolean).length < 20) {
showBanner('⚠️ Text seems too short to be a contract.', 'warning'); return;
}
const contractType = document.getElementById('contract-type').value;
const yourRole = document.getElementById('your-role').value;
const jurisdiction = document.getElementById('jurisdiction').value.trim() || 'not specified';
setLoading(true);
try {
const pyodide = window.pyodide;
if (!pyodide) throw new Error('Pyodide not ready.');
pyodide.globals.set('_contract_text', text);
pyodide.globals.set('_contract_type', contractType);
pyodide.globals.set('_your_role', yourRole);
pyodide.globals.set('_jurisdiction', jurisdiction);
await pyodide.runPythonAsync(
`load_contract(_contract_text, _contract_type, _your_role, _jurisdiction)`
);
contractLoaded = true;
enableChat();
// Show loaded bar
const words = text.split(/\s+/).filter(Boolean).length;
document.getElementById('contract-loaded-label').textContent =
`${contractType} contract loaded · ${yourRole} · ${jurisdiction} · ${words.toLocaleString()} words`;
document.getElementById('contract-loaded-bar').classList.remove('hidden');
document.getElementById('reset-btn').classList.remove('hidden');
showBanner('', '');
// Trigger automatic initial analysis
const typingId = addTypingIndicator();
setProcessing(true);
pyodide.globals.set('_init_query', 'INITIAL_ANALYSIS');
const result = await pyodide.runPythonAsync(`await process_user_query(_init_query)`);
removeTypingIndicator(typingId);
addAssistantMessage(result);
} catch (err) {
showBanner(`❌ Failed to load contract: ${err.message}`, 'error');
} finally {
setLoading(false);
setProcessing(false);
}
}
function enableChat() {
document.getElementById('chat-input').disabled = false;
document.getElementById('chat-input').placeholder = 'Ask anything about this contract…';
document.getElementById('send-btn').disabled = false;
document.getElementById('suggestions-row').classList.remove('hidden');
document.getElementById('chat-input').focus();
}
// ── Chat ──────────────────────────────────────────────────────────────────
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
async function sendMessage() {
const input = document.getElementById('chat-input');
const query = input.value.trim();
if (!query || isProcessing || !contractLoaded) return;
input.value = ''; autoResize(input);
addUserMessage(query);
const typingId = addTypingIndicator();
setProcessing(true);
try {
const pyodide = window.pyodide;
if (!pyodide) throw new Error('Pyodide not ready.');
pyodide.globals.set('_chat_query', query);
const result = await pyodide.runPythonAsync(`await process_user_query(_chat_query)`);
removeTypingIndicator(typingId);
addAssistantMessage(result);
} catch (err) {
removeTypingIndicator(typingId);
addAssistantMessage(`❌ Error: ${err.message}`);
} finally {
setProcessing(false);
}
}
function sendSuggestion(text) {
if (!contractLoaded || isProcessing) return;
document.getElementById('chat-input').value = text;
sendMessage();
}
// ── Message Rendering ─────────────────────────────────────────────────────
function addUserMessage(text) {
const div = document.createElement('div');
div.className = 'flex justify-end';
div.innerHTML = `<div class="bubble-user px-4 py-2.5 max-w-sm shadow-sm text-sm leading-relaxed">${escapeHtml(text)}</div>`;
document.getElementById('chat-messages').appendChild(div);
scrollToBottom();
}
function addAssistantMessage(text) {
const div = document.createElement('div');
div.className = 'flex items-start gap-3';
div.innerHTML = `
<div class="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="bubble-assistant px-4 py-3 max-w-xl shadow-sm prose-bubble text-sm leading-relaxed">
${renderMarkdown(text)}
</div>`;
document.getElementById('chat-messages').appendChild(div);
scrollToBottom();
}
function addTypingIndicator() {
const id = 'typing-' + Date.now();
const div = document.createElement('div');
div.id = id; div.className = 'flex items-start gap-3';
div.innerHTML = `
<div class="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="bubble-assistant px-4 py-3 shadow-sm flex items-center gap-1.5">
<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>
</div>`;
document.getElementById('chat-messages').appendChild(div);
scrollToBottom();
return id;
}
function removeTypingIndicator(id) {
const el = document.getElementById(id); if (el) el.remove();
}
// ── Markdown renderer ─────────────────────────────────────────────────────
function renderMarkdown(text) {
let html = escapeHtml(text);
// Risk labels with colour
html = html.replace(/\[HIGH RISK\]/g, '<span class="risk-high">[HIGH RISK]</span>');
html = html.replace(/\[MEDIUM RISK\]/g, '<span class="risk-medium">[MEDIUM RISK]</span>');
html = html.replace(/\[LOW RISK\]/g, '<span class="risk-low">[LOW RISK]</span>');
// Headers
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
// Bold / italic
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
// Blockquote
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
// HR
html = html.replace(/^---$/gm, '<hr>');
// Lists
html = html.replace(/^(\d+)\. (.+)$/gm, '<li data-n="$1">$2</li>');
html = html.replace(/^[-•*] (.+)$/gm, '<li>$2</li>');
html = html.replace(/(<li[^>]*>[\s\S]*?<\/li>\n?)+/g, m =>
m.includes('data-n=') ? `<ol>${m}</ol>` : `<ul>${m}</ul>`
);
// Paragraphs
html = html.replace(/\n\n+/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
return `<p>${html}</p>`;
}
function escapeHtml(t) {
return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// ── Utilities ─────────────────────────────────────────────────────────────
function scrollToBottom() {
const c = document.getElementById('chat-messages'); c.scrollTop = c.scrollHeight;
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
function setProcessing(state) {
isProcessing = state;
document.getElementById('send-btn').disabled = state || !contractLoaded;
document.getElementById('chat-input').disabled = state || !contractLoaded;
if (!state && contractLoaded) document.getElementById('chat-input').focus();
}
function setLoading(state) {
document.getElementById('load-btn').disabled = state;
document.getElementById('load-icon').classList.toggle('hidden', state);
document.getElementById('load-spinner').classList.toggle('hidden', !state);
document.getElementById('load-btn-text').textContent = state ? 'Analysing…' : 'Load & Analyse Contract';
}
function showBanner(message, type) {
const b = document.getElementById('status-banner');
if (!message) { b.classList.add('hidden'); return; }
const colors = {
success: 'bg-emerald-50 text-emerald-800 border border-emerald-200',
error: 'bg-red-50 text-red-800 border border-red-200',
warning: 'bg-amber-50 text-amber-800 border border-amber-200',
};
b.className = `mt-3 px-3 py-2 rounded-lg text-xs font-medium ${colors[type] || colors.warning}`;
b.textContent = message;
b.classList.remove('hidden');
if (type === 'success') setTimeout(() => b.classList.add('hidden'), 5000);
}
function resetAll() {
contractLoaded = false; extractedPdfText = '';
clearPdf(); clearText();
document.getElementById('chat-input').disabled = true;
document.getElementById('chat-input').placeholder = 'Load a contract to start asking questions…';
document.getElementById('send-btn').disabled = true;
document.getElementById('suggestions-row').classList.add('hidden');
document.getElementById('contract-loaded-bar').classList.add('hidden');
document.getElementById('reset-btn').classList.add('hidden');
document.getElementById('chat-messages').innerHTML = '';
addAssistantMessage('Contract cleared. Fill in the details on the left and load a new contract to start fresh.');
try {
window.pyodide && window.pyodide.runPython(`
conversation_history.clear()
contract_context['text'] = ''
contract_context['loaded'] = False
`);
} catch(e) {}
}
</script>
</body>
</html>
/* ═══════════════════════════════════════════
AgentOp Design System — Contract Explainer
Matches data-analysis-agent style exactly
═══════════════════════════════════════════ */
:root {
--primary: #059669;
--primary-hover: #047857;
--secondary: #0ea5e9;
--surface-bg: #f8fafc;
--surface-border: #e2e8f0;
--text-heading: #1e293b;
--text-body: #475569;
--text-muted: #64748b;
--radius-lg: 12px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f0f9ff 0%, #f8fafc 100%);
color: var(--text-body);
line-height: 1.6;
}
/* ── Layout ── */
.dashboard {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
grid-template-columns: 320px 1fr;
gap: 1.5rem;
min-height: calc(100vh - 140px);
align-items: start;
}
.sidebar {
background: white;
border-radius: var(--radius-lg);
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
position: sticky;
top: 160px;
}
.main-content {
background: white;
border-radius: var(--radius-lg);
padding: 2rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
/* ── Agent header ── */
.agent-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.agent-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
}
/* ── Privacy badge ── */
.privacy-badge {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
color: #16a34a;
text-align: center;
margin-bottom: 1.25rem;
}
/* ── Form elements ── */
.form-group { margin-bottom: 0.9rem; }
.form-label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-heading);
margin-bottom: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.form-select,
.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border);
border-radius: 8px;
font-family: inherit;
font-size: 0.875rem;
background: white;
color: var(--text-body);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
appearance: none;
}
.form-select:focus,
.form-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
}
.query-input {
width: 100%;
min-height: 170px;
padding: 0.75rem;
border: 1px solid var(--surface-border);
border-radius: 8px;
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
color: var(--text-body);
line-height: 1.5;
}
.query-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
}
.char-counter {
font-size: 0.72rem;
color: var(--text-muted);
text-align: right;
margin-top: 0.25rem;
margin-bottom: 0.75rem;
}
/* ── Primary button ── */
.analyze-button {
width: 100%;
background: var(--primary);
color: white;
padding: 0.75rem 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
transition: background-color 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: inherit;
}
.analyze-button:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-1px);
}
.analyze-button:active:not(:disabled) { transform: translateY(0); }
.analyze-button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* ── Clear button ── */
.btn-clear {
background: transparent;
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.btn-clear:hover { border-color: #fca5a5; color: #dc2626; background: #fef2f2; }
/* ── Sample queries ── */
.sample-queries { margin-top: 1.25rem; }
.sample-query {
background: var(--surface-bg);
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.4rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
color: var(--text-body);
}
.sample-query:hover {
background: #ecfdf5;
border-color: var(--primary);
color: var(--text-heading);
transform: translateX(2px);
}
/* ── Pyodide status ── */
.status-badge {
margin-top: 1rem;
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
text-align: center;
}
.status-loading {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.status-ready {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
.status-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
/* ── Feature pills ── */
.feature-pill {
background: var(--surface-bg);
border: 1px solid var(--surface-border);
border-radius: 20px;
padding: 0.35rem 0.75rem;
font-size: 0.78rem;
color: var(--text-muted);
}
/* ── Result area ── */
.result-area {
min-height: 520px;
background: var(--surface-bg);
border-radius: 10px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
position: relative;
}
.result-content { line-height: 1.7; }
/* ── Message bubbles (same as data-analysis agent) ── */
.message {
display: flex;
gap: 1rem;
margin-bottom: 1.25rem;
padding: 1rem 1.25rem;
border-radius: 10px;
background: white;
border: 1px solid var(--surface-border);
}
.message-user { background: #eff6ff; border-color: #bfdbfe; }
.message-assistant { background: #f0fdf4; border-color: #bbf7d0; }
.message-error { background: #fef2f2; border-color: #fecaca; }
.message-system { background: #fefce8; border-color: #fde047; }
.message-icon { font-size: 1.4rem; flex-shrink: 0; line-height: 1.4; }
.message-content { flex: 1; overflow-wrap: break-word; }
.message-content p { margin: 0 0 0.6rem 0; }
.message-content p:last-child { margin-bottom: 0; }
.message-content h2,
.message-content h3 {
color: var(--text-heading);
margin: 1.1rem 0 0.4rem 0;
font-size: 1rem;
}
.message-content h2:first-child,
.message-content h3:first-child { margin-top: 0; }
.message-content ul {
margin: 0.4rem 0 0.6rem 0;
padding-left: 1.4rem;
}
.message-content li { margin-bottom: 0.25rem; }
/* ── Risk badges ── */
.risk-high {
display: inline-block;
background: #fef2f2; color: #dc2626;
border: 1px solid #fca5a5;
border-radius: 4px; padding: 0.05rem 0.45rem;
font-size: 0.78rem; font-weight: 700;
vertical-align: middle;
}
.risk-med {
display: inline-block;
background: #fffbeb; color: #d97706;
border: 1px solid #fcd34d;
border-radius: 4px; padding: 0.05rem 0.45rem;
font-size: 0.78rem; font-weight: 700;
vertical-align: middle;
}
.risk-low {
display: inline-block;
background: #f0fdf4; color: #16a34a;
border: 1px solid #86efac;
border-radius: 4px; padding: 0.05rem 0.45rem;
font-size: 0.78rem; font-weight: 700;
vertical-align: middle;
}
/* ── Loading indicator ── */
.loading-indicator {
display: none;
position: absolute;
bottom: 1.25rem;
left: 50%;
transform: translateX(-50%);
background: white;
border: 1px solid var(--surface-border);
border-radius: 8px;
padding: 0.7rem 1.2rem;
gap: 0.75rem;
align-items: center;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
font-size: 0.875rem;
color: var(--text-muted);
white-space: nowrap;
z-index: 10;
}
.loading-indicator.show { display: flex; }
.loading-spinner {
width: 18px; height: 18px;
border: 2px solid var(--surface-border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Disclaimer footer ── */
.disclaimer-bar {
margin-top: 1.25rem;
padding: 0.75rem 1rem;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 0.8rem;
color: #78350f;
line-height: 1.5;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.dashboard { grid-template-columns: 1fr; }
.sidebar { position: static; }
}
/* ═══════════════════════════════════════════
Contract Plain-Language Explainer — JS
═══════════════════════════════════════════ */
const CONTRACT_SAMPLES = {
'non-compete': `The Employee agrees that during the term of employment and for a period of twenty-four (24) months following the termination of employment for any reason, the Employee shall not, directly or indirectly, engage in, own, manage, operate, control, or be employed by any business that competes with the Company within the European Union or any country in which the Company operates or has operated in the preceding 12 months.`,
'termination': `Either party may terminate this Agreement upon thirty (30) days' written notice. The Company may terminate this Agreement immediately and without notice in the event of: (i) material breach of this Agreement by the Employee; (ii) gross misconduct or dishonesty; (iii) criminal conviction; (iv) unauthorized disclosure of confidential information; or (v) insolvency. Upon termination, the Employee shall return all Company property and data within five (5) business days.`,
'ip-ownership': `The Employee agrees that all inventions, improvements, discoveries, developments, software, code, designs, and any other work product created or conceived by the Employee during the course of employment — whether or not created during working hours or using Company equipment — shall be the exclusive property of the Company. The Employee hereby irrevocably assigns all such rights to the Company and waives any and all moral rights therein.`,
'liability': `In no event shall either party be liable to the other for any indirect, incidental, special, consequential, or punitive damages, including without limitation loss of profits, revenue, business opportunity, or data, even if such party has been advised of the possibility of such damages. Each party's total cumulative liability arising out of or related to this Agreement shall not exceed the total fees paid under this Agreement in the three (3) calendar months immediately preceding the date of the claim.`
};
/* ── Track whether this is the first analysis turn ── */
let _isFirstTurn = true;
/* ── Pyodide readiness polling ── */
(function pollPyodideStatus() {
const badge = document.getElementById('pyodide-status');
const analyzeBtn = document.getElementById('analyzeBtn');
let attempts = 0;
const check = setInterval(() => {
attempts++;
if (window.pyodide) {
clearInterval(check);
if (badge) {
badge.textContent = '✅ Ready — analysis will run locally';
badge.className = 'status-badge status-ready';
}
if (analyzeBtn) analyzeBtn.disabled = false;
} else if (attempts > 150) {
clearInterval(check);
if (badge) {
badge.textContent = '❌ Failed to load Python environment';
badge.className = 'status-badge status-error';
}
}
}, 400);
})();
/* ── Character counter ── */
document.addEventListener('DOMContentLoaded', function () {
const textarea = document.getElementById('contract-input');
const counter = document.getElementById('char-counter');
if (textarea && counter) {
textarea.addEventListener('input', function () {
const n = textarea.value.length;
counter.textContent = n.toLocaleString() + ' characters';
counter.style.color = n > 8000 ? '#d97706' : 'var(--text-muted)';
});
/* Ctrl/Cmd + Enter to submit */
textarea.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
processQuery();
}
});
}
/* Follow-up question input — Enter to submit */
const followupInput = document.getElementById('followup-input');
if (followupInput) {
followupInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendFollowUp();
}
});
}
});
/* ── Fill sample clause ── */
function fillSample(type) {
const textarea = document.getElementById('contract-input');
const counter = document.getElementById('char-counter');
if (!textarea || !CONTRACT_SAMPLES[type]) return;
textarea.value = CONTRACT_SAMPLES[type];
if (counter) counter.textContent = textarea.value.length.toLocaleString() + ' characters';
textarea.focus();
textarea.scrollTop = 0;
}
/* ══════════════════════════════════
MAIN: processQuery — initial analysis
══════════════════════════════════ */
async function processQuery() {
const contractText = (document.getElementById('contract-input')?.value || '').trim();
const contractType = document.getElementById('contract-type')?.value || 'other';
const yourRole = document.getElementById('your-role')?.value || 'signer';
const jurisdiction = (document.getElementById('jurisdiction')?.value || '').trim() || 'not specified';
if (!contractText) {
showToast('⚠️ Please paste some contract text first.');
return;
}
if (!window.pyodide) {
addMessage('error', '⏳ Python environment is still loading. Please wait a moment and try again.');
return;
}
/* Reset turn tracker — this is a fresh contract load */
_isFirstTurn = true;
/* Step 0: load contract into Python context (also clears conversation_history) */
await window.pyodide.runPythonAsync(
`load_contract(${JSON.stringify(contractText)}, ` +
`${JSON.stringify(contractType)}, ` +
`${JSON.stringify(yourRole)}, ` +
`${JSON.stringify(jurisdiction)})`
);
/* Hide empty state */
const emptyState = document.getElementById('empty-state');
if (emptyState) emptyState.style.display = 'none';
/* Show user context card */
addMessage('user',
`📄 **Contract type:** ${contractType} | **Role:** ${yourRole} | **Jurisdiction:** ${jurisdiction}\n\n` +
`*Submitted ${contractText.length.toLocaleString()} characters of contract text.*`
);
/* UI: loading state */
const loadingEl = document.getElementById('loading-indicator');
const loadingTxt = document.getElementById('loading-text');
const analyzeBtn = document.getElementById('analyzeBtn');
const clearBtn = document.getElementById('clearBtn');
if (loadingEl) loadingEl.classList.add('show');
if (loadingTxt) loadingTxt.textContent = 'Analyzing contract...';
if (analyzeBtn) {
analyzeBtn.disabled = true;
analyzeBtn.innerHTML = '<span>⏳</span><span>Analyzing...</span>';
}
try {
/* Step 1: Build first-turn prompt — includes full (truncated) contract */
const llmPrompt = await window.pyodide.runPythonAsync('build_initial_prompt()');
/* Step 2: Lawyer questions — pure Python, zero LLM tokens */
const questions = await window.pyodide.runPythonAsync('get_lawyer_questions()');
/* Step 3: Run LLM */
window.pyodide.globals.set('_query', llmPrompt);
const llmResult = await window.pyodide.runPythonAsync('await process_user_query(_query)');
/* Step 4: Typo fixes + append questions */
const cleaned = llmResult
.replace(/CLAUSES EXPLINED/g, 'CLAUSES EXPLAINED')
.replace(/CLAUSES EXPLANED/g, 'CLAUSES EXPLAINED');
const fullResult = cleaned + '\n\n## ❓ Questions for Your Lawyer\n' + questions;
addMessage('assistant', fullResult);
/* Show follow-up UI if it exists */
const followupSection = document.getElementById('followup-section');
if (followupSection) followupSection.style.display = 'block';
if (clearBtn) clearBtn.style.display = 'inline-flex';
/* Mark first turn as done */
_isFirstTurn = false;
} catch (err) {
console.error('[CONTRACT AGENT ERROR]', err);
addMessage('error', `Analysis failed: ${err.message}`);
} finally {
if (loadingEl) loadingEl.classList.remove('show');
if (analyzeBtn) {
analyzeBtn.disabled = false;
analyzeBtn.innerHTML = '<span>⚖️</span><span>Analyze Contract</span>';
}
}
}
/* ══════════════════════════════════
sendFollowUp — follow-up questions
Uses build_followup_prompt() which
strips the full contract and keeps
only a short snippet + history.
══════════════════════════════════ */
async function sendFollowUp() {
const input = document.getElementById('followup-input');
if (!input) return;
const question = input.value.trim();
if (!question) return;
if (!window.pyodide) {
showToast('⏳ Python environment not ready.');
return;
}
input.value = '';
addMessage('user', question);
const loadingEl = document.getElementById('loading-indicator');
const loadingTxt = document.getElementById('loading-text');
const sendBtn = document.getElementById('followup-send-btn');
if (loadingEl) loadingEl.classList.add('show');
if (loadingTxt) loadingTxt.textContent = 'Thinking...';
if (sendBtn) sendBtn.disabled = true;
try {
/* build_followup_prompt strips the contract body — only snippet + history */
window.pyodide.globals.set('_followup_question', question);
const compactPrompt = await window.pyodide.runPythonAsync(
'build_followup_prompt(_followup_question)'
);
window.pyodide.globals.set('_query', compactPrompt);
const result = await window.pyodide.runPythonAsync('await process_user_query(_query)');
addMessage('assistant', result);
} catch (err) {
console.error('[FOLLOWUP ERROR]', err);
addMessage('error', `Follow-up failed: ${err.message}`);
} finally {
if (loadingEl) loadingEl.classList.remove('show');
if (sendBtn) sendBtn.disabled = false;
if (input) input.focus();
}
}
/* ── Clear results ── */
function clearResults() {
const container = document.getElementById('results-container');
const emptyState = document.getElementById('empty-state');
const clearBtn = document.getElementById('clearBtn');
const followupSection = document.getElementById('followup-section');
if (!container) return;
container.querySelectorAll('.message').forEach(el => el.remove());
if (emptyState) emptyState.style.display = 'block';
if (clearBtn) clearBtn.style.display = 'none';
if (followupSection) followupSection.style.display = 'none';
_isFirstTurn = true;
}
/* ══════════════════════════════════
addMessage — render AI response
══════════════════════════════════ */
function addMessage(type, content) {
const container = document.getElementById('results-container');
if (!container) return;
const icons = { user: '📄', assistant: '⚖️', error: '❌', system: 'ℹ️' };
const msgEl = document.createElement('div');
msgEl.className = `message message-${type}`;
msgEl.innerHTML = `
<div class="message-icon">${icons[type] || '💬'}</div>
<div class="message-content">${renderMarkdown(content)}</div>
`;
container.appendChild(msgEl);
msgEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/* ══════════════════════════════════
renderMarkdown
══════════════════════════════════ */
function renderMarkdown(text) {
let html = escapeHtml(text);
// Risk badges
html = html.replace(/\[HIGH RISK\]/g, '<span class="risk-high">● HIGH RISK</span>');
html = html.replace(/\[MEDIUM RISK\]/g, '<span class="risk-med">● MEDIUM RISK</span>');
html = html.replace(/\[LOW RISK\]/g, '<span class="risk-low">● LOW RISK</span>');
// Headings — use function replacer to avoid $1 dollar-sign collisions
html = html.replace(/^### (.+)$/gm, (_, t) => `<h3>${t}</h3>`);
html = html.replace(/^## (.+)$/gm, (_, t) => `<h2>${t}</h2>`);
html = html.replace(/^# (.+)$/gm, (_, t) => `<h2>${t}</h2>`);
// Bold then italic — function replacer is critical here
html = html.replace(/\*\*(.+?)\*\*/g, (_, t) => `<strong>${t}</strong>`);
html = html.replace(/\*(.+?)\*/g, (_, t) => `<em>${t}</em>`);
html = html.replace(/_(.+?)_/g, (_, t) => `<em>${t}</em>`);
// Blockquote
html = html.replace(/^> (.+)$/gm, (_, t) => `<blockquote>${t}</blockquote>`);
// Horizontal rule
html = html.replace(/^---+$/gm,
'<hr style="border:none;border-top:1px solid var(--surface-border);margin:1rem 0;">');
// Lists — function replacer
html = html.replace(/^\d+\. (.+)$/gm, (_, t) => `<li data-n="1">${t}</li>`);
html = html.replace(/^[-•] (.+)$/gm, (_, t) => `<li>${t}</li>`);
// Wrap list items
html = html.replace(
/(<li data-n="1">[\s\S]*?<\/li>)(\s*<li data-n="1">[\s\S]*?<\/li>)*/g,
m => `<ol>${m.replace(/ data-n="1"/g, '')}</ol>`
);
html = html.replace(
/(<li>[\s\S]*?<\/li>)(\s*<li>[\s\S]*?<\/li>)*/g,
m => `<ul>${m}</ul>`
);
// Paragraphs
html = html.replace(/\n\n+/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
return `<p>${html}</p>`;
}
/* ── HTML escaper ── */
function escapeHtml(t) {
return t
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
/* ── Toast notification ── */
function showToast(msg) {
const t = document.createElement('div');
t.style.cssText = `
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%);
background: #1e293b; color: white; padding: 0.6rem 1.2rem;
border-radius: 8px; font-size: 0.875rem; z-index: 9999;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); white-space: nowrap;
`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
import io
import json
import inspect
import sys
import locale
from typing import get_type_hints
# ── Encoding fixes ────────────────────────────────────────────────────────────
locale.getpreferredencoding = lambda do_set_locale=True: 'UTF-8'
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
try:
import httpx.models as _httpx_models
def _safe_normalize(value, encoding=None):
if isinstance(value, bytes): return value
if value is None: return b''
return str(value).encode(encoding or 'ascii', errors='replace')
_httpx_models.normalize_header_value = _safe_normalize
except Exception:
pass
# ── Provider (injected by AgentHTMLGenerator) ─────────────────────────────────
PROVIDER = globals().get('PROVIDER', 'local')
# ── Contract state ────────────────────────────────────────────────────────────
contract_context = {
'text': '',
'loaded': False,
'contract_type': 'employment',
'your_role': 'employee',
'jurisdiction': 'not specified',
}
conversation_history = []
# ─────────────────────────────────────────────────────────────────────────────
# Schema helpers
# ─────────────────────────────────────────────────────────────────────────────
def python_type_to_json_type(python_type):
type_mapping = {
'str': 'string', 'int': 'integer', 'float': 'number',
'bool': 'boolean', 'list': 'array', 'dict': 'object',
}
type_name = python_type.__name__ if hasattr(python_type, '__name__') else str(python_type)
return type_mapping.get(type_name, 'string')
def extract_function_schema(func):
sig = inspect.signature(func)
try:
hints = get_type_hints(func)
except Exception:
hints = {}
doc = inspect.getdoc(func) or f'Execute {func.__name__}'
description = doc.split('\n')[0]
parameters = {'type': 'object', 'properties': {}, 'required': []}
param_descriptions = {}
if 'Args:' in doc:
args_section = doc.split('Args:')[1].split('Returns:')[0] if 'Returns:' in doc else doc.split('Args:')[1]
for line in args_section.split('\n'):
line = line.strip()
if ':' in line:
param_name = line.split(':')[0].strip()
param_desc = line.split(':', 1)[1].strip()
param_descriptions[param_name] = param_desc
for param_name, param in sig.parameters.items():
if param_name in ('self', 'cls'):
continue
param_type = hints.get(param_name, param.annotation)
if param_type is inspect.Parameter.empty:
param_type = str
json_type = python_type_to_json_type(param_type)
prop_schema = {
'type': json_type,
'description': param_descriptions.get(param_name, f'The {param_name} parameter'),
}
parameters['properties'][param_name] = prop_schema
if param.default is inspect.Parameter.empty:
parameters['required'].append(param_name)
return {'name': func.__name__, 'description': description, 'parameters': parameters}
# ─────────────────────────────────────────────────────────────────────────────
# Tool: PDF extraction
# ─────────────────────────────────────────────────────────────────────────────
async def extract_pdf_text(pdf_bytes: bytes) -> str:
"""
Extract all text from a PDF file given its raw bytes.
Args:
pdf_bytes: Raw bytes of the PDF file
Returns:
Extracted plain text from all pages
"""
try:
import micropip
await micropip.install('pypdf')
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(pdf_bytes))
pages_text = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text and text.strip():
pages_text.append(f'--- Page {i+1} ---\n{text.strip()}')
if not pages_text:
return '⚠️ No readable text found in this PDF. It may be a scanned/image-based PDF.'
return '\n\n'.join(pages_text)
except Exception as e:
return f'❌ Error extracting PDF text: {str(e)}'
# ─────────────────────────────────────────────────────────────────────────────
# Tool: load_contract
# ─────────────────────────────────────────────────────────────────────────────
def load_contract(text: str, contract_type: str, your_role: str, jurisdiction: str) -> str:
"""
Load contract text and metadata into the analysis context.
Args:
text: Full contract text
contract_type: Type of contract e.g. employment, lease, NDA, freelance
your_role: User role e.g. employee, tenant, contractor, client, signer
jurisdiction: Legal jurisdiction e.g. Netherlands, UK, US-CA
Returns:
Confirmation message with word count
"""
contract_context['text'] = text.strip()
contract_context['loaded'] = True
contract_context['contract_type'] = contract_type or 'employment'
contract_context['your_role'] = your_role or 'employee'
contract_context['jurisdiction'] = jurisdiction or 'not specified'
conversation_history.clear()
word_count = len(text.split())
return f'✅ Contract loaded ({word_count:,} words). Ready for analysis.'
# ─────────────────────────────────────────────────────────────────────────────
# Context window budget
#
# Hermes-3-Llama-3.1-8B has a 4096-token context window.
# Budget breakdown (tokens):
# ~400 system prompt
# ~100 metadata header (type / role / jurisdiction)
# ~200 overhead (role tags, formatting)
# ~800 assistant response headroom
# ────
# ~1500 remaining for contract text → ~6000 chars (≈4 chars/token)
#
# For follow-up turns the contract is NOT re-sent; only a short summary
# (~300 chars) is prepended so the model remembers what it read.
# ─────────────────────────────────────────────────────────────────────────────
_LOCAL_MAX_CHARS = 6000 # first-turn full contract cap
_LOCAL_SUMMARY_CHARS = 300 # follow-up: short reminder snippet
_HISTORY_ASSISTANT_CAP = 600 # max chars kept per assistant turn in history
_MAX_HISTORY_TURNS = 2 # how many past user/assistant pairs to include
def _truncate_for_local(text: str, max_chars: int = _LOCAL_MAX_CHARS) -> str:
"""Hard-cap contract text and append an omission notice."""
if len(text) <= max_chars:
return text
cutoff = text.rfind('\n', 0, max_chars)
if cutoff == -1:
cutoff = max_chars
omitted = len(text) - cutoff
return (
text[:cutoff]
+ f'\n\n[... {omitted:,} characters omitted to fit local model context window ...]'
)
def _contract_summary_snippet() -> str:
"""Return a short reminder of the contract for follow-up turns."""
text = contract_context['text']
snippet = text[:_LOCAL_SUMMARY_CHARS].strip()
if len(text) > _LOCAL_SUMMARY_CHARS:
snippet += ' [...]'
return snippet
def _build_history_block() -> str:
"""
Build a compact conversation history string.
Keeps only the last _MAX_HISTORY_TURNS pairs.
Truncates long assistant replies to _HISTORY_ASSISTANT_CAP chars.
"""
if not conversation_history:
return ''
# Keep last N pairs (each pair = 1 user + 1 assistant message)
pairs = []
history = conversation_history[:]
while history and len(pairs) < _MAX_HISTORY_TURNS:
# Walk backwards looking for assistant+user pairs
if len(history) >= 2 and history[-1]['role'] == 'assistant' and history[-2]['role'] == 'user':
pairs.insert(0, (history[-2], history[-1]))
history = history[:-2]
else:
history = history[:-1]
lines = []
for user_msg, asst_msg in pairs:
user_content = user_msg['content']
# Strip the full contract block from history — keep only the question part
if 'CONTRACT TEXT:' in user_content:
user_content = user_content.split('CONTRACT TEXT:')[0].strip()
asst_content = asst_msg['content']
if len(asst_content) > _HISTORY_ASSISTANT_CAP:
asst_content = asst_content[:_HISTORY_ASSISTANT_CAP] + ' [...]'
lines.append(f'User: {user_content}')
lines.append(f'Assistant: {asst_content}')
return '\n'.join(lines)
# ─────────────────────────────────────────────────────────────────────────────
# Prompt builders
# ─────────────────────────────────────────────────────────────────────────────
def build_initial_prompt() -> str:
"""
Build the structured first user message from loaded contract context.
Truncates contract text automatically for local models.
Returns:
Formatted prompt string ready to pass to process_user_query
"""
is_local = globals().get('PROVIDER', 'local') == 'local'
body = (
_truncate_for_local(contract_context['text'])
if is_local
else contract_context['text']
)
return (
f"Contract type: {contract_context['contract_type']}\n"
f"My role in this contract: {contract_context['your_role']}\n"
f"Jurisdiction: {contract_context['jurisdiction']}\n\n"
f"CONTRACT TEXT:\n{body}"
)
def build_followup_prompt(question: str) -> str:
"""
Build a follow-up question prompt WITHOUT re-sending the full contract.
Includes a short contract snippet + compact history so the model has context.
Args:
question: The follow-up question from the user
Returns:
Compact prompt string safe for a 4096-token context window
"""
snippet = _contract_summary_snippet()
history = _build_history_block()
c_type = contract_context['contract_type']
role = contract_context['your_role']
jur = contract_context['jurisdiction']
parts = [
f"Contract type: {c_type} | Role: {role} | Jurisdiction: {jur}",
f"Contract snippet for reference:\n{snippet}",
]
if history:
parts.append(f"Previous exchanges:\n{history}")
parts.append(f"Follow-up question: {question}")
return '\n\n'.join(parts)
# ─────────────────────────────────────────────────────────────────────────────
# Lawyer questions — pure logic, no LLM call
# ─────────────────────────────────────────────────────────────────────────────
def get_lawyer_questions() -> str:
"""
Generate relevant lawyer questions based on contract type and role.
Returns:
Numbered list of questions as a plain string
"""
c_type = contract_context.get('contract_type', 'employment')
jur = contract_context.get('jurisdiction', 'not specified')
type_questions = {
'employment': [
'Is the non-compete or non-solicitation clause enforceable and proportionate to my role?',
'What are my rights regarding intellectual property I create outside of work hours?',
'What severance or notice period am I entitled to if terminated without cause?',
],
'lease': [
'What are my rights if the landlord wants to enter the property or sell it?',
'Are there hidden costs or maintenance obligations not immediately obvious?',
'What are the exact conditions under which I can be evicted?',
],
'nda': [
'Is the definition of "confidential information" too broad to be practical?',
'How long does the confidentiality obligation last after the agreement ends?',
'Are there exceptions to the confidentiality obligation I should know about?',
],
'freelance': [
'Who owns the intellectual property I create under this contract?',
'What is the payment schedule and what happens if payment is delayed?',
'Under what conditions can either party terminate the engagement early?',
],
'saas': [
'What data do you collect and how is it stored or shared?',
'What happens to my data if I cancel the subscription?',
'Are there automatic price increases or renewal lock-ins?',
],
'purchase': [
'What warranties or guarantees are included, and for how long?',
'What are the return, refund, or dispute resolution procedures?',
'Are there any ongoing obligations or fees after the initial purchase?',
],
}
base_questions = [
f'Are there any clauses unenforceable under {jur} law?',
'What is the dispute resolution process if something goes wrong?',
'Are there automatic renewal or lock-in clauses I should be aware of?',
'What happens to my rights if this contract is assigned to a third party?',
]
specific = type_questions.get(c_type, [
'What are my core obligations under this contract?',
'What happens if either party fails to meet their obligations?',
'Are there any unusual or one-sided clauses I should negotiate?',
])
all_questions = specific + base_questions
return '\n'.join(f'{i+1}. {q}' for i, q in enumerate(all_questions))
# ─────────────────────────────────────────────────────────────────────────────
# Main query processor
# ─────────────────────────────────────────────────────────────────────────────
async def process_user_query(query: str) -> str:
"""
Process a user question about the loaded contract.
Routes to local WebLLM or cloud provider (OpenAI / Anthropic).
First turn receives the full contract; follow-up turns get a compact prompt.
Args:
query: The prompt string built by build_initial_prompt() or build_followup_prompt()
Returns:
AI response as a plain string
"""
provider = globals().get('PROVIDER', 'local')
if not contract_context['loaded']:
return (
'⚠️ No contract loaded yet. '
'Please upload a PDF or paste contract text and click **Load Contract** first.'
)
conversation_history.append({'role': 'user', 'content': query})
# ── Local WebLLM ──────────────────────────────────────────────────────────
if provider == 'local':
try:
system_prompt = globals().get('TEMPLATE_SYSTEM_PROMPT', '')
result = await process_user_query_webllm(query, system_prompt)
conversation_history.append({'role': 'assistant', 'content': result})
return result
except Exception as e:
import traceback
traceback.print_exc()
conversation_history.pop()
return f'❌ Error using WebLLM: {str(e)}'
# ── Cloud providers ───────────────────────────────────────────────────────
try:
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
api_key = globals().get('current_api_key', '') or globals().get('api_key', '')
if not api_key:
conversation_history.pop()
return '⚠️ Please enter an API key to use this agent.'
if provider == 'openai':
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model='gpt-4o-mini', api_key=api_key, temperature=0.3)
elif provider == 'anthropic':
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model='claude-3-5-sonnet-20241022', api_key=api_key, temperature=0.3)
else:
conversation_history.pop()
return f'❌ Unknown provider: {provider}'
system_prompt = globals().get('TEMPLATE_SYSTEM_PROMPT', '')
few_shot_examples = globals().get('AGENT_FEW_SHOT_EXAMPLES', [])
messages = [SystemMessage(content=system_prompt)]
for ex in few_shot_examples:
if ex.get('role') == 'user':
messages.append(HumanMessage(content=ex['content']))
else:
messages.append(AIMessage(content=ex['content']))
# For cloud: include full history — no token budget concern
for msg in conversation_history:
if msg['role'] == 'user':
messages.append(HumanMessage(content=msg['content']))
else:
messages.append(AIMessage(content=msg['content']))
response = llm.invoke(messages)
assistant_reply = response.content
conversation_history.append({'role': 'assistant', 'content': assistant_reply})
return assistant_reply
except Exception as e:
import traceback
traceback.print_exc()
conversation_history.pop()
return f'❌ Error: {str(e)}'
# ─────────────────────────────────────────────────────────────────────────────
# Tool schema export
# ─────────────────────────────────────────────────────────────────────────────
def get_tool_schemas() -> str:
"""
Export all tool function schemas in OpenAI format.
Called by PyodideToolBridge in JS to discover available tools.
Returns:
JSON string with OpenAI-format tool schemas
"""
tool_functions = [load_contract]
schemas = []
for func in tool_functions:
try:
function_schema = extract_function_schema(func)
openai_schema = {'type': 'function', 'function': function_schema}
schemas.append(openai_schema)
except Exception as e:
print(f'Warning: Failed to extract schema for {func.__name__}: {e}')
return json.dumps(schemas, indent=2)
print(f'✅ Contract Plain Language Explainer initialized (provider: {PROVIDER})')