fix(spark-analytics): proxy NIM API calls through server to avoid CORS

Browser cannot call integrate.api.nvidia.com directly — no CORS headers.
Add POST /explain endpoint: browser sends key + dataset, server calls NIM
with urllib, returns JSON. Key is never logged or stored server-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janis Eccarius
2026-06-21 17:36:04 +02:00
parent 60467d8ff6
commit 7f11de365c
+74 -35
View File
@@ -13,6 +13,7 @@ data:
import json import json
import os import os
import re import re
import urllib.request
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -115,8 +116,6 @@ data:
JS = """ JS = """
const NIM_KEY_STORAGE = 'nim_api_key'; 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 getKey() { return localStorage.getItem(NIM_KEY_STORAGE) || ''; }
function hasKey() { return !!getKey(); } function hasKey() { return !!getKey(); }
@@ -184,49 +183,22 @@ data:
textEl.textContent = ''; textEl.textContent = '';
const dataset = window.DATASET; 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 + '"\\n'
+ 'Total rows: ' + dataset.total_rows + '\\n'
+ 'Columns: ' + dataset.headers.join(', ') + '\\n\\n'
+ 'Sample data (first ' + dataset.sample.length + ' rows):\\n'
+ sampleText + '\\n\\n'
+ 'What does this dataset tell us about chess?';
try { try {
const resp = await fetch(NIM_ENDPOINT, { const resp = await fetch('/explain', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Authorization': 'Bearer ' + key, body: JSON.stringify({ key: key, dataset: dataset }),
'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) { if (!resp.ok) {
const err = await resp.text(); const err = await resp.text();
throw new Error('NIM API ' + resp.status + ': ' + err); throw new Error('Proxy ' + resp.status + ': ' + err);
} }
const data = await resp.json(); const data = await resp.json();
const text = data.choices?.[0]?.message?.content || '(no response)'; if (data.error) throw new Error(data.error);
textEl.textContent = text; textEl.textContent = data.text || '(no response)';
} catch (e) { } catch (e) {
textEl.className = 'explain-error'; textEl.className = 'explain-error';
textEl.textContent = 'Error: ' + e.message; textEl.textContent = 'Error: ' + e.message;
@@ -402,6 +374,62 @@ data:
self.send_response(404) self.send_response(404)
self.end_headers() self.end_headers()
def do_POST(self):
path = self.path.split("?")[0].lstrip("/")
if path != "explain":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(length))
api_key = body.get("key", "")
dataset = body.get("dataset", {})
if not api_key:
raise ValueError("missing key")
sample_text = "\n".join(
", ".join(f"{h}: {v}" for h, v in zip(dataset.get("headers", []), row))
for row in dataset.get("sample", [])
)
system_prompt = (
"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."
)
user_prompt = (
f"Dataset: \"{dataset.get('label', '')}\"\n"
f"Total rows: {dataset.get('total_rows', 0)}\n"
f"Columns: {', '.join(dataset.get('headers', []))}\n\n"
f"Sample data (first {len(dataset.get('sample', []))} rows):\n"
f"{sample_text}\n\nWhat does this dataset tell us about chess?"
)
payload = json.dumps({
"model": "meta/llama-3.3-70b-instruct",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_tokens": 512,
"temperature": 0.4,
"stream": False,
}).encode("utf-8")
req = urllib.request.Request(
"https://integrate.api.nvidia.com/v1/chat/completions",
data=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as r:
nim_data = json.loads(r.read())
text = nim_data.get("choices", [{}])[0].get("message", {}).get("content", "(no response)")
self._send_json({"text": text})
except Exception as e:
self._send_json({"error": str(e)})
def _send(self, body: str): def _send(self, body: str):
data = body.encode("utf-8") data = body.encode("utf-8")
self.send_response(200) self.send_response(200)
@@ -413,6 +441,17 @@ data:
except BrokenPipeError: except BrokenPipeError:
pass pass
def _send_json(self, obj: dict):
data = json.dumps(obj).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
try:
self.wfile.write(data)
except BrokenPipeError:
pass
if __name__ == "__main__": if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", PORT), Handler) server = HTTPServer(("0.0.0.0", PORT), Handler)
print(f"Listening on :{PORT} DB={DB_HOST}:{DB_PORT}/{DB_NAME} JDBC_URL={_url!r}", flush=True) print(f"Listening on :{PORT} DB={DB_HOST}:{DB_PORT}/{DB_NAME} JDBC_URL={_url!r}", flush=True)