Confidential Meeting Notes Summarizer
The Confidential Meeting Notes Summarizer is a two-stage, privacy-first AI agent that processes meeting notes or transcripts entirely inside the browser — no data ever leaves your device. What makes it technically unique is the anonymization pipeline that runs before the language model ever sees your text. In stage one, a local BERT-based Named Entity Recognition (NER) model — onnx-community/bert-base-NER-ONNX running via Transformers.js — scans your raw notes and detects persons (PER), organisations (ORG), and locations (LOC) with confidence scores above 0.85. Every detected entity is replaced with a neutral label like [PER-1], [ORG-2], or [LOC-3] before the text is passed anywhere else. An anonymization report shows you exactly what was replaced, what it was replaced with, and the entity type — so the process is fully transparent and auditable. In stage two, four Python tools running via Pyodide perform deterministic pre-analysis on the anonymized text: action item extraction (patterns like "Action:", "TODO:", "will", "needs to", "assigned to"), decision detection ("decided", "agreed", "approved", "we will"), topic scoring across eight categories (budget, timeline, technical, product, HR, sales, strategy, risks), and meeting metadata extraction (word count, estimated duration, transcript vs. bullet format, participant count). These structured results are passed as a context block to a fully local WebLLM language model (Hermes-2-Pro-Mistral-7B via WebGPU), which generates a four-section prose output: Summary, Decisions, Key Discussions, and Tone & Dynamics. Because the entire pipeline — BERT NER, Python analysis, and LLM inference — runs locally in the browser, this template is suitable for legal proceedings, HR interviews, board meetings, client calls, medical consultations, or any meeting where confidentiality is non-negotiable. No API key required. Works completely offline after 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
Confidential Meeting Notes Summarizer 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
- confidential-meeting-notes-summarizer
- Created By
- ozzo
- Created
- Feb 28, 2026
- Usage Count
- 0
Tags
Code Statistics
- HTML Lines
- 163
- CSS Lines
- 200
- JS Lines
- 373
- Python Lines
- 144
Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Confidential Meeting Notes Summarizer</title>
<!-- Pyodide CDN (required by init_orchestrator) -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.29.0/full/pyodide.js"></script>
<!-- Transformers.js NER — load early in background -->
<script type="module">
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@xenova/[email protected]';
window.nerPipelineReady = false;
window.nerPipeline = null;
async function loadNERPipeline() {
try {
console.log('[NER] Loading onnx-community/bert-base-NER-ONNX...');
window.nerPipeline = await pipeline(
'token-classification',
'onnx-community/bert-base-NER-ONNX',
{ aggregation_strategy: 'simple' }
);
window.nerPipelineReady = true;
console.log('[NER] ✅ NER pipeline ready');
} catch (e) {
console.warn('[NER] NER unavailable:', e.message);
}
}
loadNERPipeline();
window.loadNERPipeline = loadNERPipeline;
</script>
<style>{{ css_code }}</style>
</head>
<body>
<div id="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-content">
<div class="header-left">
<span class="header-icon">📝</span>
<div>
<h1>Confidential Meeting Notes Summarizer</h1>
<p class="header-sub">Privacy-first summarization · automatic anonymization · stays in browser</p>
</div>
</div>
<div class="header-badges">
<span class="badge badge-local">🔒 Local Only</span>
<span class="badge badge-anon">👤 Auto-Anonymize</span>
</div>
</div>
</header>
<!-- Status Bar -->
<div class="status-bar">
<span id="st-python" class="st-item st-loading">⏳ Python loading...</span>
<span id="st-ner" class="st-item st-loading">⏳ NER model loading...</span>
<span id="st-llm" class="st-item st-waiting">⚪ WebLLM: select model above</span>
</div>
<!-- Main layout -->
<main class="layout">
<!-- LEFT: Input -->
<div class="panel">
<h2 class="panel-title">Meeting Details</h2>
<div class="row2">
<div class="fg">
<label for="inp-title">Meeting Title</label>
<input id="inp-title" type="text" placeholder="Q1 Planning" value="Team Meeting">
</div>
<div class="fg">
<label for="inp-date">Date</label>
<input id="inp-date" type="date">
</div>
</div>
<div class="fg">
<label for="inp-participants">Participants <span class="hint">(comma-separated, optional)</span></label>
<input id="inp-participants" type="text" placeholder="Alice, Bob, Carol">
</div>
<div class="fg toggle-row">
<label class="toggle-label">
<input id="chk-anon" type="checkbox" checked>
<span>🛡️ Anonymize names, organisations & locations before summarizing</span>
</label>
</div>
<div class="fg">
<label for="inp-notes">
Meeting Notes / Transcript
<span class="hint">paste raw notes, bullets, or transcript</span>
</label>
<textarea id="inp-notes" rows="16"
placeholder="Paste your meeting notes here...
Example:
Alice opened the meeting. Bob from Acme Corp presented the Q1 budget.
Action: Carol to send the revised contract to John by Friday.
Decision: We will move the launch to March 15.
Todo: Alice to schedule a follow-up with the Berlin office."></textarea>
<div class="char-count"><span id="char-ct">0</span> characters</div>
</div>
<button id="btn-analyze" class="btn-primary" disabled>
<span id="btn-icon">🔍</span>
<span id="btn-lbl">Analyze Meeting Notes</span>
</button>
<p id="btn-hint" class="btn-hint">⏳ Waiting for Python runtime...</p>
</div>
<!-- RIGHT: Output -->
<div class="panel" id="panel-output">
<div id="output-placeholder" class="placeholder">
<div class="ph-icon">📋</div>
<p>Summary will appear here after analysis</p>
<ul class="ph-list">
<li>📝 Plain-language meeting summary</li>
<li>✅ Decision log</li>
<li>🎯 Detected action items</li>
<li>👤 Anonymization report</li>
</ul>
</div>
<div id="analyzing" class="analyzing" style="display:none">
<div class="spinner"></div>
<div>
<p id="step-lbl">Running analysis...</p>
<p class="step-sub">Everything stays in your browser</p>
</div>
</div>
<div id="output-results" style="display:none">
<div id="anon-report" class="anon-report" style="display:none">
<div class="anon-header">
<span>🛡️ Anonymization Report</span>
<button class="anon-btn" onclick="window.toggleAnonReport()">Hide</button>
</div>
<div id="anon-body" class="anon-body"></div>
</div>
<div id="llm-out" class="llm-out"></div>
<div id="action-sect" class="det-section" style="display:none">
<div class="det-title">ACTION ITEMS</div>
<ul id="action-list" class="action-list"></ul>
</div>
<div id="meta-bar" class="meta-bar"></div>
</div>
</div>
</main>
</div>
<!-- Python tools (loaded by Pyodide via init_orchestrator) -->
<script type="text/python" id="python-code">{{ python_code }}</script>
<!-- Agent interaction logic -->
<script>{{ js_code }}</script>
</body>
</html>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
padding-bottom: 48px;
}
/* Header */
.app-header {
background: linear-gradient(135deg, #1e293b, #0f172a);
border-bottom: 1px solid #334155;
padding: 14px 24px;
position: sticky;
top: 60px;
z-index: 10;
}
.header-content {
max-width: 1400px; margin: 0 auto;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 10px;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-icon { font-size: 28px; }
.header-left h1 { font-size: 1.25rem; font-weight: 700; color: #f1f5f9; }
.header-sub { font-size: 0.75rem; color: #64748b; margin-top: 2px; }
.header-badges { display: flex; gap: 8px; flex-wrap: wrap; }
.badge { padding: 4px 10px; border-radius: 20px; font-size: 0.72rem; font-weight: 600; }
.badge-local { background: #1e3a5f; color: #60a5fa; border: 1px solid #3b82f6; }
.badge-anon { background: #14532d; color: #4ade80; border: 1px solid #22c55e; }
/* Status bar */
.status-bar {
max-width: 1400px; margin: 0 auto;
display: flex; gap: 14px; padding: 7px 24px;
font-size: 0.74rem; flex-wrap: wrap;
}
.st-item { padding: 2px 8px; border-radius: 4px; }
.st-loading { background: #292524; color: #fbbf24; }
.st-ready { background: #14532d; color: #4ade80; }
.st-waiting { background: #1e293b; color: #64748b; }
.st-error { background: #450a0a; color: #f87171; }
/* Layout */
.layout {
max-width: 1400px; margin: 16px auto 0; padding: 0 24px;
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
}
@media (max-width: 880px) { .layout { grid-template-columns: 1fr; } }
/* Panels */
.panel {
background: #1e293b; border: 1px solid #334155;
border-radius: 12px; padding: 20px;
}
.panel-title {
font-size: 1rem; font-weight: 700; color: #f1f5f9;
margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid #334155;
}
/* Form */
.row2 { display: flex; gap: 12px; margin-bottom: 14px; }
.row2 .fg { flex: 1; margin-bottom: 0; }
.fg { margin-bottom: 14px; }
.fg label {
display: block; font-size: 0.79rem; color: #94a3b8;
margin-bottom: 5px; font-weight: 500;
}
.hint { font-size: 0.7rem; color: #64748b; margin-left: 6px; font-weight: 400; }
.fg input[type="text"], .fg input[type="date"], .fg textarea {
width: 100%; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #e2e8f0; padding: 8px 12px;
font-size: 0.84rem; font-family: inherit; transition: border-color 0.2s;
}
.fg input:focus, .fg textarea:focus {
outline: none; border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59,130,246,0.12);
}
.fg textarea { resize: vertical; min-height: 220px; line-height: 1.65; }
.char-count { text-align: right; font-size: 0.7rem; color: #475569; margin-top: 3px; }
.toggle-row { margin-bottom: 16px; }
.toggle-label {
display: flex; align-items: center; gap: 8px;
cursor: pointer; font-size: 0.82rem; color: #cbd5e1;
}
.toggle-label input[type="checkbox"] {
width: 15px; height: 15px; accent-color: #3b82f6; cursor: pointer;
}
/* Button */
.btn-primary {
width: 100%; padding: 11px; border: none; border-radius: 8px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white; font-size: 0.93rem; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: all 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #3b82f6, #2563eb);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59,130,246,0.28);
}
.btn-primary:disabled { background: #1e293b; color: #475569; cursor: not-allowed; }
.btn-hint { text-align: center; font-size: 0.73rem; color: #64748b; margin-top: 6px; }
/* Placeholder */
.placeholder {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 380px;
text-align: center; gap: 10px; color: #475569;
}
.ph-icon { font-size: 44px; margin-bottom: 4px; }
.ph-list { list-style: none; text-align: left; margin-top: 6px; }
.ph-list li { padding: 3px 0; font-size: 0.8rem; color: #475569; }
/* Analyzing */
.analyzing {
display: flex; align-items: center; justify-content: center;
gap: 16px; min-height: 200px; color: #94a3b8;
}
.spinner {
width: 34px; height: 34px; border: 3px solid #334155;
border-top-color: #3b82f6; border-radius: 50%;
animation: spin 0.8s linear infinite; flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
#step-lbl { font-size: 0.88rem; color: #e2e8f0; margin-bottom: 4px; }
.step-sub { font-size: 0.73rem; color: #475569; }
/* Anonymization report */
.anon-report {
background: #0d2818; border: 1px solid #166534;
border-radius: 8px; margin-bottom: 14px; overflow: hidden;
}
.anon-header {
display: flex; align-items: center; justify-content: space-between;
padding: 9px 14px; background: #14532d;
font-size: 0.8rem; font-weight: 600; color: #4ade80;
}
.anon-btn {
background: transparent; border: 1px solid #22c55e;
color: #4ade80; font-size: 0.7rem; padding: 2px 8px;
border-radius: 4px; cursor: pointer;
}
.anon-body { padding: 10px 14px; font-size: 0.76rem; color: #86efac; line-height: 1.6; }
.anon-grid {
display: grid; grid-template-columns: 1fr 1fr 80px;
gap: 3px 10px; font-size: 0.73rem;
}
.anon-gh {
color: #4ade80; font-weight: 700;
border-bottom: 1px solid #166534; padding-bottom: 4px; margin-bottom: 3px;
}
.anon-orig { color: #fcd34d; }
.anon-repl { color: #60a5fa; }
.anon-type { color: #94a3b8; }
/* LLM prose output */
.llm-out {
background: #0f172a; border: 1px solid #334155;
border-radius: 8px; padding: 16px; margin-bottom: 14px;
font-size: 0.87rem; line-height: 1.8; color: #e2e8f0;
}
.sec-title {
font-size: 0.68rem; font-weight: 700; color: #3b82f6;
letter-spacing: 0.1em; margin-top: 14px; margin-bottom: 6px;
padding-bottom: 4px; border-bottom: 1px solid #1e3a5f;
}
.sec-title:first-child { margin-top: 0; }
.sec-body { color: #cbd5e1; font-size: 0.85rem; line-height: 1.75; }
/* Deterministic action items section */
.det-section {
background: #0f172a; border: 1px solid #292524;
border-radius: 8px; padding: 12px 14px; margin-bottom: 14px;
}
.det-title {
font-size: 0.68rem; font-weight: 700; color: #f59e0b;
letter-spacing: 0.1em; margin-bottom: 8px;
padding-bottom: 5px; border-bottom: 1px solid #292524;
}
.action-list { list-style: none; display: flex; flex-direction: column; gap: 5px; }
.action-item {
padding: 6px 10px; background: #1e293b; border-radius: 6px;
border-left: 3px solid #f59e0b; font-size: 0.82rem;
color: #e2e8f0; line-height: 1.5;
}
/* Metadata bar */
.meta-bar {
display: flex; gap: 8px; flex-wrap: wrap;
padding: 8px 0 0; border-top: 1px solid #1e293b;
}
.meta-chip {
background: #1e293b; border-radius: 12px;
padding: 3px 10px; font-size: 0.73rem; color: #94a3b8;
}
/* ============================================================
Confidential Meeting Notes Summarizer — Agent JS
============================================================ */
(function () {
'use strict';
// State
let pyodideReady = false;
let nerReady = false;
// DOM refs
const btnAnalyze = document.getElementById('btn-analyze');
const btnLbl = document.getElementById('btn-lbl');
const btnIcon = document.getElementById('btn-icon');
const btnHint = document.getElementById('btn-hint');
const inpNotes = document.getElementById('inp-notes');
const charCt = document.getElementById('char-ct');
const analyzing = document.getElementById('analyzing');
const stepLbl = document.getElementById('step-lbl');
const placeholder= document.getElementById('output-placeholder');
const results = document.getElementById('output-results');
const llmOut = document.getElementById('llm-out');
const anonReport = document.getElementById('anon-report');
const anonBody = document.getElementById('anon-body');
const actionSect = document.getElementById('action-sect');
const actionList = document.getElementById('action-list');
const metaBar = document.getElementById('meta-bar');
const stPython = document.getElementById('st-python');
const stNer = document.getElementById('st-ner');
const stLlm = document.getElementById('st-llm');
// Character counter
inpNotes.addEventListener('input', () => {
charCt.textContent = inpNotes.value.length.toLocaleString();
});
// Readiness polling
function pollPyodide() {
if (window.pyodide && window.agentManager) {
pyodideReady = true;
stPython.textContent = '✅ Python ready';
stPython.className = 'st-item st-ready';
updateBtn();
} else {
setTimeout(pollPyodide, 600);
}
}
function pollNer() {
if (window.nerPipelineReady) {
nerReady = true;
stNer.textContent = '✅ NER model ready';
stNer.className = 'st-item st-ready';
updateBtn();
} else {
setTimeout(pollNer, 1200);
}
}
function pollLLM() {
if (window.agentManager && window.agentManager.agent) {
stLlm.textContent = '✅ WebLLM ready';
stLlm.className = 'st-item st-ready';
} else {
setTimeout(pollLLM, 1500);
}
}
function updateBtn() {
if (!pyodideReady) return;
btnAnalyze.disabled = false;
btnHint.textContent = nerReady
? '🛡️ NER anonymization active — ready to analyze'
: '⚠️ NER still loading — will skip anonymization';
}
// NER anonymization
async function anonymize(text) {
if (!document.getElementById('chk-anon').checked) {
return { text: text, map: {}, count: 0 };
}
if (!window.nerPipelineReady || !window.nerPipeline) {
console.warn('[NER] Not ready — skipping');
return { text: text, map: {}, count: 0 };
}
try {
const entities = await window.nerPipeline(text);
const counters = {};
const map = {};
const replacements = [];
for (const e of entities) {
if ((e.score || 0) < 0.85) continue;
const word = (e.word || '').replace(/^##/, '').trim();
if (word.length < 2 || map[word]) continue;
const typeRaw = e.entity_group || e.entity || 'MISC';
const type = typeRaw.replace(/^[BI]-/, '');
if (!['PER', 'ORG', 'LOC', 'MISC'].includes(type)) continue;
counters[type] = (counters[type] || 0) + 1;
const label = '[' + type + '-' + counters[type] + ']';
map[word] = { label: label, type: type };
replacements.push({ word: word, label: label });
}
// Longest match first — prevents partial replacements
replacements.sort(function(a, b) { return b.word.length - a.word.length; });
let anonText = text;
for (var i = 0; i < replacements.length; i++) {
var r = replacements[i];
var esc = r.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
anonText = anonText.replace(new RegExp(esc, 'g'), r.label);
}
return { text: anonText, map: map, count: Object.keys(map).length };
} catch (err) {
console.error('[NER] Error:', err);
return { text: text, map: {}, count: 0 };
}
}
// Python tool calls via Pyodide
async function pyCall(fn, args) {
var code = [
'import json as _j',
'_args = _j.loads(' + JSON.stringify(JSON.stringify(args)) + ')',
'_j.dumps(' + fn + '(**_args))'
].join('\n');
try {
var raw = await window.pyodide.runPythonAsync(code);
return JSON.parse(raw);
} catch (err) {
console.error('[Python]', fn, 'failed:', err);
return null;
}
}
// Build pre-computed analysis block
function buildBlock(meta, topics, decisions, actions) {
var lines = ['=== PRE-COMPUTED ANALYSIS — do NOT copy or reprint this block ==='];
if (meta) {
lines.push('MEETING FORMAT: ' + meta.format);
lines.push('ESTIMATED DURATION: ' + meta.estimated_duration);
lines.push('WORD COUNT: ' + meta.word_count);
lines.push('QUESTIONS DETECTED: ' + meta.question_count);
if (meta.participant_count > 0) {
lines.push('PARTICIPANT COUNT: ' + meta.participant_count);
}
}
if (topics && topics.top_topics && topics.top_topics.length > 0) {
lines.push('TOP TOPICS (by frequency):');
for (var i = 0; i < topics.top_topics.length; i++) {
lines.push(' - ' + topics.top_topics[i].topic + ': ' + topics.top_topics[i].mentions + ' mentions');
}
lines.push('DOMINANT TOPIC: ' + topics.dominant_topic);
}
if (decisions && decisions.count > 0) {
lines.push('DECISIONS DETECTED: ' + decisions.count);
for (var j = 0; j < decisions.decisions.length; j++) {
lines.push(' - ' + decisions.decisions[j]);
}
} else {
lines.push('DECISIONS DETECTED: 0');
}
if (actions && actions.count > 0) {
lines.push('ACTION ITEMS DETECTED: ' + actions.count);
}
lines.push('=== END PRE-COMPUTED ANALYSIS ===');
return lines.join('\n');
}
// Post-process LLM output
function postProcess(raw) {
return raw
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/TONE\s*[&+]\s*DYNAMICS/gi, 'TONE AND DYNAMICS')
.replace(/KEY\s*DISCUSSION\b(?!S)/gi, 'KEY DISCUSSIONS')
.trim();
}
// Render LLM prose into styled sections
function renderLLM(text) {
llmOut.innerHTML = '';
var sectionRe = /^(SUMMARY|DECISIONS|KEY DISCUSSIONS|TONE AND DYNAMICS)\s*$/m;
var parts = text.split(sectionRe);
if (parts.length <= 1) {
var div = document.createElement('div');
div.className = 'sec-body';
div.textContent = text;
llmOut.appendChild(div);
return;
}
for (var i = 1; i < parts.length; i += 2) {
var h = document.createElement('div');
h.className = 'sec-title';
h.textContent = parts[i];
llmOut.appendChild(h);
var b = document.createElement('div');
b.className = 'sec-body';
b.textContent = (parts[i + 1] || '').trim();
llmOut.appendChild(b);
}
}
// Render anonymization report
function renderAnonReport(map) {
if (!map || Object.keys(map).length === 0) {
anonReport.style.display = 'none';
return;
}
var typeLabel = { PER: 'Person', ORG: 'Organisation', LOC: 'Location', MISC: 'Other' };
var html = '<div class="anon-grid">'
+ '<span class="anon-gh">Original</span>'
+ '<span class="anon-gh">Replaced with</span>'
+ '<span class="anon-gh">Type</span>';
for (var orig in map) {
if (!Object.prototype.hasOwnProperty.call(map, orig)) continue;
var info = map[orig];
html += '<span class="anon-orig">' + esc(orig) + '</span>'
+ '<span class="anon-repl">' + esc(info.label) + '</span>'
+ '<span class="anon-type">' + (typeLabel[info.type] || info.type) + '</span>';
}
html += '</div><p style="margin-top:8px;color:#475569;font-size:0.7rem;">'
+ '⚠️ Original text was never sent to the LLM — only the anonymized version was used.</p>';
anonBody.innerHTML = html;
anonReport.style.display = 'block';
}
// Render action items (Python-deterministic — LLM never touches this)
function renderActions(actions) {
if (!actions || actions.count === 0) { actionSect.style.display = 'none'; return; }
actionList.innerHTML = '';
for (var i = 0; i < actions.action_items.length; i++) {
var item = actions.action_items[i];
var li = document.createElement('li');
li.className = 'action-item';
li.textContent = '→ ' + item.charAt(0).toUpperCase() + item.slice(1);
actionList.appendChild(li);
}
actionSect.style.display = 'block';
}
// Render metadata chips
function renderMeta(meta, topics, anonCount) {
if (!meta) { metaBar.innerHTML = ''; return; }
var chips = [
'📋 ' + (meta.format === 'transcript' ? 'Transcript' : 'Bullet notes'),
'⏱️ ~' + meta.estimated_duration,
'📝 ' + meta.word_count + ' words'
];
if (meta.participant_count > 0) chips.push('👥 ' + meta.participant_count + ' participants');
if (topics && topics.dominant_topic) chips.push('🎯 ' + topics.dominant_topic);
if (anonCount > 0) chips.push('🛡️ ' + anonCount + ' entities anonymized');
metaBar.innerHTML = chips.map(function(c) {
return '<span class="meta-chip">' + esc(c) + '</span>';
}).join('');
}
function esc(s) {
return String(s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
// Toggle anonymization report visibility
window.toggleAnonReport = function () {
var body = document.getElementById('anon-body');
var btn = document.querySelector('.anon-btn');
var hidden = body.style.display === 'none';
body.style.display = hidden ? 'block' : 'none';
btn.textContent = hidden ? 'Hide' : 'Show';
};
// Main analyze function
async function analyze() {
var notes = inpNotes.value.trim();
if (!notes) { alert('Please paste your meeting notes first.'); return; }
if (!window.pyodide) { alert('Python is still loading. Please wait.'); return; }
if (!window.agentManager || !window.agentManager.agent) {
alert('Please load a WebLLM model first using the selector above.');
return;
}
// Loading state
btnAnalyze.disabled = true;
btnLbl.textContent = 'Analyzing...';
btnIcon.textContent = '⏳';
placeholder.style.display = 'none';
results.style.display = 'none';
analyzing.style.display = 'flex';
var title = document.getElementById('inp-title').value || 'Team Meeting';
var date = document.getElementById('inp-date').value || '';
var people = document.getElementById('inp-participants').value || '';
try {
// Step 1 — NER anonymization (JS Transformers.js)
setStep('🛡️ Running NER anonymization...');
var anonResult = await anonymize(notes);
var anonNotes = anonResult.text;
var entityMap = anonResult.map;
var anonCount = anonResult.count;
// Step 2 — Python deterministic tools (on anonymized text)
setStep('🎯 Extracting action items...');
var actions = await pyCall('extract_action_items', { notes: anonNotes });
setStep('📊 Scoring topics...');
var topics = await pyCall('score_topics', { notes: anonNotes });
setStep('✅ Detecting decisions...');
var decisions = await pyCall('generate_decision_checklist', { notes: anonNotes });
setStep('📋 Reading metadata...');
var meta = await pyCall('extract_meeting_metadata', {
notes: anonNotes,
participants: people,
meeting_date: date
});
// Step 3 — Build full prompt with pre-computed block
setStep('📝 Building prompt...');
var block = buildBlock(meta, topics, decisions, actions);
var lines = [
'Meeting title: ' + title,
date ? 'Date: ' + date : '',
people ? 'Participants: ' + people : '',
'',
block,
'',
'MEETING NOTES (anonymized):',
anonNotes.slice(0, 4000),
'',
'Write a plain-language summary using only SUMMARY, DECISIONS, KEY DISCUSSIONS, TONE AND DYNAMICS.'
].filter(Boolean);
var prompt = lines.join('\n');
// Step 4 — Local LLM call
setStep('🤖 Running local LLM (Qwen2.5 7B)...');
var raw = await window.processWebLLMQuery(
prompt,
window.TEMPLATE_SYSTEM_PROMPT || ''
);
// Step 5 — Post-process and render
setStep('✨ Formatting output...');
var clean = postProcess(raw);
analyzing.style.display = 'none';
results.style.display = 'block';
renderAnonReport(entityMap);
renderLLM(clean);
renderActions(actions); // deterministic — never from LLM
renderMeta(meta, topics, anonCount);
} catch (err) {
console.error('[Analyze]', err);
analyzing.style.display = 'none';
results.style.display = 'block';
llmOut.textContent = '❌ Error: ' + err.message;
}
btnAnalyze.disabled = false;
btnLbl.textContent = 'Analyze Meeting Notes';
btnIcon.textContent = '🔍';
}
function setStep(msg) { stepLbl.textContent = msg; console.log('[Step]', msg); }
btnAnalyze.addEventListener('click', analyze);
document.addEventListener('DOMContentLoaded', function () {
document.getElementById('inp-date').value = new Date().toISOString().split('T')[0];
pollPyodide();
pollNer();
pollLLM();
});
}());
import re
import json
def extract_action_items(notes: str) -> str:
"""
Extract action items and follow-ups from meeting notes using pattern matching.
Args:
notes: Raw meeting notes text
Returns:
JSON string with list of action items found
"""
action_items = []
patterns = [
r"action[:\s]+(.+?)(?:\n|$)",
r"todo[:\s]+(.+?)(?:\n|$)",
r"to[- ]do[:\s]+(.+?)(?:\n|$)",
r"follow[- ]up[:\s]+(.+?)(?:\n|$)",
r"\bwill\s+(.{5,80})(?:\n|$)",
r"needs?\s+to\s+(.{5,80})(?:\n|$)",
r"\[\s*action\s*\]\s*(.+?)(?:\n|$)",
r"\[\s*todo\s*\]\s*(.+?)(?:\n|$)",
r"assigned\s+to[:\s]+(.+?)(?:\n|$)",
]
for pattern in patterns:
for match in re.findall(pattern, notes, re.IGNORECASE):
cleaned = match.strip().rstrip(".,;:")
if 8 <= len(cleaned) <= 200:
low = cleaned.lower()
if not any(low == x.lower() for x in action_items):
action_items.append(cleaned)
return json.dumps({
"action_items": action_items[:15],
"count": len(action_items)
})
def score_topics(notes: str) -> str:
"""
Score and rank main discussion topics by keyword frequency.
Args:
notes: Raw meeting notes text
Returns:
JSON string with ranked topic categories
"""
notes_lower = notes.lower()
topic_keywords = {
"budget and finance": ["budget", "cost", "spend", "revenue", "profit", "invoice", "payment", "financ", "money", "fund"],
"timeline and deadlines": ["deadline", "timeline", "schedule", "milestone", "sprint", "release", "launch", "due date"],
"technical": ["bug", "feature", "code", "deploy", "server", "api", "database", "system", "software", "engineering"],
"people and hr": ["hire", "headcount", "onboard", "performance", "review", "promotion", "role", "responsibility"],
"product": ["product", "roadmap", "backlog", "customer", "feedback", "design", "ux", "ui"],
"sales and marketing": ["sales", "marketing", "campaign", "lead", "prospect", "client", "deal", "pipeline"],
"strategy": ["strategy", "goal", "objective", "okr", "kpi", "vision", "mission", "plan", "initiative", "priority"],
"risks and issues": ["risk", "issue", "problem", "blocker", "concern", "challenge", "obstacle", "critical", "urgent"],
}
scores = {}
for topic, keywords in topic_keywords.items():
score = sum(notes_lower.count(kw) for kw in keywords)
if score > 0:
scores[topic] = score
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return json.dumps({
"top_topics": [{"topic": t, "mentions": s} for t, s in ranked[:5]],
"dominant_topic": ranked[0][0] if ranked else "general discussion"
})
def generate_decision_checklist(notes: str) -> str:
"""
Extract explicit decisions from meeting notes using pattern matching.
Args:
notes: Raw meeting notes text
Returns:
JSON string with decisions found
"""
decisions = []
patterns = [
r"decided[:\s]+(.+?)(?:\n|$)",
r"agreed[:\s]+(.+?)(?:\n|$)",
r"decision[:\s]+(.+?)(?:\n|$)",
r"approved[:\s]+(.+?)(?:\n|$)",
r"confirmed[:\s]+(.+?)(?:\n|$)",
r"we will\s+(.{5,80})(?:\n|$)",
r"we are going to\s+(.{5,80})(?:\n|$)",
r"\[\s*decision\s*\]\s*(.+?)(?:\n|$)",
r"resolved[:\s]+(.+?)(?:\n|$)",
r"voted to\s+(.{5,80})(?:\n|$)",
]
for pattern in patterns:
for match in re.findall(pattern, notes, re.IGNORECASE):
cleaned = match.strip().rstrip(".,;:")
if 8 <= len(cleaned) <= 200:
low = cleaned.lower()
if not any(low == x.lower() for x in decisions):
decisions.append(cleaned)
return json.dumps({
"decisions": decisions[:10],
"count": len(decisions)
})
def extract_meeting_metadata(notes: str, participants: str = "", meeting_date: str = "") -> str:
"""
Extract metadata: word count, estimated duration, format, participant count.
Args:
notes: Raw meeting notes text
participants: Comma-separated participant list
meeting_date: Meeting date string
Returns:
JSON string with meeting metadata
"""
word_count = len(notes.split())
question_count = notes.count("?")
bullet_count = len(re.findall(r"^\s*[-*\u2022]\s", notes, re.MULTILINE))
is_transcript = word_count > 200 and bullet_count < max(1, word_count // 60)
if word_count < 300:
estimated_duration = "under 30 minutes"
elif word_count < 800:
estimated_duration = "30-60 minutes"
elif word_count < 2000:
estimated_duration = "60-90 minutes"
else:
estimated_duration = "90+ minutes"
participant_list = [p.strip() for p in participants.split(",") if p.strip()] if participants else []
return json.dumps({
"word_count": word_count,
"question_count": question_count,
"estimated_duration": estimated_duration,
"participant_count": len(participant_list),
"participants": participant_list,
"format": "transcript" if is_transcript else "bullet notes"
})