AgentOp

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 &amp; 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, '&amp;').replace(/</g, '&lt;')
            .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }

    // 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"
    })