16 Commits

Author SHA1 Message Date
Kargo 9c0114a42e chore(kargo): update image tag official-bots to 0.36.0 on eu-central-1-prod
Delete PR Branch On Close / delete-branch (pull_request) Successful in 5s
2026-06-26 00:17:44 +00:00
svc.kargo e81fd1bc83 chore(kargo): update image tag kargo to 1.10.8 (#520) 2026-06-26 02:13:16 +02:00
svc.kargo ad6c0e7112 chore(kargo): update image tag official-bots to 0.40.0-x86_v2 on htwg-1-prod (#519) 2026-06-24 22:58:37 +02:00
svc.kargo 2d03547d58 chore(kargo): update image tag official-bots to 0.40.0 on staging (#518) 2026-06-24 22:53:18 +02:00
svc.kargo caad6681f7 chore(kargo): update image tag official-bots to 0.39.0-x86_v2 on htwg-1-prod (#517) 2026-06-24 22:32:29 +02:00
svc.kargo 8a2e175ba8 chore(kargo): update image tag official-bots to 0.39.0 on staging (#516) 2026-06-24 22:27:21 +02:00
svc.kargo ccf1a15901 chore(kargo): update image tag official-bots to 0.38.0-x86_v2 on htwg-1-prod (#515) 2026-06-24 20:37:01 +02:00
svc.kargo 1f9c5fc940 chore(kargo): update image tag official-bots to 0.38.0 on staging (#514) 2026-06-24 20:32:46 +02:00
svc.kargo 3ad1d3bbaf chore(kargo): update image tag official-bots to 0.37.0-x86_v2 on htwg-1-prod (#513) 2026-06-24 20:11:13 +02:00
svc.kargo 13ada3312d chore(kargo): update image tag official-bots to 0.37.0 on staging (#512) 2026-06-24 20:06:49 +02:00
Janis 35097e22ff revert: "feat(spark-analytics): per-row free-text questions in webview"
This reverts commit af3310297b.
2026-06-24 09:54:16 +02:00
Janis af3310297b feat(spark-analytics): per-row free-text questions in webview
Each expandable row now has a text input + Ask button alongside the
auto-analysis. Type any question about that row, press Ask or Enter, and
the answer streams in below via the existing /explain SSE path.

Server: row mode accepts an optional "question" field — when set it uses
a grounded Q&A prompt instead of the default analysis. Adds askRow() JS
handler, row-ask markup, and supporting CSS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:47:15 +02:00
Janis 9a5b62c2ab fix(spark-analytics): remove raw newline from webview JS comment
The SEP comment contained "\n\n", which Python's triple-quoted JS block
turned into real newline characters in the emitted script. That produced
an inline-<script> SyntaxError ("string literal contains an unescaped
line break"), so every handler went undefined and clicking the analyse
buttons did nothing (ReferenceError: analyzeRow is not defined).

Replace the comment text with plain words — no backslash escapes. Verified
by exec'ing serve.py (psycopg2 stubbed) to capture the Python-evaluated
JS and parsing it with node --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:37:15 +02:00
Janis 9bb8f119b5 fix(spark-analytics): stream NIM AI responses to kill webview 504s
The /explain endpoint blocked on a synchronous NIM call (llama-3.3-70b,
30-60s+) while nginx's default 60s proxy-read-timeout fired first,
surfacing every AI request as a 504 Gateway Timeout.

Switch the NIM proxy to streaming (stream:true) and relay its SSE to the
browser via a new _emit() helper; the client now reads resp.body
incrementally and renders tokens as they arrive. With chunks flowing,
nginx's read-timeout resets per chunk so the 60s wall is gone, and the
frozen spinner is replaced by live text.

Ingress: disable proxy-buffering (else nginx buffers the whole response,
defeating streaming) and set proxy-read-timeout to 120s as the
inter-chunk gap budget. Server urlopen timeout 60->110s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:22:31 +02:00
Janis Eccarius f64b838030 feat(spark-analytics): add AccuracyBlunder, ClockPressure, SmurfAnomaly jobs and webview datasets
Adds three new CronJobs (spark-accuracy-blunder, spark-clock-pressure,
spark-smurf-anomaly) and registers their six output tables in the
analytics webview (accuracy_by_rating, blunder_outcome, clock_by_rating,
scramble_outcome, smurf_anomaly, flagged_smurfs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:18:22 +02:00
svc.kargo b3fffec838 chore(kargo): update image tag official-bots to 0.36.0-x86_v2 on htwg-1-prod (#510) 2026-06-24 00:09:18 +02:00
7 changed files with 492 additions and 35 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ spec:
sources:
- repoURL: ghcr.io/akuity/kargo-charts
chart: kargo
targetRevision: 1.10.7
targetRevision: 1.10.8
helm:
valueFiles:
- $values/kargo/eu-central-1/values.yaml
@@ -124,7 +124,7 @@ images:
- name: ghcr.io/now-chess/now-chess-systems/io
newTag: 0.23.0
- name: ghcr.io/now-chess/now-chess-systems/official-bots
newTag: 0.36.0
newTag: 0.40.0
- name: ghcr.io/now-chess/now-chess-systems/rule
newTag: 0.18.0
- name: ghcr.io/now-chess/now-chess-systems/store
+1 -1
View File
@@ -88,7 +88,7 @@ images:
- name: ghcr.io/now-chess/now-chess-systems/io
newTag: 0.23.0-x86_v2
- name: ghcr.io/now-chess/now-chess-systems/official-bots
newTag: 0.35.0-x86_v2
newTag: 0.40.0-x86_v2
- name: ghcr.io/now-chess/now-chess-systems/rule
newTag: 0.18.0-x86_v2
- name: ghcr.io/now-chess/now-chess-systems/store
+328
View File
@@ -1197,3 +1197,331 @@ spec:
- name: spark-output
persistentVolumeClaim:
claimName: spark-analytics-output
---
apiVersion: v1
kind: Service
metadata:
name: spark-accuracy-blunder-driver
labels:
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
clusterIP: None
selector:
app: spark-accuracy-blunder
ports:
- name: driver
port: 44167
targetPort: 44167
- name: blockmanager
port: 44777
targetPort: 44777
---
apiVersion: v1
kind: Service
metadata:
name: spark-clock-pressure-driver
labels:
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
clusterIP: None
selector:
app: spark-clock-pressure
ports:
- name: driver
port: 44167
targetPort: 44167
- name: blockmanager
port: 44777
targetPort: 44777
---
apiVersion: v1
kind: Service
metadata:
name: spark-smurf-anomaly-driver
labels:
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
clusterIP: None
selector:
app: spark-smurf-anomaly
ports:
- name: driver
port: 44167
targetPort: 44167
- name: blockmanager
port: 44777
targetPort: 44777
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-accuracy-blunder
labels:
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
schedule: "0 9 * * 6"
suspend: true
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app: spark-accuracy-blunder
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
serviceAccountName: spark-analytics
restartPolicy: OnFailure
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: spark-worker
topologyKey: kubernetes.io/hostname
securityContext:
fsGroup: 185
imagePullSecrets:
- name: ghcr-pull-secret
containers:
- name: spark-driver
image: ghcr.io/now-chess/now-chess-systems/analytics:latest
command:
- /opt/spark/bin/spark-submit
- --master
- spark://spark-master:7077
- --conf
- spark.executor.instances=1
- --conf
- spark.executor.cores=1
- --conf
- spark.executor.memory=512m
- --conf
- spark.driver.memory=512m
- --conf
- spark.driver.host=spark-accuracy-blunder-driver
- --conf
- spark.driver.port=44167
- --conf
- spark.blockManager.port=44777
- --class
- de.nowchess.analytics.AccuracyBlunderJob
- file:///app/analytics.jar
- /spark-output/accuracy
env:
- name: NOWCHESS_JDBC_URL
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_URL
- name: NOWCHESS_DB_USER
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_USER
- name: NOWCHESS_DB_PASS
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_PASSWORD
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: spark-output
mountPath: /spark-output
volumes:
- name: spark-output
persistentVolumeClaim:
claimName: spark-analytics-output
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-clock-pressure
labels:
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
schedule: "0 10 * * 6"
suspend: true
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app: spark-clock-pressure
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
serviceAccountName: spark-analytics
restartPolicy: OnFailure
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: spark-worker
topologyKey: kubernetes.io/hostname
securityContext:
fsGroup: 185
imagePullSecrets:
- name: ghcr-pull-secret
containers:
- name: spark-driver
image: ghcr.io/now-chess/now-chess-systems/analytics:latest
command:
- /opt/spark/bin/spark-submit
- --master
- spark://spark-master:7077
- --conf
- spark.executor.instances=1
- --conf
- spark.executor.cores=1
- --conf
- spark.executor.memory=512m
- --conf
- spark.driver.memory=512m
- --conf
- spark.driver.host=spark-clock-pressure-driver
- --conf
- spark.driver.port=44167
- --conf
- spark.blockManager.port=44777
- --class
- de.nowchess.analytics.ClockPressureJob
- file:///app/analytics.jar
- /spark-output/clock-pressure
env:
- name: NOWCHESS_JDBC_URL
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_URL
- name: NOWCHESS_DB_USER
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_USER
- name: NOWCHESS_DB_PASS
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_PASSWORD
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: spark-output
mountPath: /spark-output
volumes:
- name: spark-output
persistentVolumeClaim:
claimName: spark-analytics-output
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-smurf-anomaly
labels:
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
schedule: "0 11 * * 6"
suspend: true
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app: spark-smurf-anomaly
app.kubernetes.io/name: spark-analytics
app.kubernetes.io/part-of: nowchess
spec:
serviceAccountName: spark-analytics
restartPolicy: OnFailure
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: spark-worker
topologyKey: kubernetes.io/hostname
securityContext:
fsGroup: 185
imagePullSecrets:
- name: ghcr-pull-secret
containers:
- name: spark-driver
image: ghcr.io/now-chess/now-chess-systems/analytics:latest
command:
- /opt/spark/bin/spark-submit
- --master
- spark://spark-master:7077
- --conf
- spark.executor.instances=1
- --conf
- spark.executor.cores=1
- --conf
- spark.executor.memory=512m
- --conf
- spark.driver.memory=512m
- --conf
- spark.driver.host=spark-smurf-anomaly-driver
- --conf
- spark.driver.port=44167
- --conf
- spark.blockManager.port=44777
- --class
- de.nowchess.analytics.SmurfAnomalyJob
- file:///app/analytics.jar
- /spark-output/smurf-anomaly
- "15"
env:
- name: NOWCHESS_JDBC_URL
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_URL
- name: NOWCHESS_DB_USER
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_USER
- name: NOWCHESS_DB_PASS
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_PASSWORD
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: spark-output
mountPath: /spark-output
volumes:
- name: spark-output
persistentVolumeClaim:
claimName: spark-analytics-output
+93 -32
View File
@@ -44,6 +44,12 @@ data:
("weekly-activity", "Weekly Activity", "analytics_weekly_activity"),
("rating-mismatch", "Rating Mismatch (Upsets)", "analytics_rating_mismatch"),
("termination-stats", "Termination Types", "analytics_termination_stats"),
("accuracy-by-rating", "Accuracy by Rating", "analytics_accuracy_by_rating"),
("blunder-outcome", "Blunder Outcome", "analytics_blunder_outcome"),
("clock-by-rating", "Clock Pressure by Rating", "analytics_clock_by_rating"),
("scramble-outcome", "Scramble Outcome", "analytics_scramble_outcome"),
("smurf-anomaly", "Smurf Anomaly Scores", "analytics_smurf_anomaly"),
("flagged-smurfs", "Flagged Smurfs", "analytics_flagged_smurfs"),
]
CSS = """
@@ -181,37 +187,52 @@ data:
if (btn) btn.disabled = !hasKey();
}
async function nimFetch(payload) {
async function nimStream(payload, onDelta) {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 65000);
let resp;
const tid = setTimeout(() => ctrl.abort(), 180000);
try {
resp = await fetch('/explain', {
const resp = await fetch('/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: ctrl.signal,
});
if (resp.status === 429) {
const retryAfter = parseInt(resp.headers.get('Retry-After') || '10', 10);
const err = new Error('Rate limited — retry in ' + retryAfter + 's');
err.retryAfter = retryAfter;
throw err;
}
if (!resp.ok) {
const msg = await resp.text();
throw new Error('Proxy ' + resp.status + ': ' + msg);
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
const SEP = String.fromCharCode(10, 10); // SSE frame separator (two newlines)
let buf = '', full = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf(SEP)) >= 0) {
const frame = buf.slice(0, idx).trim();
buf = buf.slice(idx + 2);
if (!frame.startsWith('data:')) continue;
const obj = JSON.parse(frame.slice(5).trim());
if (obj.error) throw new Error(obj.error);
if (obj.delta) { full += obj.delta; onDelta(full); }
}
}
return full || '(no response)';
} finally {
clearTimeout(tid);
}
if (resp.status === 429) {
const retryAfter = parseInt(resp.headers.get('Retry-After') || '10', 10);
const err = new Error('Rate limited — retry in ' + retryAfter + 's');
err.retryAfter = retryAfter;
throw err;
}
if (!resp.ok) {
const msg = await resp.text();
throw new Error('Proxy ' + resp.status + ': ' + msg);
}
const data = await resp.json();
if (data.error) throw new Error(data.error);
return data.text || '(no response)';
}
function queueNim(payload) {
const next = nimQueue.then(() => nimFetch(payload));
function queueNim(payload, onDelta) {
const next = nimQueue.then(() => nimStream(payload, onDelta));
nimQueue = next.catch(() => {});
return next;
}
@@ -231,7 +252,10 @@ data:
textEl.textContent = '';
try {
const text = await queueNim({ key: key, mode: 'dataset', dataset: window.DATASET });
const text = await queueNim(
{ key: key, mode: 'dataset', dataset: window.DATASET },
partial => { textEl.textContent = partial; },
);
textEl.textContent = text;
} catch (e) {
textEl.className = 'explain-error';
@@ -269,10 +293,13 @@ data:
textEl.textContent = 'Analyzing…';
try {
const text = await queueNim({
key: key, mode: 'row',
dataset: { label: dataset.label, headers: dataset.headers, row: row },
});
const text = await queueNim(
{
key: key, mode: 'row',
dataset: { label: dataset.label, headers: dataset.headers, row: row },
},
partial => { textEl.textContent = partial; },
);
rowCache[idx] = text;
textEl.textContent = text;
btn.innerHTML = '✓';
@@ -474,6 +501,7 @@ data:
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
headers_sent = False
try:
body = json.loads(self.rfile.read(length))
api_key = body.get("key", "")
@@ -532,7 +560,7 @@ data:
],
"max_tokens": max_tokens,
"temperature": 0.4,
"stream": False,
"stream": True,
}).encode("utf-8")
req = urllib.request.Request(
"https://integrate.api.nvidia.com/v1/chat/completions",
@@ -544,10 +572,7 @@ data:
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) 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})
upstream = urllib.request.urlopen(req, timeout=110)
except urllib.error.HTTPError as he:
if he.code == 429:
retry_after = he.headers.get("Retry-After", "10")
@@ -561,10 +586,46 @@ data:
self.wfile.write(resp_body)
except BrokenPipeError:
pass
else:
self._send_json({"error": f"NIM API error {he.code}: {he.reason}"})
return
raise RuntimeError(f"NIM API error {he.code}: {he.reason}")
# Relay NIM's SSE stream to the browser as our own SSE.
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
headers_sent = True
for raw in upstream:
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
piece = obj.get("choices", [{}])[0].get("delta", {}).get("content", "")
if piece and not self._emit({"delta": piece}):
return
self._emit({"done": True})
except Exception as e:
self._send_json({"error": str(e)})
if not headers_sent:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self._emit({"error": str(e)})
def _emit(self, obj: dict) -> bool:
try:
self.wfile.write(("data: " + json.dumps(obj) + "\n\n").encode("utf-8"))
self.wfile.flush()
return True
except (BrokenPipeError, ConnectionResetError):
return False
def _send(self, body: str):
data = body.encode("utf-8")
@@ -353,6 +353,70 @@ patches:
target:
kind: CronJob
name: spark-termination-stats
# Eval-annotated dump required for per-move accuracy; the default 2013 dump
# carries no [%eval] comments — point this at an evals dump for real output.
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-accuracy-blunder
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
- name: NOWCHESS_PGN_CACHE_DIR
value: "/spark-output/.pgn-cache"
target:
kind: CronJob
name: spark-accuracy-blunder
# Clock-annotated dump required; the default 2013 dump carries no [%clk]
# comments — point this at a dump with clocks for real output.
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-clock-pressure
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
- name: NOWCHESS_PGN_CACHE_DIR
value: "/spark-output/.pgn-cache"
target:
kind: CronJob
name: spark-clock-pressure
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-smurf-anomaly
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
- name: NOWCHESS_PGN_CACHE_DIR
value: "/spark-output/.pgn-cache"
target:
kind: CronJob
name: spark-smurf-anomaly
- patch: |-
apiVersion: apps/v1
kind: Deployment
@@ -9,6 +9,10 @@ metadata:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# AI /explain streams Server-Sent Events — disable response buffering so
# chunks flush to the client immediately, and allow gaps between chunks.
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
spec:
ingressClassName: nginx
rules: