stevhliu HF Staff ArthurZ HF Staff commited on
Commit
9ef0bbc
·
1 Parent(s): e03f509

Add /jobs page (CI-style UI) + /community v0 stub (#1)

Browse files

- Add /jobs CI-style page + /community v0 stub (98cf995979cf72e3d8319f72dc0a0ecb58877698)


Co-authored-by: Arthur Zucker <ArthurZ@users.noreply.huggingface.co>

Files changed (6) hide show
  1. COMMUNITY.md +159 -0
  2. Dockerfile +1 -1
  3. README.md +46 -0
  4. app.py +298 -4
  5. community.html +120 -0
  6. jobs.html +428 -0
COMMUNITY.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Slyfox — community roadmap
2
+
3
+ ## Why this matters
4
+
5
+ Claude Code (and equivalents) generate a uniquely rich signal: a maintainer's
6
+ running internal monologue while they fix bugs, explain weird design choices,
7
+ and triage broken builds. Today, that signal evaporates the moment the user's
8
+ terminal closes. Two things change when maintainers can opt in to publishing
9
+ their traces:
10
+
11
+ 1. **New contributors get a runway.** Someone joining the `transformers` repo
12
+ for the first time can see how Arthur thinks about a tokenizer fix — without
13
+ needing a 1:1 onboarding session.
14
+ 2. **Open-source models get a training edge.** High-signal expert traces are
15
+ exactly the kind of data closed labs already farm internally. Putting them
16
+ in the open shifts the data moat: open-weight models can be fine-tuned on
17
+ the same kind of corpora that drive frontier-model behavior.
18
+
19
+ For both of those to work, slyfox needs to be community-centric, not a
20
+ private-dashboard tool. This doc sketches what that looks like.
21
+
22
+ ## North-star user stories
23
+
24
+ - **Maintainer Maya** runs `hf upload-traces --share community` after a long
25
+ session on `vllm-project/vllm`. Her bundle goes to a private bucket *and* an
26
+ opt-in public catalog with a model-fit summary attached.
27
+ - **Contributor Carlos** browses `huggingface/transformers` on the slyfox
28
+ Space, sees that "Gemma 2 9B" tracks Arthur's emotional cadence the closest
29
+ on long sessions, picks it for his fork's CI.
30
+ - **Researcher Riya** fine-tunes a 7B model on the public catalog's
31
+ exported-for-training dataset, posts results back to a leaderboard slot.
32
+
33
+ ## Consent + visibility model
34
+
35
+ Nothing leaves a maintainer's machine without an explicit flag. The
36
+ defaults are strict; the opt-ins are loud.
37
+
38
+ | State | Default | What it means |
39
+ | ------------ | ------- | -------------------------------------------------------------------------- |
40
+ | `private` | ✅ | Bucket is theirs only. (Current behavior of `hf upload-traces`.) |
41
+ | `org` | | Bucket is shared within a chosen HF org. Discoverable by org members only. |
42
+ | `community` | | Bundle manifest is published to `HF-slyfox/community-index`; anyone can browse. The trace bucket itself stays per-maintainer-controlled (public / private / read-token). |
43
+ | `training` | | Maintainer also tags the bundle with a CC-BY-style flag, signalling that the published traces may be redistributed in training corpora. |
44
+
45
+ Each escalation is a separate flag (`--share`, `--share-org`, `--publish`,
46
+ `--license cc-by-4.0`) — you never get bumped up a tier silently.
47
+
48
+ ## Phased rollout
49
+
50
+ ### Phase 1 — Catalog (this PR + a follow-up)
51
+ - `/community` page on the Space lists opt-in published runs from
52
+ `hf://buckets/HF-slyfox/community-index/runs/*.json`.
53
+ - `hf upload-traces` gains `--share community` that writes a minimal
54
+ manifest entry: `{run_id, owner, repo, bucket, summary, created_at}`.
55
+ - API: `/api/community/runs` — already stubbed in this PR.
56
+
57
+ ### Phase 2 — Browse + remix
58
+ - Each catalog entry has a "Remix" button that resubmits the analysis with the
59
+ current user's chosen model (calls `/api/analyses` with the source/prefix from
60
+ the manifest).
61
+ - Per-run page with the EmotionScope orb animation timed to the session's
62
+ timestamps.
63
+ - Comments / annotations via HF Discussions API on the underlying bucket / dataset.
64
+
65
+ ### Phase 3 — Persona leaderboard
66
+ - For each public bundle, the slyfox Space computes a "persona vector"
67
+ (average emotion profile, talking-style features, code-language mix) and a
68
+ per-model fit score across the supported model catalog.
69
+ - `/community/leaderboard?repo=huggingface/transformers` shows which models
70
+ best match each maintainer's persona on that repo.
71
+
72
+ ### Phase 4 — Training-dataset export
73
+ - Public bundles tagged `--license cc-by-4.0` can be aggregated into a
74
+ redistributable dataset (`HF-slyfox/training-set`) suitable for SFT/DPO.
75
+ - The exported dataset is *re-scrubbed* (paths, secrets, emails) on top of the
76
+ uploader-side filter, with a stricter regex set — defense in depth.
77
+ - Periodic releases versioned as `HF-slyfox/training-set:v0.1`, `:v0.2`, etc.
78
+
79
+ ### Phase 5 — Org-level governance
80
+ - Org admins can set `slyfox.community.policy = {allow|deny|approve}` on their
81
+ HF org; uploads that target a community share check the policy before
82
+ publishing.
83
+ - Per-repo opt-out (e.g. a private repo never publishes).
84
+ - "Trace of the week" highlighted on the slyfox landing page.
85
+
86
+ ## Data shapes
87
+
88
+ ### Community manifest (one JSON file per published run)
89
+
90
+ ```json
91
+ {
92
+ "schema_version": 1,
93
+ "kind": "analysis-run",
94
+ "run_id": "20260522-184201-a1b2c3",
95
+ "owner": "ArthurZ",
96
+ "repo": "huggingface/transformers",
97
+ "model_id": "google/gemma-2-9b-it",
98
+ "vectors_ref": "hf://buckets/HF-slyfox/emotion-vectors/google/gemma-2-9b-it/",
99
+ "source_bucket": "ArthurZ/transformers-traces",
100
+ "trace_count": 5,
101
+ "license": "cc-by-4.0",
102
+ "summary": {
103
+ "valence_mean": 0.31,
104
+ "arousal_mean": 0.62,
105
+ "dominant_emotions": ["focused", "curious", "amused"]
106
+ },
107
+ "consent": {
108
+ "scope": "community",
109
+ "training_use": true,
110
+ "issued_at": "2026-05-22T18:42:01Z"
111
+ },
112
+ "links": {
113
+ "results_prefix": "hf://buckets/ArthurZ/transformers-traces/trace-analysis/20260522-184201-a1b2c3/",
114
+ "discussions": "https://huggingface.co/datasets/ArthurZ/transformers-traces/discussions"
115
+ }
116
+ }
117
+ ```
118
+
119
+ ### Index layout
120
+
121
+ ```
122
+ HF-slyfox/community-index/
123
+ runs/
124
+ 20260522-184201-a1b2c3.json # per-run manifests, append-only
125
+ orgs/
126
+ huggingface.json # one-file-per-org summary
127
+ vllm-project.json
128
+ leaderboard/
129
+ huggingface__transformers.json # cached persona-vs-model scores
130
+ ```
131
+
132
+ ## What's already in place vs. what's still on paper
133
+
134
+ | Piece | State |
135
+ | ---------------------------------- | -------------- |
136
+ | Trace upload with filtering | ✅ shipped (`hf upload-traces`) |
137
+ | `hf jobs uv` integration | ✅ this PR |
138
+ | `/api/community/runs` endpoint | ✅ stub in this PR |
139
+ | `/community` page | ✅ stub in this PR |
140
+ | `--share community` upload flag | 🚧 next |
141
+ | Catalog write-side (manifest writer) | 🚧 next |
142
+ | Remix UI + per-run page | 🔜 phase 2 |
143
+ | Persona leaderboard | 🔜 phase 3 |
144
+ | Training-dataset export | 🔜 phase 4 |
145
+ | Org-level policy / opt-out | 🔜 phase 5 |
146
+
147
+ ## Open questions
148
+
149
+ - **Anonymization**: is a `username` ever fine to surface, or do we go fully
150
+ pseudonymous (`maintainer-1234`) until a user explicitly publishes under
151
+ their handle? Probably the latter as default with an opt-in toggle.
152
+ - **Quality bar**: should the community catalog filter junk runs (e.g. 1-turn
153
+ sessions)? Likely yes via a minimum-turns threshold.
154
+ - **Reciprocity**: should publishing be a prerequisite for accessing premium
155
+ analysis features (GPU time)? Soft incentive vs. paywall — needs a
156
+ discussion with the org.
157
+ - **Redaction asymmetry**: should the community catalog re-run the filter
158
+ with a *stricter* regex set than the per-user uploader, on the theory that
159
+ publishers may have less context to judge what they leak?
Dockerfile CHANGED
@@ -9,7 +9,7 @@ WORKDIR /app
9
  COPY requirements.txt .
10
  RUN pip install --no-cache-dir -r requirements.txt
11
 
12
- COPY app.py index.html ./
13
 
14
  # HF Spaces require listening on $PORT (default 7860).
15
  ENV PORT=7860
 
9
  COPY requirements.txt .
10
  RUN pip install --no-cache-dir -r requirements.txt
11
 
12
+ COPY app.py index.html jobs.html community.html ./
13
 
14
  # HF Spaces require listening on $PORT (default 7860).
15
  ENV PORT=7860
README.md CHANGED
@@ -67,3 +67,49 @@ HF_TOKEN=hf_… uvicorn app:app --reload --port 7860
67
  ```
68
 
69
  Then visit `http://localhost:7860/?api=http://localhost:8000/v1`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ```
68
 
69
  Then visit `http://localhost:7860/?api=http://localhost:8000/v1`.
70
+
71
+ ## Jobs page
72
+
73
+ A second route, `/jobs`, exposes two operations that submit Hugging Face Jobs
74
+ on behalf of the user. Both run via `HfApi.run_uv_job` and report CI-style
75
+ status back into the UI.
76
+
77
+ | Operation | Submits | Output |
78
+ | --- | --- | --- |
79
+ | **Request a new model** | `jobs_scripts/extract_vectors.py` | `hf://buckets/HF-slyfox/emotion-vectors/<model_id>/` |
80
+ | **Analyze traces** | `jobs_scripts/analyze_traces.py` | `hf://buckets/<source>/trace-analysis/<run_id>/` |
81
+
82
+ API surface (additive — none of the existing `/api/chats/*` routes change):
83
+
84
+ ```
85
+ POST /api/models/extract { model_id, flavor? }
86
+ POST /api/analyses { source, prefix?, n_traces, model_id, flavor? }
87
+ GET /api/jobs list slyfox-submitted jobs with live status
88
+ GET /api/jobs/{job_id} one job's record + live status
89
+ GET /api/jobs/{job_id}/logs tail of the job's stdout/stderr
90
+ POST /api/jobs/{job_id}/cancel cancel a running/queued job
91
+ ```
92
+
93
+ Bucket layout extension:
94
+
95
+ ```
96
+ SLYFOX_BUCKET/
97
+ ...existing chat layout...
98
+ jobs/<job_id>.json # sidecar record for each submitted job
99
+ ```
100
+
101
+ Extra env vars:
102
+
103
+ | Variable | Default | Description |
104
+ | --- | --- | --- |
105
+ | `SLYFOX_JOBS_REF` | `main` | Git ref of `huggingface/slyfox` to pull UV scripts from. |
106
+ | `SLYFOX_VECTORS_BUCKET` | `HF-slyfox/emotion-vectors` | Where extracted vectors land. |
107
+ | `SLYFOX_COMMUNITY_BUCKET` | `HF-slyfox/community-index` | Public index of opt-in shared runs. |
108
+
109
+ ## Community (v0)
110
+
111
+ A third route, `/community`, is a placeholder for the upcoming public catalog
112
+ of opt-in shared analysis runs. The `/api/community/runs` endpoint already
113
+ reads from `SLYFOX_COMMUNITY_BUCKET`; the publish-side flow (`hf upload-traces
114
+ --share community`) is on the roadmap. See [`COMMUNITY.md`](./COMMUNITY.md)
115
+ for the full design.
app.py CHANGED
@@ -4,6 +4,7 @@ Layout in the bucket (one file per chat):
4
 
5
  users/<user_id>/chats/<chat_id>.json
6
  users/<user_id>/index.json # ordered list of chat ids + titles + topics
 
7
 
8
  `<user_id>` is currently an anonymous UUID minted in the browser (stored in
9
  localStorage) and sent as `X-User-Id`. This is intentionally minimal — swap for
@@ -17,6 +18,7 @@ import json
17
  import logging
18
  import os
19
  import re
 
20
  from pathlib import Path
21
  from typing import Any
22
 
@@ -33,11 +35,20 @@ log = logging.getLogger("slyfox")
33
  BUCKET_ID = os.getenv("SLYFOX_BUCKET", "HF-slyfox/slyfox-chats")
34
  HF_TOKEN = os.getenv("HF_TOKEN") # Spaces inject this automatically.
35
 
 
 
 
 
 
 
 
36
  api = HfApi(token=HF_TOKEN)
37
 
38
  ROOT = Path(__file__).parent
39
  USER_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
40
  CHAT_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
 
 
41
 
42
 
43
  def _user_prefix(user_id: str) -> str:
@@ -66,8 +77,6 @@ def _read_json(remote_path: str) -> Any | None:
66
  log.warning("metadata failed for %s: %s", remote_path, e)
67
  return None
68
 
69
- # `download_bucket_files` writes to disk; pull bytes via the file handle utility.
70
- # Easiest cross-version approach: download to a temp path and read it back.
71
  import tempfile
72
 
73
  with tempfile.TemporaryDirectory() as tmp:
@@ -87,6 +96,24 @@ def _write_json(remote_path: str, payload: Any) -> None:
87
  api.batch_bucket_files(BUCKET_ID, add=[(data, remote_path)])
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def _delete(remote_path: str) -> None:
91
  try:
92
  api.batch_bucket_files(BUCKET_ID, delete=[remote_path])
@@ -94,9 +121,18 @@ def _delete(remote_path: str) -> None:
94
  log.warning("delete failed for %s: %s", remote_path, e)
95
 
96
 
 
 
 
 
97
  app = FastAPI(title="Slyfox")
98
 
99
 
 
 
 
 
 
100
  @app.get("/api/chats")
101
  def list_chats(x_user_id: str = Header(...)) -> JSONResponse:
102
  """Return the per-user index: [{id, title, topic, updated_at}, ...]."""
@@ -115,13 +151,11 @@ def get_chat(chat_id: str, x_user_id: str = Header(...)) -> JSONResponse:
115
  @app.put("/api/chats/{chat_id}")
116
  async def put_chat(chat_id: str, request: Request, x_user_id: str = Header(...)) -> JSONResponse:
117
  body = await request.json()
118
- # Expected shape: {id, title, topic, history: [...], updated_at}
119
  if body.get("id") != chat_id:
120
  raise HTTPException(400, "chat id mismatch")
121
 
122
  _write_json(_chat_path(x_user_id, chat_id), body)
123
 
124
- # Update the per-user index (one row per chat, newest first).
125
  idx = _read_json(_index_path(x_user_id)) or []
126
  idx = [e for e in idx if e.get("id") != chat_id]
127
  idx.insert(
@@ -151,7 +185,267 @@ def healthz() -> dict:
151
  return {"ok": True, "bucket": BUCKET_ID}
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  # Static frontend — mounted last so /api/* routes win.
 
 
 
155
  @app.get("/")
156
  def index() -> FileResponse:
157
  return FileResponse(ROOT / "index.html")
 
4
 
5
  users/<user_id>/chats/<chat_id>.json
6
  users/<user_id>/index.json # ordered list of chat ids + titles + topics
7
+ jobs/<job_id>.json # slyfox-submitted hf jobs (extract / analyze)
8
 
9
  `<user_id>` is currently an anonymous UUID minted in the browser (stored in
10
  localStorage) and sent as `X-User-Id`. This is intentionally minimal — swap for
 
18
  import logging
19
  import os
20
  import re
21
+ from datetime import datetime, timezone
22
  from pathlib import Path
23
  from typing import Any
24
 
 
35
  BUCKET_ID = os.getenv("SLYFOX_BUCKET", "HF-slyfox/slyfox-chats")
36
  HF_TOKEN = os.getenv("HF_TOKEN") # Spaces inject this automatically.
37
 
38
+ # Job submission config — URLs of UV scripts that run on hf jobs infra.
39
+ SLYFOX_JOBS_REF = os.getenv("SLYFOX_JOBS_REF", "arthur-work") # branch / tag of the slyfox repo (`main` once promoted)
40
+ JOBS_SCRIPT_BASE = f"https://raw.githubusercontent.com/huggingface/slyfox/{SLYFOX_JOBS_REF}/jobs_scripts"
41
+ EXTRACT_SCRIPT_URL = f"{JOBS_SCRIPT_BASE}/extract_vectors.py"
42
+ ANALYZE_SCRIPT_URL = f"{JOBS_SCRIPT_BASE}/analyze_traces.py"
43
+ VECTORS_BUCKET = os.getenv("SLYFOX_VECTORS_BUCKET", "HF-slyfox/emotion-vectors")
44
+
45
  api = HfApi(token=HF_TOKEN)
46
 
47
  ROOT = Path(__file__).parent
48
  USER_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
49
  CHAT_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
50
+ MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*/[A-Za-z0-9][A-Za-z0-9._\-]*$")
51
+ BUCKET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*/[A-Za-z0-9][A-Za-z0-9._\-]*$")
52
 
53
 
54
  def _user_prefix(user_id: str) -> str:
 
77
  log.warning("metadata failed for %s: %s", remote_path, e)
78
  return None
79
 
 
 
80
  import tempfile
81
 
82
  with tempfile.TemporaryDirectory() as tmp:
 
96
  api.batch_bucket_files(BUCKET_ID, add=[(data, remote_path)])
97
 
98
 
99
+ def _list_json(prefix: str) -> list[dict]:
100
+ """List JSON objects directly under `prefix/` in the bucket."""
101
+ try:
102
+ files = list(api.list_bucket_files(BUCKET_ID, path=prefix))
103
+ except Exception as e: # noqa: BLE001
104
+ log.warning("list failed for %s: %s", prefix, e)
105
+ return []
106
+ out: list[dict] = []
107
+ for f in files:
108
+ path = getattr(f, "path", None) or getattr(f, "rfilename", None) or str(f)
109
+ if not path.endswith(".json"):
110
+ continue
111
+ data = _read_json(path)
112
+ if isinstance(data, dict):
113
+ out.append(data)
114
+ return out
115
+
116
+
117
  def _delete(remote_path: str) -> None:
118
  try:
119
  api.batch_bucket_files(BUCKET_ID, delete=[remote_path])
 
121
  log.warning("delete failed for %s: %s", remote_path, e)
122
 
123
 
124
+ def _now_iso() -> str:
125
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
126
+
127
+
128
  app = FastAPI(title="Slyfox")
129
 
130
 
131
+ # =============================================================================
132
+ # Existing chat persistence API (unchanged)
133
+ # =============================================================================
134
+
135
+
136
  @app.get("/api/chats")
137
  def list_chats(x_user_id: str = Header(...)) -> JSONResponse:
138
  """Return the per-user index: [{id, title, topic, updated_at}, ...]."""
 
151
  @app.put("/api/chats/{chat_id}")
152
  async def put_chat(chat_id: str, request: Request, x_user_id: str = Header(...)) -> JSONResponse:
153
  body = await request.json()
 
154
  if body.get("id") != chat_id:
155
  raise HTTPException(400, "chat id mismatch")
156
 
157
  _write_json(_chat_path(x_user_id, chat_id), body)
158
 
 
159
  idx = _read_json(_index_path(x_user_id)) or []
160
  idx = [e for e in idx if e.get("id") != chat_id]
161
  idx.insert(
 
185
  return {"ok": True, "bucket": BUCKET_ID}
186
 
187
 
188
+ # =============================================================================
189
+ # Jobs API — emotion-vector extraction + trace analysis via `hf jobs`
190
+ # =============================================================================
191
+ #
192
+ # Two job kinds, both submitted via `HfApi.run_uv_job`:
193
+ #
194
+ # extract : take a model_id, run EmotionScope's vector extraction, write
195
+ # the 20 emotion direction vectors to a bucket under
196
+ # <VECTORS_BUCKET>/<model_id>/
197
+ #
198
+ # analyze : take a traces source (bucket+prefix) and a model_id, replay
199
+ # sessions through the model, project each turn's residual stream
200
+ # onto the emotion vectors, write per-trace parquet results under
201
+ # <source>/trace-analysis/<run_id>/
202
+ #
203
+ # A small sidecar record is written to the chats bucket under `jobs/<job_id>.json`
204
+ # so the Space can list its own jobs. Live status / logs are pulled directly
205
+ # from the HF jobs API on demand.
206
+
207
+
208
+ def _job_record(job_id: str, *, kind: str, params: dict, requester: str) -> dict:
209
+ return {
210
+ "job_id": job_id,
211
+ "kind": kind,
212
+ "params": params,
213
+ "requester": requester,
214
+ "submitted_at": _now_iso(),
215
+ }
216
+
217
+
218
+ def _hf_job_status(job_id: str) -> dict:
219
+ """Live status snapshot for one job. Falls back gracefully on errors."""
220
+ try:
221
+ info = api.inspect_job(job_id)
222
+ except Exception as e: # noqa: BLE001
223
+ return {"job_id": job_id, "status": "unknown", "error": str(e)}
224
+ started = getattr(info, "started_at", None)
225
+ ended = getattr(info, "ended_at", None)
226
+ elapsed = None
227
+ if started:
228
+ end = ended or datetime.now(timezone.utc)
229
+ try:
230
+ elapsed = int((end - started).total_seconds())
231
+ except Exception: # noqa: BLE001
232
+ elapsed = None
233
+ return {
234
+ "job_id": job_id,
235
+ "status": getattr(info, "status", None) or getattr(info, "stage", "unknown"),
236
+ "flavor": getattr(info, "flavor", None),
237
+ "started_at": started.isoformat() if hasattr(started, "isoformat") else started,
238
+ "ended_at": ended.isoformat() if hasattr(ended, "isoformat") else ended,
239
+ "elapsed_s": elapsed,
240
+ }
241
+
242
+
243
+ @app.post("/api/models/extract")
244
+ async def request_model_extract(request: Request, x_user_id: str = Header(...)) -> JSONResponse:
245
+ """Submit an emotion-vector extraction job for a HuggingFace model."""
246
+ _user_prefix(x_user_id)
247
+ body = await request.json()
248
+ model_id = (body.get("model_id") or "").strip()
249
+ flavor = body.get("flavor") or "a10g-large"
250
+ if not MODEL_RE.match(model_id):
251
+ raise HTTPException(400, "model_id must look like 'org/name'")
252
+
253
+ output = f"hf://buckets/{VECTORS_BUCKET}/{model_id}/"
254
+ job = api.run_uv_job(
255
+ script=EXTRACT_SCRIPT_URL,
256
+ script_args=["--model", model_id, "--output", output],
257
+ dependencies=[
258
+ "transformers>=4.45",
259
+ "torch",
260
+ "huggingface_hub>=1.7",
261
+ "scikit-learn",
262
+ "safetensors",
263
+ "numpy",
264
+ ],
265
+ flavor=flavor,
266
+ timeout="45m",
267
+ labels={
268
+ "slyfox.io/type": "extract",
269
+ "slyfox.io/model": model_id,
270
+ "slyfox.io/requester": x_user_id,
271
+ },
272
+ )
273
+ record = _job_record(
274
+ job.id,
275
+ kind="extract",
276
+ params={"model_id": model_id, "flavor": flavor, "output": output},
277
+ requester=x_user_id,
278
+ )
279
+ _write_json(f"jobs/{job.id}.json", record)
280
+ return JSONResponse(record)
281
+
282
+
283
+ @app.post("/api/analyses")
284
+ async def request_analysis(request: Request, x_user_id: str = Header(...)) -> JSONResponse:
285
+ """Submit a trace-analysis job: replay sessions through a model and emit emotion trajectories."""
286
+ _user_prefix(x_user_id)
287
+ body = await request.json()
288
+ source = (body.get("source") or "").strip() # bucket id, e.g. "HF-slyfox/traces"
289
+ prefix = (body.get("prefix") or "").strip().strip("/")
290
+ n_traces = int(body.get("n_traces") or 5)
291
+ model_id = (body.get("model_id") or "").strip()
292
+ flavor = body.get("flavor") or "a10g-large"
293
+
294
+ if not BUCKET_RE.match(source):
295
+ raise HTTPException(400, "source must look like 'namespace/bucket'")
296
+ if not MODEL_RE.match(model_id):
297
+ raise HTTPException(400, "model_id must look like 'org/name'")
298
+ if n_traces < 1 or n_traces > 100:
299
+ raise HTTPException(400, "n_traces must be 1..100")
300
+
301
+ source_url = f"hf://buckets/{source}/{prefix}/" if prefix else f"hf://buckets/{source}/"
302
+ output_url = f"hf://buckets/{source}/trace-analysis/"
303
+ vectors_url = f"hf://buckets/{VECTORS_BUCKET}/{model_id}/"
304
+ job = api.run_uv_job(
305
+ script=ANALYZE_SCRIPT_URL,
306
+ script_args=[
307
+ "--source", source_url,
308
+ "--model", model_id,
309
+ "--vectors", vectors_url,
310
+ "--n-traces", str(n_traces),
311
+ "--output", output_url,
312
+ ],
313
+ dependencies=[
314
+ "transformers>=4.45",
315
+ "torch",
316
+ "huggingface_hub>=1.7",
317
+ "safetensors",
318
+ "numpy",
319
+ "pyarrow",
320
+ ],
321
+ flavor=flavor,
322
+ timeout="2h",
323
+ labels={
324
+ "slyfox.io/type": "analyze",
325
+ "slyfox.io/model": model_id,
326
+ "slyfox.io/source": source,
327
+ "slyfox.io/requester": x_user_id,
328
+ },
329
+ )
330
+ record = _job_record(
331
+ job.id,
332
+ kind="analyze",
333
+ params={
334
+ "source": source,
335
+ "prefix": prefix,
336
+ "n_traces": n_traces,
337
+ "model_id": model_id,
338
+ "flavor": flavor,
339
+ "output": output_url,
340
+ "vectors": vectors_url,
341
+ },
342
+ requester=x_user_id,
343
+ )
344
+ _write_json(f"jobs/{job.id}.json", record)
345
+ return JSONResponse(record)
346
+
347
+
348
+ @app.get("/api/jobs")
349
+ def list_slyfox_jobs() -> JSONResponse:
350
+ """List slyfox-submitted jobs (newest first) with live status merged in."""
351
+ records = _list_json("jobs")
352
+ records.sort(key=lambda r: r.get("submitted_at", ""), reverse=True)
353
+ out: list[dict] = []
354
+ for r in records:
355
+ out.append({**r, "live": _hf_job_status(r["job_id"])})
356
+ return JSONResponse(out)
357
+
358
+
359
+ @app.get("/api/jobs/{job_id}")
360
+ def get_job(job_id: str) -> JSONResponse:
361
+ record = _read_json(f"jobs/{job_id}.json") or {"job_id": job_id}
362
+ return JSONResponse({**record, "live": _hf_job_status(job_id)})
363
+
364
+
365
+ @app.get("/api/jobs/{job_id}/logs")
366
+ def get_job_logs(job_id: str, tail: int = 200) -> JSONResponse:
367
+ try:
368
+ chunks = list(api.fetch_job_logs(job_id))
369
+ except Exception as e: # noqa: BLE001
370
+ raise HTTPException(404, f"logs unavailable: {e}")
371
+ text = "".join(chunks)
372
+ lines = text.splitlines()
373
+ if tail and tail > 0:
374
+ lines = lines[-tail:]
375
+ return JSONResponse({"job_id": job_id, "lines": lines})
376
+
377
+
378
+ @app.post("/api/jobs/{job_id}/cancel")
379
+ def cancel_slyfox_job(job_id: str, x_user_id: str = Header(...)) -> JSONResponse:
380
+ _user_prefix(x_user_id)
381
+ try:
382
+ api.cancel_job(job_id)
383
+ except Exception as e: # noqa: BLE001
384
+ raise HTTPException(404, f"cancel failed: {e}")
385
+ return JSONResponse({"ok": True, "job_id": job_id})
386
+
387
+
388
+ @app.get("/jobs", include_in_schema=False)
389
+ def jobs_page() -> FileResponse:
390
+ return FileResponse(ROOT / "jobs.html")
391
+
392
+
393
+ # =============================================================================
394
+ # Community API — placeholder surface for org-level / public trace sharing.
395
+ # =============================================================================
396
+ #
397
+ # v0 stubs reserve the URL shape so future PRs can land without renaming.
398
+ # The intent (see COMMUNITY.md) is that orgs and users *opt in* to publishing
399
+ # their trace bundles + analysis runs to a public index, which the Space
400
+ # surfaces here so anyone can browse other maintainers' personas, remix
401
+ # analyses with their own model picks, and (eventually) export
402
+ # fine-tuning-friendly datasets from the published traces.
403
+
404
+ COMMUNITY_INDEX_BUCKET = os.getenv("SLYFOX_COMMUNITY_BUCKET", "HF-slyfox/community-index")
405
+
406
+
407
+ @app.get("/api/community/runs")
408
+ def community_runs() -> JSONResponse:
409
+ """Return the public list of opt-in published analysis runs.
410
+
411
+ v0: returns whatever has been written to
412
+ hf://buckets/<SLYFOX_COMMUNITY_BUCKET>/runs/*.json
413
+ Each entry is a manifest with {run_id, model_id, source, owner, summary}.
414
+ """
415
+ try:
416
+ files = list(api.list_bucket_files(COMMUNITY_INDEX_BUCKET, path="runs"))
417
+ except Exception as e: # noqa: BLE001
418
+ log.info("community index not available yet: %s", e)
419
+ return JSONResponse({"runs": [], "note": "community index not initialized"})
420
+ runs: list[dict] = []
421
+ for f in files:
422
+ path = getattr(f, "path", None) or getattr(f, "rfilename", None) or str(f)
423
+ if not path.endswith(".json"):
424
+ continue
425
+ # Read each manifest. The community bucket is *public*, so anonymous
426
+ # reads should work; we still go via HfApi for consistency.
427
+ import tempfile
428
+
429
+ with tempfile.TemporaryDirectory() as tmp:
430
+ local = Path(tmp) / "m.json"
431
+ try:
432
+ api.download_bucket_files(COMMUNITY_INDEX_BUCKET, files=[(path, str(local))])
433
+ runs.append(json.loads(local.read_text()))
434
+ except Exception as e: # noqa: BLE001
435
+ log.warning("skip community entry %s: %s", path, e)
436
+ return JSONResponse({"runs": runs})
437
+
438
+
439
+ @app.get("/community", include_in_schema=False)
440
+ def community_page() -> FileResponse:
441
+ return FileResponse(ROOT / "community.html")
442
+
443
+
444
+ # =============================================================================
445
  # Static frontend — mounted last so /api/* routes win.
446
+ # =============================================================================
447
+
448
+
449
  @app.get("/")
450
  def index() -> FileResponse:
451
  return FileResponse(ROOT / "index.html")
community.html ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Slyfox · Community</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117; --panel: #161b22; --panel-2: #1c2128; --border: #30363d;
10
+ --text: #e6edf3; --muted: #8b949e; --accent: #2f81f7;
11
+ --ok: #3fb950; --warn: #d29922; --err: #f85149;
12
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
13
+ }
14
+ * { box-sizing: border-box; }
15
+ html, body { background: var(--bg); color: var(--text); margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
16
+ a { color: var(--accent); text-decoration: none; }
17
+ a:hover { text-decoration: underline; }
18
+ header { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid var(--border); }
19
+ header h1 { font-size: 16px; margin: 0; font-weight: 600; }
20
+ header .nav a { color: var(--muted); margin-left: 18px; font-size: 14px; }
21
+ header .nav a.active { color: var(--text); }
22
+ main { max-width: 1100px; margin: 0 auto; padding: 24px; }
23
+ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 18px 20px; margin-bottom: 18px; }
24
+ .panel h2 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
25
+ .panel p { color: var(--muted); font-size: 13px; line-height: 1.6; margin: 0 0 10px; }
26
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); margin-right: 6px; }
27
+ .runs { display: grid; gap: 10px; }
28
+ .run { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
29
+ .run h3 { margin: 0; font-size: 13px; font-weight: 500; }
30
+ .run h3 .id { color: var(--muted); font-family: var(--mono); font-size: 11px; margin-left: 6px; }
31
+ .run .meta { color: var(--muted); font-size: 11px; margin-top: 4px; }
32
+ .empty { padding: 24px; text-align: center; color: var(--muted); font-size: 13px; }
33
+ pre { background: #0a0d12; border: 1px solid var(--border); padding: 12px; border-radius: 6px; font-family: var(--mono); font-size: 12px; overflow: auto; color: #c9d1d9; }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <header>
38
+ <h1>🦊 Slyfox · Community</h1>
39
+ <nav class="nav">
40
+ <a href="/">Chat</a>
41
+ <a href="/jobs">Jobs</a>
42
+ <a href="/community" class="active">Community</a>
43
+ </nav>
44
+ </header>
45
+
46
+ <main>
47
+ <section class="panel">
48
+ <h2>What this is <span class="badge">v0 stub</span></h2>
49
+ <p>
50
+ Slyfox is set up so any maintainer can publish their Claude Code traces +
51
+ analysis runs to a public catalog. The idea: shared traces are a high-signal
52
+ training and tuning corpus for open-source models, and they help
53
+ contributors get up to speed on a repo without needing direct access to
54
+ the original maintainer.
55
+ </p>
56
+ <p>
57
+ This page lists opt-in <b>public analysis runs</b> — manifests that
58
+ maintainers chose to share. The browse / remix / leaderboard UX is being
59
+ built; the wiring below is already in place.
60
+ </p>
61
+ <p>
62
+ See <code>COMMUNITY.md</code> in the slyfox repo for the full roadmap and
63
+ the consent / visibility model.
64
+ </p>
65
+ </section>
66
+
67
+ <section class="panel">
68
+ <h2>Public runs</h2>
69
+ <div id="runs" class="runs"><div class="empty">loading…</div></div>
70
+ </section>
71
+
72
+ <section class="panel">
73
+ <h2>How to publish</h2>
74
+ <p>From a clone of a repo whose maintainer has set up a slyfox bucket:</p>
75
+ <pre>hf upload-traces \
76
+ --bucket &lt;org&gt;/&lt;repo&gt;-traces \
77
+ --share community # opt-in: writes a manifest to HF-slyfox/community-index
78
+
79
+ # Then from this Space, run an analysis with `--publish` to add the run to the
80
+ # public catalog. Anyone can browse, fork, or download.</pre>
81
+ <p style="color: var(--warn); font-size: 12px;">
82
+ Note: <code>--share</code> / <code>--publish</code> are part of the
83
+ roadmap, not yet implemented. Don't paste secrets you don't want indexed.
84
+ </p>
85
+ </section>
86
+ </main>
87
+
88
+ <script>
89
+ "use strict";
90
+ async function load() {
91
+ const root = document.getElementById("runs");
92
+ try {
93
+ const res = await fetch("/api/community/runs");
94
+ const data = await res.json();
95
+ const runs = data.runs || [];
96
+ if (!runs.length) {
97
+ root.innerHTML = `<div class="empty">no public runs yet — be the first to publish.</div>`;
98
+ return;
99
+ }
100
+ root.innerHTML = runs.map(r => `
101
+ <div class="run">
102
+ <h3>${esc(r.title || r.model_id || r.run_id)}<span class="id">${esc(r.run_id || "")}</span></h3>
103
+ <div class="meta">
104
+ ${r.owner ? `<span class="badge">${esc(r.owner)}</span>` : ""}
105
+ ${r.model_id ? `<span class="badge">${esc(r.model_id)}</span>` : ""}
106
+ ${r.source ? `<span class="badge">${esc(r.source)}</span>` : ""}
107
+ ${r.completed_at || ""}
108
+ </div>
109
+ </div>
110
+ `).join("");
111
+ } catch (e) {
112
+ root.innerHTML = `<div class="empty">failed to load: ${esc(e.message)}</div>`;
113
+ }
114
+ }
115
+ function esc(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
116
+ load();
117
+ setInterval(load, 30000);
118
+ </script>
119
+ </body>
120
+ </html>
jobs.html ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Slyfox · Jobs</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --panel: #161b22;
11
+ --panel-2: #1c2128;
12
+ --border: #30363d;
13
+ --text: #e6edf3;
14
+ --muted: #8b949e;
15
+ --accent: #2f81f7;
16
+ --ok: #3fb950;
17
+ --warn: #d29922;
18
+ --err: #f85149;
19
+ --pending: #6e7681;
20
+ --running: #58a6ff;
21
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ html, body { background: var(--bg); color: var(--text); margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
25
+ a { color: var(--accent); text-decoration: none; }
26
+ a:hover { text-decoration: underline; }
27
+
28
+ header {
29
+ display: flex; align-items: center; justify-content: space-between;
30
+ padding: 14px 24px; border-bottom: 1px solid var(--border);
31
+ }
32
+ header h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: 0.2px; }
33
+ header .nav a { color: var(--muted); margin-left: 18px; font-size: 14px; }
34
+ header .nav a.active { color: var(--text); }
35
+
36
+ main { max-width: 1100px; margin: 0 auto; padding: 24px; }
37
+
38
+ .panel {
39
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
40
+ padding: 18px 20px; margin-bottom: 18px;
41
+ }
42
+ .panel h2 {
43
+ margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--text);
44
+ display: flex; align-items: center; gap: 8px;
45
+ }
46
+ .panel h2 .hint { color: var(--muted); font-weight: 400; font-size: 12px; }
47
+
48
+ .grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
49
+ label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
50
+ input[type=text], input[type=number], select {
51
+ width: 100%;
52
+ background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
53
+ color: var(--text); padding: 8px 10px; font-size: 13px; font-family: inherit;
54
+ }
55
+ input:focus, select:focus { outline: none; border-color: var(--accent); }
56
+ button {
57
+ background: var(--accent); color: white; border: 0; border-radius: 6px;
58
+ padding: 8px 14px; font-size: 13px; cursor: pointer; font-weight: 500;
59
+ }
60
+ button:hover { filter: brightness(1.1); }
61
+ button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
62
+ button.danger { background: var(--err); }
63
+ button[disabled] { opacity: 0.5; cursor: not-allowed; }
64
+
65
+ .row-actions { display: flex; gap: 10px; margin-top: 14px; align-items: center; }
66
+ .row-actions .filler { flex: 1; }
67
+ .toast { font-size: 12px; color: var(--muted); }
68
+ .toast.ok { color: var(--ok); }
69
+ .toast.err { color: var(--err); }
70
+
71
+ /* Job list — CI-style rows */
72
+ .jobs { border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
73
+ .job-row {
74
+ display: grid; grid-template-columns: 28px 24px 1fr auto auto;
75
+ gap: 12px; align-items: center;
76
+ padding: 12px 14px; background: var(--panel); border-bottom: 1px solid var(--border);
77
+ cursor: pointer; transition: background 0.1s;
78
+ }
79
+ .job-row:last-child { border-bottom: 0; }
80
+ .job-row:hover { background: var(--panel-2); }
81
+ .job-row.expanded { background: var(--panel-2); }
82
+
83
+ .dot {
84
+ width: 12px; height: 12px; border-radius: 50%;
85
+ display: inline-block; flex-shrink: 0;
86
+ box-shadow: 0 0 0 2px var(--panel);
87
+ }
88
+ .dot.queued, .dot.pending { background: var(--pending); }
89
+ .dot.running { background: var(--running); animation: pulse 1.4s ease-in-out infinite; }
90
+ .dot.completed, .dot.success { background: var(--ok); }
91
+ .dot.failed, .dot.error { background: var(--err); }
92
+ .dot.cancelled, .dot.canceled { background: var(--warn); }
93
+ .dot.unknown { background: var(--pending); opacity: 0.5; }
94
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
95
+
96
+ .kind { font-size: 16px; opacity: 0.9; text-align: center; }
97
+ .job-title { font-size: 13px; color: var(--text); font-weight: 500; }
98
+ .job-title .id { color: var(--muted); font-family: var(--mono); margin-left: 6px; font-size: 11px; }
99
+ .job-sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
100
+ .job-meta { color: var(--muted); font-size: 12px; font-family: var(--mono); text-align: right; }
101
+ .job-meta .status { display: block; color: var(--text); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; }
102
+ .chevron { color: var(--muted); font-size: 14px; transition: transform 0.15s; }
103
+ .job-row.expanded .chevron { transform: rotate(90deg); }
104
+
105
+ .job-detail {
106
+ background: #0a0d12; border-bottom: 1px solid var(--border);
107
+ padding: 14px 18px; font-family: var(--mono); font-size: 12px;
108
+ display: none;
109
+ }
110
+ .job-row.expanded + .job-detail { display: block; }
111
+
112
+ .job-detail .params { color: var(--muted); margin-bottom: 10px; line-height: 1.6; }
113
+ .job-detail .params b { color: var(--text); font-weight: 500; }
114
+ .job-detail .logs {
115
+ background: #000; border: 1px solid var(--border); border-radius: 6px;
116
+ padding: 10px 12px; max-height: 280px; overflow: auto; white-space: pre-wrap;
117
+ color: #c9d1d9; line-height: 1.5;
118
+ }
119
+ .job-detail .actions { display: flex; gap: 8px; margin-top: 10px; }
120
+
121
+ .empty { padding: 24px; text-align: center; color: var(--muted); font-size: 13px; }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <header>
126
+ <h1>🦊 Slyfox · Jobs</h1>
127
+ <nav class="nav">
128
+ <a href="/">Chat</a>
129
+ <a href="/jobs" class="active">Jobs</a>
130
+ <a href="/community">Community</a>
131
+ </nav>
132
+ </header>
133
+
134
+ <main>
135
+ <!-- Request model -->
136
+ <section class="panel" id="panel-extract">
137
+ <h2>Request a new model <span class="hint">runs emotion-vector extraction on hf jobs</span></h2>
138
+ <div class="grid">
139
+ <div>
140
+ <label for="ex-model">model id</label>
141
+ <input id="ex-model" type="text" placeholder="Qwen/Qwen2.5-7B-Instruct" />
142
+ </div>
143
+ <div>
144
+ <label for="ex-flavor">hardware</label>
145
+ <select id="ex-flavor">
146
+ <option value="a10g-large">a10g-large (default)</option>
147
+ <option value="a10g-small">a10g-small</option>
148
+ <option value="a100-large">a100-large</option>
149
+ <option value="cpu-upgrade">cpu-upgrade</option>
150
+ </select>
151
+ </div>
152
+ </div>
153
+ <div class="row-actions">
154
+ <button id="ex-submit">Submit extraction</button>
155
+ <span class="toast" id="ex-toast"></span>
156
+ </div>
157
+ </section>
158
+
159
+ <!-- Analyze traces -->
160
+ <section class="panel" id="panel-analyze">
161
+ <h2>Analyze traces <span class="hint">replays sessions through a model, emits per-turn emotion trajectories</span></h2>
162
+ <div class="grid">
163
+ <div>
164
+ <label for="an-source">traces bucket</label>
165
+ <input id="an-source" type="text" placeholder="HF-slyfox/traces" value="HF-slyfox/traces" />
166
+ </div>
167
+ <div>
168
+ <label for="an-prefix">prefix (optional)</label>
169
+ <input id="an-prefix" type="text" placeholder="Arthurs-beast/-Users-...-transformers" />
170
+ </div>
171
+ <div>
172
+ <label for="an-n">num traces</label>
173
+ <input id="an-n" type="number" min="1" max="100" value="5" />
174
+ </div>
175
+ <div>
176
+ <label for="an-model">model</label>
177
+ <input id="an-model" type="text" placeholder="google/gemma-2-2b-it" />
178
+ </div>
179
+ <div>
180
+ <label for="an-flavor">hardware</label>
181
+ <select id="an-flavor">
182
+ <option value="a10g-large">a10g-large</option>
183
+ <option value="a10g-small">a10g-small</option>
184
+ <option value="a100-large">a100-large</option>
185
+ </select>
186
+ </div>
187
+ </div>
188
+ <div class="row-actions">
189
+ <button id="an-submit">Submit analysis</button>
190
+ <span class="toast" id="an-toast"></span>
191
+ </div>
192
+ </section>
193
+
194
+ <!-- Job list -->
195
+ <section>
196
+ <h2 style="font-size: 14px; font-weight: 600; margin: 0 0 10px;">Recent runs</h2>
197
+ <div class="jobs" id="jobs"></div>
198
+ </section>
199
+ </main>
200
+
201
+ <script>
202
+ "use strict";
203
+
204
+ // --------------------------------------------------------------------------
205
+ // Tiny per-browser user id, persisted in localStorage. Mirrors index.html.
206
+ // --------------------------------------------------------------------------
207
+
208
+ const USER_KEY = "slyfox.user_id";
209
+ function userId() {
210
+ let id = localStorage.getItem(USER_KEY);
211
+ if (!id) {
212
+ id = "u-" + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
213
+ localStorage.setItem(USER_KEY, id);
214
+ }
215
+ return id;
216
+ }
217
+
218
+ async function api(path, opts = {}) {
219
+ const headers = { "X-User-Id": userId(), ...(opts.headers || {}) };
220
+ if (opts.body && typeof opts.body !== "string") {
221
+ headers["Content-Type"] = "application/json";
222
+ opts.body = JSON.stringify(opts.body);
223
+ }
224
+ const res = await fetch(path, { ...opts, headers });
225
+ if (!res.ok) {
226
+ let err;
227
+ try { err = (await res.json()).detail || res.statusText; } catch { err = res.statusText; }
228
+ throw new Error(err);
229
+ }
230
+ return res.json();
231
+ }
232
+
233
+ // --------------------------------------------------------------------------
234
+ // Submit handlers
235
+ // --------------------------------------------------------------------------
236
+
237
+ function bindSubmit(buttonId, toastId, build, endpoint) {
238
+ const btn = document.getElementById(buttonId);
239
+ const toast = document.getElementById(toastId);
240
+ btn.addEventListener("click", async () => {
241
+ btn.disabled = true;
242
+ toast.textContent = "submitting…";
243
+ toast.className = "toast";
244
+ try {
245
+ const body = build();
246
+ const job = await api(endpoint, { method: "POST", body });
247
+ toast.textContent = "queued: " + job.job_id;
248
+ toast.className = "toast ok";
249
+ refreshJobs();
250
+ } catch (e) {
251
+ toast.textContent = "✗ " + e.message;
252
+ toast.className = "toast err";
253
+ } finally {
254
+ btn.disabled = false;
255
+ }
256
+ });
257
+ }
258
+
259
+ bindSubmit("ex-submit", "ex-toast", () => ({
260
+ model_id: document.getElementById("ex-model").value.trim(),
261
+ flavor: document.getElementById("ex-flavor").value,
262
+ }), "/api/models/extract");
263
+
264
+ bindSubmit("an-submit", "an-toast", () => ({
265
+ source: document.getElementById("an-source").value.trim(),
266
+ prefix: document.getElementById("an-prefix").value.trim(),
267
+ n_traces: parseInt(document.getElementById("an-n").value, 10),
268
+ model_id: document.getElementById("an-model").value.trim(),
269
+ flavor: document.getElementById("an-flavor").value,
270
+ }), "/api/analyses");
271
+
272
+ // --------------------------------------------------------------------------
273
+ // Job list rendering
274
+ // --------------------------------------------------------------------------
275
+
276
+ const jobsRoot = document.getElementById("jobs");
277
+ const expanded = new Set();
278
+ const logCache = new Map();
279
+ let logTimer = null;
280
+
281
+ const KIND_ICON = { extract: "🧠", analyze: "🧪" };
282
+
283
+ function statusClass(s) {
284
+ if (!s) return "unknown";
285
+ s = String(s).toLowerCase();
286
+ if (s.includes("run")) return "running";
287
+ if (s.includes("queue") || s.includes("pend") || s.includes("start")) return "queued";
288
+ if (s.includes("complete") || s.includes("succ") || s.includes("done")) return "completed";
289
+ if (s.includes("fail") || s.includes("err")) return "failed";
290
+ if (s.includes("cancel")) return "cancelled";
291
+ return "unknown";
292
+ }
293
+
294
+ function fmtElapsed(s) {
295
+ if (!s && s !== 0) return "—";
296
+ const m = Math.floor(s / 60), sec = s % 60;
297
+ if (m === 0) return sec + "s";
298
+ if (m < 60) return m + "m " + sec + "s";
299
+ const h = Math.floor(m / 60);
300
+ return h + "h " + (m % 60) + "m";
301
+ }
302
+
303
+ function jobTitle(j) {
304
+ if (j.kind === "extract") return "Extract " + (j.params?.model_id || "?");
305
+ if (j.kind === "analyze") return "Analyze " + (j.params?.source || "?") + " · " + (j.params?.model_id || "?");
306
+ return j.job_id;
307
+ }
308
+
309
+ function jobSubtitle(j) {
310
+ if (j.kind === "extract") return "→ " + (j.params?.output || "");
311
+ if (j.kind === "analyze") {
312
+ const n = j.params?.n_traces ?? "?";
313
+ return n + " traces → " + (j.params?.output || "");
314
+ }
315
+ return "";
316
+ }
317
+
318
+ function paramRow(label, value) {
319
+ if (!value) return "";
320
+ return `<div><b>${label}</b>: ${escapeHtml(String(value))}</div>`;
321
+ }
322
+
323
+ function escapeHtml(s) {
324
+ return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
325
+ }
326
+
327
+ function render(jobs) {
328
+ if (!jobs.length) {
329
+ jobsRoot.innerHTML = `<div class="empty">No jobs yet — submit one above to get started.</div>`;
330
+ return;
331
+ }
332
+ jobsRoot.innerHTML = jobs.map(j => {
333
+ const sCls = statusClass(j.live?.status);
334
+ const elapsed = fmtElapsed(j.live?.elapsed_s);
335
+ const isOpen = expanded.has(j.job_id);
336
+ const logs = (logCache.get(j.job_id) || []).join("\n");
337
+ const canCancel = sCls === "running" || sCls === "queued";
338
+ return `
339
+ <div class="job-row ${isOpen ? "expanded" : ""}" data-id="${j.job_id}">
340
+ <span class="dot ${sCls}" title="${j.live?.status ?? "unknown"}"></span>
341
+ <span class="kind">${KIND_ICON[j.kind] || "▶"}</span>
342
+ <div>
343
+ <div class="job-title">${escapeHtml(jobTitle(j))}<span class="id">${j.job_id.slice(0, 12)}</span></div>
344
+ <div class="job-sub">${escapeHtml(jobSubtitle(j))}</div>
345
+ </div>
346
+ <div class="job-meta"><span class="status">${(j.live?.status || "unknown")}</span>${elapsed}</div>
347
+ <span class="chevron">▶</span>
348
+ </div>
349
+ <div class="job-detail" data-id="${j.job_id}">
350
+ <div class="params">
351
+ ${paramRow("submitted", j.submitted_at)}
352
+ ${paramRow("requester", j.requester)}
353
+ ${paramRow("flavor", j.params?.flavor || j.live?.flavor)}
354
+ ${paramRow("started", j.live?.started_at)}
355
+ ${paramRow("ended", j.live?.ended_at)}
356
+ ${j.kind === "analyze" ? paramRow("source", j.params?.source) : ""}
357
+ ${j.kind === "analyze" ? paramRow("prefix", j.params?.prefix) : ""}
358
+ ${j.kind === "analyze" ? paramRow("n_traces", j.params?.n_traces) : ""}
359
+ ${paramRow("output", j.params?.output)}
360
+ </div>
361
+ <div class="logs" id="logs-${j.job_id}">${escapeHtml(logs) || "<i style=\\"color:#666\\">no logs yet</i>"}</div>
362
+ <div class="actions">
363
+ <button class="secondary" data-act="refresh-logs" data-id="${j.job_id}">Refresh logs</button>
364
+ ${canCancel ? `<button class="danger" data-act="cancel" data-id="${j.job_id}">Cancel</button>` : ""}
365
+ </div>
366
+ </div>
367
+ `;
368
+ }).join("");
369
+ }
370
+
371
+ jobsRoot.addEventListener("click", async (ev) => {
372
+ const actBtn = ev.target.closest("button[data-act]");
373
+ if (actBtn) {
374
+ const id = actBtn.dataset.id;
375
+ if (actBtn.dataset.act === "refresh-logs") { await refreshLogs(id); return; }
376
+ if (actBtn.dataset.act === "cancel") {
377
+ if (!confirm("Cancel job " + id + "?")) return;
378
+ try {
379
+ await api(`/api/jobs/${id}/cancel`, { method: "POST" });
380
+ refreshJobs();
381
+ } catch (e) { alert("cancel failed: " + e.message); }
382
+ }
383
+ return;
384
+ }
385
+ const row = ev.target.closest(".job-row");
386
+ if (!row) return;
387
+ const id = row.dataset.id;
388
+ if (expanded.has(id)) expanded.delete(id);
389
+ else { expanded.add(id); await refreshLogs(id); }
390
+ refreshJobs(/* keepOpen */ true);
391
+ });
392
+
393
+ async function refreshLogs(id) {
394
+ try {
395
+ const data = await api(`/api/jobs/${id}/logs?tail=200`);
396
+ logCache.set(id, data.lines || []);
397
+ const el = document.getElementById("logs-" + id);
398
+ if (el) {
399
+ el.textContent = (data.lines || []).join("\n");
400
+ el.scrollTop = el.scrollHeight;
401
+ }
402
+ } catch (e) {
403
+ const el = document.getElementById("logs-" + id);
404
+ if (el) el.textContent = "(logs: " + e.message + ")";
405
+ }
406
+ }
407
+
408
+ let inFlight = false;
409
+ async function refreshJobs(_keepOpen = false) {
410
+ if (inFlight) return;
411
+ inFlight = true;
412
+ try {
413
+ const jobs = await api("/api/jobs");
414
+ render(jobs);
415
+ } catch (e) {
416
+ jobsRoot.innerHTML = `<div class="empty" style="color:var(--err)">${escapeHtml(e.message)}</div>`;
417
+ } finally { inFlight = false; }
418
+ }
419
+
420
+ // Initial load + polling.
421
+ refreshJobs();
422
+ setInterval(refreshJobs, 5000);
423
+
424
+ // Refresh logs for expanded rows on a tighter cadence.
425
+ setInterval(() => expanded.forEach(id => refreshLogs(id)), 3000);
426
+ </script>
427
+ </body>
428
+ </html>