Preview: Confidential Meeting Notes Summarizer
Paste raw meeting notes or a transcript and get a plain-language summary, decision log, and action item checklist. Names, organisations, and locations are automatically anonymised using a local NER model before the LLM ever sees them. Everything stays in your browser.
Preview Mode
This is a preview with sample data. The template uses placeholders like
which will be replaced with actual agent data.
Template Preview
Template Metadata
- Slug
- confidential-meeting-notes-summarizer
- Created By
- ozzo
- Created
- Feb 28, 2026
- Usage Count
- 0
Tags
privacy
meeting
notes
summarizer
anonymization
NER
workplace
confidential
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"
})