feat(spark-analytics): add per-user NVIDIA NIM AI explanations to webview
Each dataset page now has an "Explain this dataset" button that calls the NVIDIA NIM chat completions API (meta/llama-3.3-70b-instruct) directly from the browser with headers + first 10 rows as context. API key is entered via a settings modal and stored in localStorage only — never sent to the server. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ data:
|
||||
#!/usr/bin/env python3
|
||||
"""Spark analytics results viewer — queries PostgreSQL analytics tables, serves HTML tables."""
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import psycopg2
|
||||
@@ -46,9 +47,13 @@ data:
|
||||
CSS = """
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', sans-serif; margin: 0; background: #0d1117; color: #c9d1d9; }
|
||||
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 1rem 2rem; }
|
||||
header h1 { margin: 0; color: #58a6ff; font-size: 1.25rem; font-weight: 600; }
|
||||
header span { color: #8b949e; font-size: 0.875rem; margin-left: 0.5rem; }
|
||||
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 1rem 2rem; display: flex; align-items: center; gap: 1rem; }
|
||||
header h1 { margin: 0; color: #58a6ff; font-size: 1.25rem; font-weight: 600; flex: 1; }
|
||||
header span { color: #8b949e; font-size: 0.875rem; }
|
||||
.settings-btn { background: none; border: 1px solid #30363d; border-radius: 6px; color: #8b949e;
|
||||
cursor: pointer; padding: 4px 8px; font-size: 0.8rem; display: flex; align-items: center; gap: 4px; }
|
||||
.settings-btn:hover { border-color: #58a6ff; color: #58a6ff; }
|
||||
.settings-btn.active { border-color: #3fb950; color: #3fb950; }
|
||||
main { padding: 1.5rem 2rem; }
|
||||
h2 { color: #e6edf3; font-size: 1rem; font-weight: 600; margin: 0 0 1rem; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
|
||||
@@ -71,6 +76,195 @@ data:
|
||||
.table-wrap { overflow-x: auto; border: 1px solid #30363d; border-radius: 6px; }
|
||||
.notice { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||
padding: 1.5rem; color: #8b949e; }
|
||||
/* Settings modal */
|
||||
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
z-index: 100; align-items: center; justify-content: center; }
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px;
|
||||
padding: 1.5rem; width: 420px; max-width: 95vw; }
|
||||
.modal h3 { margin: 0 0 0.25rem; color: #e6edf3; font-size: 0.95rem; }
|
||||
.modal p { margin: 0 0 1rem; color: #8b949e; font-size: 0.8rem; }
|
||||
.modal input { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
|
||||
color: #c9d1d9; font-size: 0.85rem; padding: 8px 10px; margin-bottom: 0.75rem;
|
||||
font-family: monospace; }
|
||||
.modal input:focus { outline: none; border-color: #58a6ff; }
|
||||
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
||||
.btn { border-radius: 6px; padding: 6px 14px; font-size: 0.8rem; cursor: pointer; border: 1px solid transparent; }
|
||||
.btn-primary { background: #238636; color: #fff; border-color: #2ea043; }
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
.btn-danger { background: none; color: #f85149; border-color: #f85149; }
|
||||
.btn-danger:hover { background: rgba(248,81,73,0.1); }
|
||||
.btn-ghost { background: none; color: #8b949e; border-color: #30363d; }
|
||||
.btn-ghost:hover { color: #c9d1d9; border-color: #8b949e; }
|
||||
/* Explain panel */
|
||||
.explain-bar { display: flex; align-items: center; gap: 0.75rem; margin: 1rem 0; }
|
||||
.explain-btn { background: #1f3a5f; border: 1px solid #388bfd; color: #58a6ff; border-radius: 6px;
|
||||
padding: 6px 14px; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 6px; }
|
||||
.explain-btn:hover { background: #263d6a; }
|
||||
.explain-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.no-key-hint { color: #8b949e; font-size: 0.78rem; }
|
||||
.explain-panel { background: #161b22; border: 1px solid #388bfd; border-radius: 8px;
|
||||
padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.explain-panel h4 { margin: 0 0 0.5rem; color: #58a6ff; font-size: 0.85rem; font-weight: 600; }
|
||||
.explain-panel .explain-text { color: #c9d1d9; font-size: 0.85rem; line-height: 1.6; white-space: pre-wrap; }
|
||||
.explain-panel .explain-error { color: #f85149; font-size: 0.82rem; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #388bfd;
|
||||
border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
"""
|
||||
|
||||
JS = """
|
||||
const NIM_KEY_STORAGE = 'nim_api_key';
|
||||
const NIM_ENDPOINT = 'https://integrate.api.nvidia.com/v1/chat/completions';
|
||||
const NIM_MODEL = 'meta/llama-3.3-70b-instruct';
|
||||
|
||||
function getKey() { return localStorage.getItem(NIM_KEY_STORAGE) || ''; }
|
||||
function hasKey() { return !!getKey(); }
|
||||
|
||||
function updateSettingsBtn() {
|
||||
const btn = document.getElementById('settings-btn');
|
||||
if (!btn) return;
|
||||
if (hasKey()) {
|
||||
btn.classList.add('active');
|
||||
btn.title = 'NIM API key set — click to change';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.title = 'Configure NVIDIA NIM API key for AI explanations';
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
document.getElementById('nim-modal').classList.add('open');
|
||||
const inp = document.getElementById('nim-key-input');
|
||||
inp.value = getKey();
|
||||
inp.focus();
|
||||
updateNoKeyHint();
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
document.getElementById('nim-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const val = document.getElementById('nim-key-input').value.trim();
|
||||
if (val) localStorage.setItem(NIM_KEY_STORAGE, val);
|
||||
else localStorage.removeItem(NIM_KEY_STORAGE);
|
||||
closeSettings();
|
||||
updateSettingsBtn();
|
||||
updateNoKeyHint();
|
||||
}
|
||||
|
||||
function clearKey() {
|
||||
localStorage.removeItem(NIM_KEY_STORAGE);
|
||||
document.getElementById('nim-key-input').value = '';
|
||||
updateSettingsBtn();
|
||||
updateNoKeyHint();
|
||||
}
|
||||
|
||||
function updateNoKeyHint() {
|
||||
const hint = document.getElementById('no-key-hint');
|
||||
if (!hint) return;
|
||||
hint.style.display = hasKey() ? 'none' : 'inline';
|
||||
const btn = document.getElementById('explain-btn');
|
||||
if (btn) btn.disabled = !hasKey();
|
||||
}
|
||||
|
||||
async function explainDataset() {
|
||||
const key = getKey();
|
||||
if (!key) { openSettings(); return; }
|
||||
|
||||
const btn = document.getElementById('explain-btn');
|
||||
const panel = document.getElementById('explain-panel');
|
||||
const textEl = document.getElementById('explain-text');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Thinking…';
|
||||
panel.style.display = 'block';
|
||||
textEl.className = 'explain-text';
|
||||
textEl.textContent = '';
|
||||
|
||||
const dataset = window.DATASET;
|
||||
const sampleText = dataset.sample.map((row, i) =>
|
||||
row.map((v, j) => dataset.headers[j] + ': ' + v).join(', ')
|
||||
).join('\\n');
|
||||
|
||||
const systemPrompt = `You are a chess analytics expert. Explain what this dataset shows to a chess player.
|
||||
Be concise but insightful — 3-6 sentences. Focus on what the data reveals about chess patterns,
|
||||
player behaviour, or game dynamics. When column names reference chess openings (ECO codes, opening names),
|
||||
explain what those openings are.`;
|
||||
|
||||
const userPrompt = `Dataset: "${dataset.label}"
|
||||
Total rows: ${dataset.total_rows}
|
||||
Columns: ${dataset.headers.join(', ')}
|
||||
|
||||
Sample data (first ${dataset.sample.length} rows):
|
||||
${sampleText}
|
||||
|
||||
What does this dataset tell us about chess?`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(NIM_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: NIM_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0.4,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.text();
|
||||
throw new Error('NIM API ' + resp.status + ': ' + err);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const text = data.choices?.[0]?.message?.content || '(no response)';
|
||||
textEl.textContent = text;
|
||||
} catch (e) {
|
||||
textEl.className = 'explain-error';
|
||||
textEl.textContent = 'Error: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '✦ Explain this dataset';
|
||||
updateNoKeyHint();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateSettingsBtn();
|
||||
updateNoKeyHint();
|
||||
document.getElementById('nim-modal')?.addEventListener('click', e => {
|
||||
if (e.target === e.currentTarget) closeSettings();
|
||||
});
|
||||
document.getElementById('nim-key-input')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') saveSettings();
|
||||
if (e.key === 'Escape') closeSettings();
|
||||
});
|
||||
});
|
||||
"""
|
||||
|
||||
SETTINGS_MODAL = """
|
||||
<div class="modal-overlay" id="nim-modal">
|
||||
<div class="modal">
|
||||
<h3>NVIDIA NIM API Key</h3>
|
||||
<p>Used client-side only — stored in your browser's localStorage, never sent to this server.</p>
|
||||
<input type="password" id="nim-key-input" placeholder="nvapi-…" autocomplete="off" spellcheck="false" />
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-danger" onclick="clearKey()">Clear</button>
|
||||
<button class="btn btn-ghost" onclick="closeSettings()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_conn():
|
||||
@@ -107,14 +301,20 @@ data:
|
||||
]
|
||||
return headers, data
|
||||
|
||||
def page(title: str, body: str) -> str:
|
||||
def page(title: str, body: str, dataset_json: str = "null") -> str:
|
||||
return (
|
||||
f"<!doctype html><html lang=en><head><meta charset=utf-8>"
|
||||
f"<meta name=viewport content='width=device-width,initial-scale=1'>"
|
||||
f"<title>{html.escape(title)} — Spark Analytics</title>"
|
||||
f"<style>{CSS}</style></head><body>"
|
||||
f"<header><h1>Spark Analytics</h1><span>NowChess · staging</span></header>"
|
||||
f"<main>{body}</main></body></html>"
|
||||
f"<header>"
|
||||
f"<h1>Spark Analytics</h1><span>NowChess · staging</span>"
|
||||
f"<button class='settings-btn' id='settings-btn' onclick='openSettings()' title='Configure NIM API key'>⚙ NIM Key</button>"
|
||||
f"</header>"
|
||||
f"<main>{body}</main>"
|
||||
f"{SETTINGS_MODAL}"
|
||||
f"<script>window.DATASET={dataset_json};\n{JS}</script>"
|
||||
f"</body></html>"
|
||||
)
|
||||
|
||||
def index_html() -> str:
|
||||
@@ -151,6 +351,26 @@ data:
|
||||
f"{back}<h2>{html.escape(label)}</h2>"
|
||||
"<div class='notice'>No data yet. Run the CronJob first, then check back.</div>",
|
||||
)
|
||||
|
||||
sample = rows[:10]
|
||||
dataset_json = json.dumps({
|
||||
"label": label,
|
||||
"headers": headers,
|
||||
"sample": sample,
|
||||
"total_rows": len(rows),
|
||||
})
|
||||
|
||||
explain_bar = (
|
||||
"<div class='explain-bar'>"
|
||||
"<button class='explain-btn' id='explain-btn' onclick='explainDataset()'>✦ Explain this dataset</button>"
|
||||
"<span class='no-key-hint' id='no-key-hint'>— <a href='#' onclick='openSettings();return false;' style='color:#58a6ff'>add NIM key</a> to enable AI explanations</span>"
|
||||
"</div>"
|
||||
"<div class='explain-panel' id='explain-panel' style='display:none'>"
|
||||
"<h4>AI Explanation</h4>"
|
||||
"<div class='explain-text' id='explain-text'></div>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
ths = "".join(f"<th>{html.escape(h)}</th>" for h in headers)
|
||||
trs = "".join(
|
||||
"<tr>" + "".join(f"<td>{html.escape(str(c))}</td>" for c in row) + "</tr>"
|
||||
@@ -161,8 +381,10 @@ data:
|
||||
label,
|
||||
f"{back}<h2>{html.escape(label)}</h2>"
|
||||
f"<p class='meta'>{len(rows)} rows{truncated}</p>"
|
||||
f"{explain_bar}"
|
||||
f"<div class='table-wrap'><table><thead><tr>{ths}</tr></thead>"
|
||||
f"<tbody>{trs}</tbody></table></div>",
|
||||
dataset_json=dataset_json,
|
||||
)
|
||||
|
||||
SLUG_MAP = {s: (label, table) for s, label, table in DATASETS}
|
||||
|
||||
Reference in New Issue
Block a user