celik-muhammed commited on
Commit
99575fe
Β·
verified Β·
1 Parent(s): 8ca3e24

Upload 6 files

Browse files
Files changed (3) hide show
  1. DATASET_COLLECTION_GUIDANCE.md +2 -2
  2. _shared_logic.py +30 -24
  3. app.py +133 -127
DATASET_COLLECTION_GUIDANCE.md CHANGED
@@ -1,7 +1,7 @@
1
  # Dataset Collection Guidance
2
 
3
- **Applies to:** `scikit-plots/ai-assistant-contributions` (HuggingFace Dataset)
4
- **Version:** 1.0
5
  **Maintained by:** scikit-plots maintainers
6
 
7
  ---
 
1
  # Dataset Collection Guidance
2
 
3
+ **Applies to:** `scikit-plots/ai-assistant-contributions` (HuggingFace Dataset)
4
+ **Version:** 1.0
5
  **Maintained by:** scikit-plots maintainers
6
 
7
  ---
_shared_logic.py CHANGED
@@ -156,7 +156,7 @@ import json
156
  import os
157
  from typing import Any, Literal
158
 
159
- __all__ = [
160
  # Version
161
  "PROXY_VERSION",
162
  # Constants β€” routing / timeout
@@ -303,20 +303,20 @@ HFTokenType = Literal["fine-grained", "read", "write", "unknown"]
303
 
304
  #: New-style fine-grained HF token. Permissions defined at creation time.
305
  #: Declare via env var: ``HF_TOKEN_TYPE=fine-grained``.
306
- HF_TOKEN_TYPE_FINE_GRAINED: str = "fine-grained"
307
 
308
  #: Classic HF read token. Read + Inference API; no write capability.
309
  #: Declare via env var: ``HF_TOKEN_TYPE=read``.
310
- HF_TOKEN_TYPE_READ: str = "read"
311
 
312
  #: Classic HF write token. All read permissions + repo push capability.
313
  #: Declare via env var: ``HF_TOKEN_TYPE=write`` or ``HF_WRITE_TOKEN_TYPE=write``.
314
- HF_TOKEN_TYPE_WRITE: str = "write"
315
 
316
  #: Sentinel: token type not declared and could not be inferred.
317
  #: Runtime operations are not blocked, but :func:`_validate_token_config` omits
318
  #: least-privilege warnings because the type is unknown.
319
- HF_TOKEN_TYPE_UNKNOWN: str = "unknown"
320
 
321
  #: Token types that are appropriate for HF Serverless Inference API calls
322
  #: (Path 3) and private HF Space access (Path 2).
@@ -326,11 +326,13 @@ HF_TOKEN_TYPE_UNKNOWN: str = "unknown"
326
  #: a startup warning when a write token is used where a read / fine-grained
327
  #: token is the correct choice. The ``"unknown"`` sentinel is included so
328
  #: that un-declared tokens do not trigger false-positive warnings.
329
- HF_INFERENCE_TOKEN_TYPES: frozenset[str] = frozenset({
330
- HF_TOKEN_TYPE_FINE_GRAINED,
331
- HF_TOKEN_TYPE_READ,
332
- HF_TOKEN_TYPE_UNKNOWN,
333
- })
 
 
334
 
335
  #: Token types that can push commits to HuggingFace repos and datasets.
336
  #:
@@ -338,10 +340,12 @@ HF_INFERENCE_TOKEN_TYPES: frozenset[str] = frozenset({
338
  #: returns HTTP 403 / 401. ``"unknown"`` is excluded so that
339
  #: :func:`_validate_token_config` can flag a read token configured as the write
340
  #: token as a hard error rather than silently failing at request time.
341
- HF_WRITE_TOKEN_TYPES: frozenset[str] = frozenset({
342
- HF_TOKEN_TYPE_FINE_GRAINED,
343
- HF_TOKEN_TYPE_WRITE,
344
- })
 
 
345
 
346
 
347
  # ─────────────────────────────────────────────────────────────────────────────
@@ -673,10 +677,10 @@ def _classify_token_type(
673
  # Normalise accepted declared-type values (tolerate minor formatting variants).
674
  _declared_map: dict[str, HFTokenType] = {
675
  "fine-grained": "fine-grained",
676
- "finegrained": "fine-grained",
677
  "fine_grained": "fine-grained",
678
- "read": "read",
679
- "write": "write",
680
  }
681
  if declared_type:
682
  normalised = _declared_map.get(declared_type.lower().strip())
@@ -854,7 +858,8 @@ def _validate_token_config(
854
  Read token for writes β†’ ERROR:
855
 
856
  >>> msgs = _validate_token_config(
857
- ... "hf_tok", "hf_readtok",
 
858
  ... training_dataset_repo="org/dataset",
859
  ... hf_write_token_type="read",
860
  ... )
@@ -908,6 +913,7 @@ def _validate_token_config(
908
 
909
  return messages
910
 
 
911
  def _resolve_upstream_url(
912
  body: bytes,
913
  *,
@@ -1189,19 +1195,19 @@ def load_proxy_env() -> dict[str, Any]:
1189
  )
1190
 
1191
  return {
1192
- "backend_url": os.environ.get("BACKEND_URL", "").strip(),
1193
- "hf_token": _hf_token,
1194
  # Dedicated write token for dataset push operations.
1195
  # Principle of least privilege: set HF_WRITE_TOKEN to a write-scoped
1196
  # fine-grained token so HF_TOKEN can remain read-only / inference-only.
1197
  # Falls back to hf_token when absent for backward compatibility.
1198
- "hf_write_token": _hf_write_token,
1199
- "hf_dataset_token": _hf_write_token or _hf_token,
1200
  # Token type metadata β€” used by _validate_token_config at startup.
1201
  # Operators can override auto-detection by setting HF_TOKEN_TYPE /
1202
  # HF_WRITE_TOKEN_TYPE to "fine-grained", "read", or "write".
1203
- "hf_token_type": _hf_token_type,
1204
- "hf_write_token_type": _hf_write_token_type,
1205
  "hf_base": os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/"),
1206
  "default_model": (
1207
  os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
 
156
  import os
157
  from typing import Any, Literal
158
 
159
+ __all__ = [ # noqa: RUF022
160
  # Version
161
  "PROXY_VERSION",
162
  # Constants β€” routing / timeout
 
303
 
304
  #: New-style fine-grained HF token. Permissions defined at creation time.
305
  #: Declare via env var: ``HF_TOKEN_TYPE=fine-grained``.
306
+ HF_TOKEN_TYPE_FINE_GRAINED: str = "fine-grained" # noqa: S105
307
 
308
  #: Classic HF read token. Read + Inference API; no write capability.
309
  #: Declare via env var: ``HF_TOKEN_TYPE=read``.
310
+ HF_TOKEN_TYPE_READ: str = "read" # noqa: S105
311
 
312
  #: Classic HF write token. All read permissions + repo push capability.
313
  #: Declare via env var: ``HF_TOKEN_TYPE=write`` or ``HF_WRITE_TOKEN_TYPE=write``.
314
+ HF_TOKEN_TYPE_WRITE: str = "write" # noqa: S105
315
 
316
  #: Sentinel: token type not declared and could not be inferred.
317
  #: Runtime operations are not blocked, but :func:`_validate_token_config` omits
318
  #: least-privilege warnings because the type is unknown.
319
+ HF_TOKEN_TYPE_UNKNOWN: str = "unknown" # noqa: S105
320
 
321
  #: Token types that are appropriate for HF Serverless Inference API calls
322
  #: (Path 3) and private HF Space access (Path 2).
 
326
  #: a startup warning when a write token is used where a read / fine-grained
327
  #: token is the correct choice. The ``"unknown"`` sentinel is included so
328
  #: that un-declared tokens do not trigger false-positive warnings.
329
+ HF_INFERENCE_TOKEN_TYPES: frozenset[str] = frozenset(
330
+ {
331
+ HF_TOKEN_TYPE_FINE_GRAINED,
332
+ HF_TOKEN_TYPE_READ,
333
+ HF_TOKEN_TYPE_UNKNOWN,
334
+ }
335
+ )
336
 
337
  #: Token types that can push commits to HuggingFace repos and datasets.
338
  #:
 
340
  #: returns HTTP 403 / 401. ``"unknown"`` is excluded so that
341
  #: :func:`_validate_token_config` can flag a read token configured as the write
342
  #: token as a hard error rather than silently failing at request time.
343
+ HF_WRITE_TOKEN_TYPES: frozenset[str] = frozenset(
344
+ {
345
+ HF_TOKEN_TYPE_FINE_GRAINED,
346
+ HF_TOKEN_TYPE_WRITE,
347
+ }
348
+ )
349
 
350
 
351
  # ─────────────────────────────────────────────────────────────────────────────
 
677
  # Normalise accepted declared-type values (tolerate minor formatting variants).
678
  _declared_map: dict[str, HFTokenType] = {
679
  "fine-grained": "fine-grained",
680
+ "finegrained": "fine-grained",
681
  "fine_grained": "fine-grained",
682
+ "read": "read",
683
+ "write": "write",
684
  }
685
  if declared_type:
686
  normalised = _declared_map.get(declared_type.lower().strip())
 
858
  Read token for writes β†’ ERROR:
859
 
860
  >>> msgs = _validate_token_config(
861
+ ... "hf_tok",
862
+ ... "hf_readtok",
863
  ... training_dataset_repo="org/dataset",
864
  ... hf_write_token_type="read",
865
  ... )
 
913
 
914
  return messages
915
 
916
+
917
  def _resolve_upstream_url(
918
  body: bytes,
919
  *,
 
1195
  )
1196
 
1197
  return {
1198
+ "backend_url": os.environ.get("BACKEND_URL", "").strip(),
1199
+ "hf_token": _hf_token,
1200
  # Dedicated write token for dataset push operations.
1201
  # Principle of least privilege: set HF_WRITE_TOKEN to a write-scoped
1202
  # fine-grained token so HF_TOKEN can remain read-only / inference-only.
1203
  # Falls back to hf_token when absent for backward compatibility.
1204
+ "hf_write_token": _hf_write_token,
1205
+ "hf_dataset_token": _hf_write_token or _hf_token,
1206
  # Token type metadata β€” used by _validate_token_config at startup.
1207
  # Operators can override auto-detection by setting HF_TOKEN_TYPE /
1208
  # HF_WRITE_TOKEN_TYPE to "fine-grained", "read", or "write".
1209
+ "hf_token_type": _hf_token_type,
1210
+ "hf_write_token_type": _hf_write_token_type,
1211
  "hf_base": os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/"),
1212
  "default_model": (
1213
  os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
app.py CHANGED
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  # scikit-plots/ai Β· _hf_spaces_proxy/app.py v6.1.0
2
  #
3
  # Thin OpenAI-compatible reverse proxy for sphinx-ai-assistant.
@@ -117,6 +124,7 @@ import asyncio
117
  import json
118
  import logging
119
  import os
 
120
  import uuid
121
  from collections.abc import AsyncGenerator
122
  from contextlib import asynccontextmanager
@@ -128,50 +136,30 @@ from fastapi.middleware.cors import CORSMiddleware
128
  from fastapi.responses import JSONResponse, Response, StreamingResponse
129
 
130
  # _shared_logic.py must live alongside this file.
131
- try:
132
- from _shared_logic import ( # type: ignore[import]
133
- DEFAULT_HF_BASE,
134
- DEFAULT_HF_SPACES_MODEL_NAMESPACES,
135
- DEFAULT_HF_SPACES_MODEL_URL,
136
- DEFAULT_MAX_BODY_BYTES,
137
- DEFAULT_MODEL,
138
- DEFAULT_PATH2_READ_TIMEOUT,
139
- DEFAULT_PATH3_READ_TIMEOUT,
140
- DEFAULT_PROXY_TIMEOUT,
141
- PROXY_VERSION,
142
- _classify_token_type,
143
- _resolve_upstream_url,
144
- _safe_float,
145
- _safe_int,
146
- _token_log_fragment,
147
- _validate_env,
148
- _validate_token_config,
149
- )
150
- except ImportError:
151
- from .._shared_logic import ( # type: ignore[import]
152
- DEFAULT_HF_BASE,
153
- DEFAULT_HF_SPACES_MODEL_NAMESPACES,
154
- DEFAULT_HF_SPACES_MODEL_URL,
155
- DEFAULT_MAX_BODY_BYTES,
156
- DEFAULT_MODEL,
157
- DEFAULT_PATH2_READ_TIMEOUT,
158
- DEFAULT_PATH3_READ_TIMEOUT,
159
- DEFAULT_PROXY_TIMEOUT,
160
- PROXY_VERSION,
161
- _classify_token_type,
162
- _resolve_upstream_url,
163
- _safe_float,
164
- _safe_int,
165
- _token_log_fragment,
166
- _validate_env,
167
- _validate_token_config,
168
- )
169
-
170
 
171
  # ─────────────────────────────────────────────────────────────────────────────
172
  # Helpers β€” placed before configuration so they are available at module scope
173
  # ─────────────────────────────────────────────────────────────────────────────
174
 
 
175
  def _client_ip(request: Request) -> str:
176
  """Extract the real client IP from the request.
177
 
@@ -204,6 +192,7 @@ def _client_ip(request: Request) -> str:
204
  # Logging
205
  # ─────────────────────────────────────────────────────────────────────────────
206
 
 
207
  class _StructuredFormatter(logging.Formatter):
208
  """Emit one JSON object per log record to stdout.
209
 
@@ -224,10 +213,10 @@ class _StructuredFormatter(logging.Formatter):
224
 
225
  def format(self, record: logging.LogRecord) -> str: # noqa: A003
226
  payload: dict = {
227
- "ts": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
228
- "level": record.levelname,
229
  "logger": record.name,
230
- "event": record.getMessage(),
231
  }
232
  if record.exc_info:
233
  payload["exc_info"] = self.formatException(record.exc_info)
@@ -372,7 +361,9 @@ _path3_timeout_secs: float = _safe_float(
372
  #: TCP handshake timeout in seconds.
373
  #: Uses ``_safe_float`` β€” a non-numeric env var logs a warning and falls back
374
  #: to the default rather than raising ``ValueError`` at startup.
375
- _connect_timeout_secs: float = _safe_float(os.environ.get("PROXY_CONNECT_TIMEOUT"), 10.0)
 
 
376
  #: Request body upload timeout in seconds.
377
  _write_timeout_secs: float = _safe_float(os.environ.get("PROXY_WRITE_TIMEOUT"), 30.0)
378
  #: Connection pool acquire timeout in seconds.
@@ -437,7 +428,7 @@ MAX_CONTRIBUTION_RECORDS: int = 100
437
  #: When the dict would exceed this size a full sweep removes all entries whose
438
  #: sliding window has expired before the new entry is stored. This bounds
439
  #: memory to O(_MAX_RL_ENTRIES) even under a slow-drip unique-IP scan that
440
- #: never re-uses the same address. Value chosen to be generous for legitimate
441
  #: traffic (thousands of real users) while preventing unbounded growth.
442
  _MAX_RL_ENTRIES: int = 10_000
443
 
@@ -554,7 +545,11 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
554
  HF_SPACES_MODEL_URL or None,
555
  list(HF_SPACES_MODEL_NAMESPACES),
556
  _token_log_fragment(HF_TOKEN, token_type=HF_TOKEN_TYPE),
557
- _token_log_fragment(HF_WRITE_TOKEN, token_type=HF_WRITE_TOKEN_TYPE) if HF_WRITE_TOKEN else "<using-hf-token-fallback>",
 
 
 
 
558
  DEFAULT_MODEL,
559
  )
560
  logger.info(
@@ -964,11 +959,11 @@ async def root() -> JSONResponse:
964
  "status": "ok",
965
  "service": f"sphinx-ai-assistant proxy v{PROXY_VERSION}",
966
  "routing": {
967
- "path_1_backend_url": BACKEND_URL or None,
968
- "path_2_model_space_url": HF_SPACES_MODEL_URL or None,
969
- "path_2_namespaces": list(HF_SPACES_MODEL_NAMESPACES),
970
- "path_3_hf_api_base": HF_BASE,
971
- "path_3_hf_token_set": bool(HF_TOKEN),
972
  },
973
  "tokens": {
974
  # Never expose token values β€” only whether each slot is filled.
@@ -977,17 +972,17 @@ async def root() -> JSONResponse:
977
  # reading Space logs. Possible values: fine-grained | read | write |
978
  # unknown. "unknown" means the type could not be inferred β€” set
979
  # HF_TOKEN_TYPE / HF_WRITE_TOKEN_TYPE in Space secrets to resolve.
980
- "hf_token_set": bool(HF_TOKEN),
981
- "hf_token_type": HF_TOKEN_TYPE,
982
- "hf_write_token_set": bool(HF_WRITE_TOKEN),
983
- "hf_write_token_type": HF_WRITE_TOKEN_TYPE,
984
  # True when HF_WRITE_TOKEN is set; False means writes fall
985
  # back to HF_TOKEN (single-token mode, less secure).
986
  "least_privilege_mode": bool(HF_WRITE_TOKEN),
987
  },
988
  "training": {
989
- "dataset_repo": TRAINING_DATASET_REPO or None,
990
- "contribute_ready": bool(TRAINING_DATASET_REPO and HF_DATASET_TOKEN),
991
  "feedback_persist_enabled": FEEDBACK_PERSIST_ENABLED,
992
  },
993
  "timeouts": {
@@ -999,13 +994,13 @@ async def root() -> JSONResponse:
999
  },
1000
  "cors_origins": _allowed_origins,
1001
  "endpoints": {
1002
- "chat": "POST /v1/chat/completions (primary)",
1003
- "share": "POST /v1/share (conversation share)",
1004
  "share_get": "GET /v1/share/{uuid} (retrieve shared snapshot)",
1005
- "feedback": "POST /v1/feedback (rating persistence)",
1006
- "training": "POST /v1/contribute (GDPR-gated training data)",
1007
- "alias": "POST / (path-agnostic alias)",
1008
- "health": "GET /health (liveness probe)",
1009
  "head_root": "HEAD / (health-monitor probe)",
1010
  "head_health": "HEAD /health (health-monitor probe)",
1011
  },
@@ -1086,7 +1081,7 @@ async def chat_completions_alias(body: bytes = Depends(_validated_body)) -> Resp
1086
 
1087
 
1088
  @app.post("/v1/contribute")
1089
- async def contribute(request: Request) -> JSONResponse:
1090
  """Accept a training data contribution from the AI assistant browser widget.
1091
 
1092
  Parameters
@@ -1125,8 +1120,6 @@ async def contribute(request: Request) -> JSONResponse:
1125
  traffic level this is acceptable; if throughput grows, wrap in
1126
  ``asyncio.get_event_loop().run_in_executor(None, ...)`` instead.
1127
  """
1128
- import time as _time
1129
-
1130
  # Body size guard
1131
  raw = await request.body()
1132
  if len(raw) > DEFAULT_MAX_BODY_BYTES:
@@ -1176,12 +1169,14 @@ async def contribute(request: Request) -> JSONResponse:
1176
  async with _contrib_rl_lock:
1177
  now = _time.time()
1178
  count, window_start = _contrib_rl.get(client_ip, (0, now))
1179
- if now - window_start > 3600:
1180
  count, window_start = 0, now
1181
  count += 1
1182
  _contrib_rl[client_ip] = (count, window_start)
1183
- if count > 5:
1184
- logger.warning(json.dumps({"event": "contribute.ratelimit", "ip": client_ip}))
 
 
1185
  raise HTTPException(
1186
  status_code=429,
1187
  detail="Rate limit exceeded. Maximum 5 contributions per hour.",
@@ -1209,7 +1204,7 @@ async def contribute(request: Request) -> JSONResponse:
1209
  # HF_DATASET_TOKEN resolves to HF_WRITE_TOKEN when set, else HF_TOKEN.
1210
  # This ensures the inference token (HF_TOKEN, read-only) is never used
1211
  # for dataset write operations when a dedicated write token is available.
1212
- from huggingface_hub import CommitOperationAdd, HfApi
1213
 
1214
  api = HfApi(token=HF_DATASET_TOKEN)
1215
  session_id: str = payload.get("sessionId", "")
@@ -1217,23 +1212,23 @@ async def contribute(request: Request) -> JSONResponse:
1217
  json.dumps(
1218
  {
1219
  **rec,
1220
- "_sessionId": session_id,
1221
- "_page": payload.get("page", ""),
1222
- "_model": payload.get("model"),
1223
  "_consentVersion": payload.get("consentVersion"),
1224
- "_ts": int(_time.time() * 1000),
1225
  # Provenance tag: distinguishes records that arrived via the
1226
  # explicit GDPR-gated "Contribute" button (end-of-session) from
1227
  # records that arrived via the immediate per-answer rating button
1228
  # (POST /v1/feedback). Training pipelines MUST prefer
1229
  # "contribution" over "feedback" when both exist for the same
1230
  # dedup key. See DATASET_COLLECTION_GUIDANCE.md.
1231
- "_source": "contribution",
1232
  # Stable dedup key: "{conversationSessionId}:{answerIndex}".
1233
  # Identical key format used by /v1/feedback (when
1234
  # FEEDBACK_PERSIST_ENABLED=true) so a simple equality check
1235
  # across folders is sufficient to detect cross-source duplicates.
1236
- "_dedup_key": f"{session_id}:{rec.get('answerIndex', '')}",
1237
  },
1238
  ensure_ascii=False,
1239
  )
@@ -1246,12 +1241,16 @@ async def contribute(request: Request) -> JSONResponse:
1246
  # the dataset; fail fast with a clear error before touching HuggingFace.
1247
  if not rows_jsonl.strip():
1248
  logger.error(
1249
- json.dumps({
1250
- "event": "contribute.empty_jsonl",
1251
- "records": len(records),
1252
- "detail": "rows_jsonl is empty after serialising records; "
1253
- "aborting HF commit to prevent corrupt dataset file.",
1254
- })
 
 
 
 
1255
  )
1256
  raise HTTPException(
1257
  status_code=422,
@@ -1288,7 +1287,9 @@ async def contribute(request: Request) -> JSONResponse:
1288
  detail="Failed to store contribution. Try again later.",
1289
  ) from exc
1290
 
1291
- logger.info(json.dumps({"event": "contribute.write", "rows": len(records), "ip": client_ip}))
 
 
1292
  return JSONResponse({"contributed": True, "rows": len(records)})
1293
 
1294
 
@@ -1335,8 +1336,6 @@ async def share(request: Request) -> JSONResponse:
1335
  permissive than ``/v1/contribute`` because share payloads are user-facing
1336
  outputs, not GDPR-sensitive training data.
1337
  """
1338
- import time as _time
1339
-
1340
  # Body size guard
1341
  raw = await request.body()
1342
  if len(raw) > DEFAULT_MAX_BODY_BYTES:
@@ -1365,11 +1364,11 @@ async def share(request: Request) -> JSONResponse:
1365
  async with _share_rl_lock:
1366
  now = _time.time()
1367
  count, window_start = _share_rl.get(client_ip, (0, now))
1368
- if now - window_start > 3600:
1369
  count, window_start = 0, now
1370
  count += 1
1371
  _share_rl[client_ip] = (count, window_start)
1372
- if count > 10:
1373
  logger.warning(json.dumps({"event": "share.ratelimit", "ip": client_ip}))
1374
  raise HTTPException(
1375
  status_code=429,
@@ -1384,21 +1383,28 @@ async def share(request: Request) -> JSONResponse:
1384
 
1385
  # Store in memory; evict expired entries lazily on each write.
1386
  async with _share_store_lock:
1387
- expired_keys = [k for k, v in _share_store.items() if v["expiresAt_ts"] < now_ts]
 
 
1388
  for k in expired_keys:
1389
  del _share_store[k]
1390
  _share_store[share_id] = {
1391
- "content": content,
1392
- "mimeType": mime_type,
1393
- "ext": ext,
1394
- "title": title,
1395
  "expiresAt_ts": expires_ts,
1396
- "expiresAt": expires_iso,
1397
  }
1398
 
1399
  logger.info(
1400
  json.dumps(
1401
- {"event": "share.create", "id": share_id, "ip": client_ip, "ttl_days": ttl_days}
 
 
 
 
 
1402
  )
1403
  )
1404
 
@@ -1434,8 +1440,6 @@ async def share_get(share_id: str) -> Response:
1434
  **User note** β€” Shares are stored in process memory only. A Space
1435
  restart clears all shares, returning 404 for previously valid links.
1436
  """
1437
- import time as _time
1438
-
1439
  async with _share_store_lock:
1440
  entry = _share_store.get(share_id)
1441
 
@@ -1452,7 +1456,9 @@ async def share_get(share_id: str) -> Response:
1452
  status_code=200,
1453
  media_type=entry.get("mimeType", "text/html;charset=utf-8"),
1454
  headers={
1455
- "Content-Disposition": f'inline; filename="share{entry.get("ext", ".html")}"',
 
 
1456
  },
1457
  )
1458
 
@@ -1521,8 +1527,6 @@ async def feedback(request: Request) -> JSONResponse:
1521
  the ``feedback`` record β€” the GDPR-gated explicit contribution is the
1522
  higher-quality signal.
1523
  """
1524
- import time as _time
1525
-
1526
  # Body size guard
1527
  raw = await request.body()
1528
  if len(raw) > DEFAULT_MAX_BODY_BYTES:
@@ -1534,7 +1538,9 @@ async def feedback(request: Request) -> JSONResponse:
1534
  raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") from exc
1535
 
1536
  if not isinstance(payload, dict):
1537
- raise HTTPException(status_code=422, detail="Feedback body must be a JSON object.")
 
 
1538
 
1539
  # ── Distinguish retraction tombstones from regular ratings ───────────────
1540
  # Retractions are system-generated housekeeping records that invalidate a
@@ -1562,13 +1568,13 @@ async def feedback(request: Request) -> JSONResponse:
1562
  logger.info(
1563
  json.dumps(
1564
  {
1565
- "event": "feedback.retract",
1566
- "ip": client_ip,
1567
- "prevSessionId": payload.get("prevSessionId"),
1568
  "conversationId": payload.get("conversationId"),
1569
- "answerIndex": payload.get("answerIndex"),
1570
- "ts": payload.get("ts"),
1571
- "persist": FEEDBACK_PERSIST_ENABLED,
1572
  }
1573
  )
1574
  )
@@ -1586,12 +1592,14 @@ async def feedback(request: Request) -> JSONResponse:
1586
  for k in expired:
1587
  del _feedback_rl[k]
1588
  count, window_start = _feedback_rl.get(client_ip, (0, now))
1589
- if now - window_start > 3600:
1590
  count, window_start = 0, now
1591
  count += 1
1592
  _feedback_rl[client_ip] = (count, window_start)
1593
- if count > 30:
1594
- logger.warning(json.dumps({"event": "feedback.ratelimit", "ip": client_ip}))
 
 
1595
  raise HTTPException(
1596
  status_code=429,
1597
  detail="Rate limit exceeded. Maximum 30 feedback submissions per hour.",
@@ -1601,15 +1609,15 @@ async def feedback(request: Request) -> JSONResponse:
1601
  logger.info(
1602
  json.dumps(
1603
  {
1604
- "event": "feedback.receive",
1605
- "ip": client_ip,
1606
- "ratingValue": payload.get("ratingValue"),
1607
- "model": payload.get("model"),
1608
  "conversationId": payload.get("conversationId"),
1609
- "answerIndex": payload.get("answerIndex"),
1610
- "page": payload.get("page"),
1611
- "ts": payload.get("ts"),
1612
- "persist": FEEDBACK_PERSIST_ENABLED,
1613
  }
1614
  )
1615
  )
@@ -1627,7 +1635,7 @@ async def feedback(request: Request) -> JSONResponse:
1627
  # rating also wasn't persisted, so there is nothing to suppress.
1628
  if FEEDBACK_PERSIST_ENABLED and TRAINING_DATASET_REPO and HF_DATASET_TOKEN:
1629
  try:
1630
- from huggingface_hub import CommitOperationAdd, HfApi
1631
 
1632
  # ``conversationId`` is the stable per-page-load session UUID that
1633
  # the JS widget sets as ``detail.conversationId = _sessionId``.
@@ -1641,7 +1649,7 @@ async def feedback(request: Request) -> JSONResponse:
1641
  record = json.dumps(
1642
  {
1643
  **payload,
1644
- "_ts": int(_time.time() * 1000),
1645
  # Provenance β€” training pipelines must discard "feedback"
1646
  # records whenever a "contribution" record exists for the
1647
  # same _dedup_key. Retraction tombstones also use
@@ -1650,11 +1658,11 @@ async def feedback(request: Request) -> JSONResponse:
1650
  # overrides retraction). deduplicate_dataset.py filters
1651
  # action="retract" winners from the clean output.
1652
  # See DATASET_COLLECTION_GUIDANCE.md Β§3 and Β§8.
1653
- "_source": "feedback",
1654
  # "{conversationId}:{answerIndex}" β€” identical key format
1655
  # to contribution records; equality check is sufficient to
1656
  # detect cross-source duplicates.
1657
- "_dedup_key": f"{conversation_id}:{answer_index}",
1658
  },
1659
  ensure_ascii=False,
1660
  )
@@ -1681,10 +1689,10 @@ async def feedback(request: Request) -> JSONResponse:
1681
  logger.info(
1682
  json.dumps(
1683
  {
1684
- "event": "feedback.persist_ok",
1685
- "retract": is_retract,
1686
- "dedup_key": f"{conversation_id}:{answer_index}",
1687
- "ip": client_ip,
1688
  }
1689
  )
1690
  )
@@ -1692,9 +1700,7 @@ async def feedback(request: Request) -> JSONResponse:
1692
  # Never propagate β€” the JS widget does not read the response body
1693
  # and the user's rating must always be acknowledged with 200.
1694
  logger.error(
1695
- json.dumps(
1696
- {"event": "feedback.persist_fail", "error": str(exc)}
1697
- )
1698
  )
1699
 
1700
  return JSONResponse({"ok": True})
 
1
+ # scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/app.py
2
+ #
3
+ # flake8: noqa: D213
4
+ #
5
+ # Authors: The scikit-plots developers
6
+ # SPDX-License-Identifier: BSD-3-Clause
7
+ #
8
  # scikit-plots/ai Β· _hf_spaces_proxy/app.py v6.1.0
9
  #
10
  # Thin OpenAI-compatible reverse proxy for sphinx-ai-assistant.
 
124
  import json
125
  import logging
126
  import os
127
+ import time as _time
128
  import uuid
129
  from collections.abc import AsyncGenerator
130
  from contextlib import asynccontextmanager
 
136
  from fastapi.responses import JSONResponse, Response, StreamingResponse
137
 
138
  # _shared_logic.py must live alongside this file.
139
+ from ._shared_logic import ( # type: ignore[import]
140
+ DEFAULT_HF_BASE,
141
+ DEFAULT_HF_SPACES_MODEL_NAMESPACES,
142
+ DEFAULT_HF_SPACES_MODEL_URL,
143
+ DEFAULT_MAX_BODY_BYTES,
144
+ DEFAULT_MODEL,
145
+ DEFAULT_PATH2_READ_TIMEOUT,
146
+ DEFAULT_PATH3_READ_TIMEOUT,
147
+ DEFAULT_PROXY_TIMEOUT,
148
+ PROXY_VERSION,
149
+ _classify_token_type,
150
+ _resolve_upstream_url,
151
+ _safe_float,
152
+ _safe_int,
153
+ _token_log_fragment,
154
+ _validate_env,
155
+ _validate_token_config,
156
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
  # ─────────────────────────────────────────────────────────────────────────────
159
  # Helpers β€” placed before configuration so they are available at module scope
160
  # ─────────────────────────────────────────────────────────────────────────────
161
 
162
+
163
  def _client_ip(request: Request) -> str:
164
  """Extract the real client IP from the request.
165
 
 
192
  # Logging
193
  # ─────────────────────────────────────────────────────────────────────────────
194
 
195
+
196
  class _StructuredFormatter(logging.Formatter):
197
  """Emit one JSON object per log record to stdout.
198
 
 
213
 
214
  def format(self, record: logging.LogRecord) -> str: # noqa: A003
215
  payload: dict = {
216
+ "ts": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
217
+ "level": record.levelname,
218
  "logger": record.name,
219
+ "event": record.getMessage(),
220
  }
221
  if record.exc_info:
222
  payload["exc_info"] = self.formatException(record.exc_info)
 
361
  #: TCP handshake timeout in seconds.
362
  #: Uses ``_safe_float`` β€” a non-numeric env var logs a warning and falls back
363
  #: to the default rather than raising ``ValueError`` at startup.
364
+ _connect_timeout_secs: float = _safe_float(
365
+ os.environ.get("PROXY_CONNECT_TIMEOUT"), 10.0
366
+ )
367
  #: Request body upload timeout in seconds.
368
  _write_timeout_secs: float = _safe_float(os.environ.get("PROXY_WRITE_TIMEOUT"), 30.0)
369
  #: Connection pool acquire timeout in seconds.
 
428
  #: When the dict would exceed this size a full sweep removes all entries whose
429
  #: sliding window has expired before the new entry is stored. This bounds
430
  #: memory to O(_MAX_RL_ENTRIES) even under a slow-drip unique-IP scan that
431
+ #: never reuses the same address. Value chosen to be generous for legitimate
432
  #: traffic (thousands of real users) while preventing unbounded growth.
433
  _MAX_RL_ENTRIES: int = 10_000
434
 
 
545
  HF_SPACES_MODEL_URL or None,
546
  list(HF_SPACES_MODEL_NAMESPACES),
547
  _token_log_fragment(HF_TOKEN, token_type=HF_TOKEN_TYPE),
548
+ (
549
+ _token_log_fragment(HF_WRITE_TOKEN, token_type=HF_WRITE_TOKEN_TYPE)
550
+ if HF_WRITE_TOKEN
551
+ else "<using-hf-token-fallback>"
552
+ ),
553
  DEFAULT_MODEL,
554
  )
555
  logger.info(
 
959
  "status": "ok",
960
  "service": f"sphinx-ai-assistant proxy v{PROXY_VERSION}",
961
  "routing": {
962
+ "path_1_backend_url": BACKEND_URL or None,
963
+ "path_2_model_space_url": HF_SPACES_MODEL_URL or None,
964
+ "path_2_namespaces": list(HF_SPACES_MODEL_NAMESPACES),
965
+ "path_3_hf_api_base": HF_BASE,
966
+ "path_3_hf_token_set": bool(HF_TOKEN),
967
  },
968
  "tokens": {
969
  # Never expose token values β€” only whether each slot is filled.
 
972
  # reading Space logs. Possible values: fine-grained | read | write |
973
  # unknown. "unknown" means the type could not be inferred β€” set
974
  # HF_TOKEN_TYPE / HF_WRITE_TOKEN_TYPE in Space secrets to resolve.
975
+ "hf_token_set": bool(HF_TOKEN),
976
+ "hf_token_type": HF_TOKEN_TYPE,
977
+ "hf_write_token_set": bool(HF_WRITE_TOKEN),
978
+ "hf_write_token_type": HF_WRITE_TOKEN_TYPE,
979
  # True when HF_WRITE_TOKEN is set; False means writes fall
980
  # back to HF_TOKEN (single-token mode, less secure).
981
  "least_privilege_mode": bool(HF_WRITE_TOKEN),
982
  },
983
  "training": {
984
+ "dataset_repo": TRAINING_DATASET_REPO or None,
985
+ "contribute_ready": bool(TRAINING_DATASET_REPO and HF_DATASET_TOKEN),
986
  "feedback_persist_enabled": FEEDBACK_PERSIST_ENABLED,
987
  },
988
  "timeouts": {
 
994
  },
995
  "cors_origins": _allowed_origins,
996
  "endpoints": {
997
+ "chat": "POST /v1/chat/completions (primary)",
998
+ "share": "POST /v1/share (conversation share)",
999
  "share_get": "GET /v1/share/{uuid} (retrieve shared snapshot)",
1000
+ "feedback": "POST /v1/feedback (rating persistence)",
1001
+ "training": "POST /v1/contribute (GDPR-gated training data)",
1002
+ "alias": "POST / (path-agnostic alias)",
1003
+ "health": "GET /health (liveness probe)",
1004
  "head_root": "HEAD / (health-monitor probe)",
1005
  "head_health": "HEAD /health (health-monitor probe)",
1006
  },
 
1081
 
1082
 
1083
  @app.post("/v1/contribute")
1084
+ async def contribute(request: Request) -> JSONResponse: # noqa: PLR0912
1085
  """Accept a training data contribution from the AI assistant browser widget.
1086
 
1087
  Parameters
 
1120
  traffic level this is acceptable; if throughput grows, wrap in
1121
  ``asyncio.get_event_loop().run_in_executor(None, ...)`` instead.
1122
  """
 
 
1123
  # Body size guard
1124
  raw = await request.body()
1125
  if len(raw) > DEFAULT_MAX_BODY_BYTES:
 
1169
  async with _contrib_rl_lock:
1170
  now = _time.time()
1171
  count, window_start = _contrib_rl.get(client_ip, (0, now))
1172
+ if now - window_start > 3600: # noqa: PLR2004
1173
  count, window_start = 0, now
1174
  count += 1
1175
  _contrib_rl[client_ip] = (count, window_start)
1176
+ if count > 5: # noqa: PLR2004
1177
+ logger.warning(
1178
+ json.dumps({"event": "contribute.ratelimit", "ip": client_ip})
1179
+ )
1180
  raise HTTPException(
1181
  status_code=429,
1182
  detail="Rate limit exceeded. Maximum 5 contributions per hour.",
 
1204
  # HF_DATASET_TOKEN resolves to HF_WRITE_TOKEN when set, else HF_TOKEN.
1205
  # This ensures the inference token (HF_TOKEN, read-only) is never used
1206
  # for dataset write operations when a dedicated write token is available.
1207
+ from huggingface_hub import CommitOperationAdd, HfApi # noqa: PLC0415
1208
 
1209
  api = HfApi(token=HF_DATASET_TOKEN)
1210
  session_id: str = payload.get("sessionId", "")
 
1212
  json.dumps(
1213
  {
1214
  **rec,
1215
+ "_sessionId": session_id,
1216
+ "_page": payload.get("page", ""),
1217
+ "_model": payload.get("model"),
1218
  "_consentVersion": payload.get("consentVersion"),
1219
+ "_ts": int(_time.time() * 1000),
1220
  # Provenance tag: distinguishes records that arrived via the
1221
  # explicit GDPR-gated "Contribute" button (end-of-session) from
1222
  # records that arrived via the immediate per-answer rating button
1223
  # (POST /v1/feedback). Training pipelines MUST prefer
1224
  # "contribution" over "feedback" when both exist for the same
1225
  # dedup key. See DATASET_COLLECTION_GUIDANCE.md.
1226
+ "_source": "contribution",
1227
  # Stable dedup key: "{conversationSessionId}:{answerIndex}".
1228
  # Identical key format used by /v1/feedback (when
1229
  # FEEDBACK_PERSIST_ENABLED=true) so a simple equality check
1230
  # across folders is sufficient to detect cross-source duplicates.
1231
+ "_dedup_key": f"{session_id}:{rec.get('answerIndex', '')}",
1232
  },
1233
  ensure_ascii=False,
1234
  )
 
1241
  # the dataset; fail fast with a clear error before touching HuggingFace.
1242
  if not rows_jsonl.strip():
1243
  logger.error(
1244
+ json.dumps(
1245
+ {
1246
+ "event": "contribute.empty_jsonl",
1247
+ "records": len(records),
1248
+ "detail": (
1249
+ "rows_jsonl is empty after serialising records; "
1250
+ "aborting HF commit to prevent corrupt dataset file."
1251
+ ),
1252
+ }
1253
+ )
1254
  )
1255
  raise HTTPException(
1256
  status_code=422,
 
1287
  detail="Failed to store contribution. Try again later.",
1288
  ) from exc
1289
 
1290
+ logger.info(
1291
+ json.dumps({"event": "contribute.write", "rows": len(records), "ip": client_ip})
1292
+ )
1293
  return JSONResponse({"contributed": True, "rows": len(records)})
1294
 
1295
 
 
1336
  permissive than ``/v1/contribute`` because share payloads are user-facing
1337
  outputs, not GDPR-sensitive training data.
1338
  """
 
 
1339
  # Body size guard
1340
  raw = await request.body()
1341
  if len(raw) > DEFAULT_MAX_BODY_BYTES:
 
1364
  async with _share_rl_lock:
1365
  now = _time.time()
1366
  count, window_start = _share_rl.get(client_ip, (0, now))
1367
+ if now - window_start > 3600: # noqa: PLR2004
1368
  count, window_start = 0, now
1369
  count += 1
1370
  _share_rl[client_ip] = (count, window_start)
1371
+ if count > 10: # noqa: PLR2004
1372
  logger.warning(json.dumps({"event": "share.ratelimit", "ip": client_ip}))
1373
  raise HTTPException(
1374
  status_code=429,
 
1383
 
1384
  # Store in memory; evict expired entries lazily on each write.
1385
  async with _share_store_lock:
1386
+ expired_keys = [
1387
+ k for k, v in _share_store.items() if v["expiresAt_ts"] < now_ts
1388
+ ]
1389
  for k in expired_keys:
1390
  del _share_store[k]
1391
  _share_store[share_id] = {
1392
+ "content": content,
1393
+ "mimeType": mime_type,
1394
+ "ext": ext,
1395
+ "title": title,
1396
  "expiresAt_ts": expires_ts,
1397
+ "expiresAt": expires_iso,
1398
  }
1399
 
1400
  logger.info(
1401
  json.dumps(
1402
+ {
1403
+ "event": "share.create",
1404
+ "id": share_id,
1405
+ "ip": client_ip,
1406
+ "ttl_days": ttl_days,
1407
+ }
1408
  )
1409
  )
1410
 
 
1440
  **User note** β€” Shares are stored in process memory only. A Space
1441
  restart clears all shares, returning 404 for previously valid links.
1442
  """
 
 
1443
  async with _share_store_lock:
1444
  entry = _share_store.get(share_id)
1445
 
 
1456
  status_code=200,
1457
  media_type=entry.get("mimeType", "text/html;charset=utf-8"),
1458
  headers={
1459
+ "Content-Disposition": (
1460
+ f'inline; filename="share{entry.get("ext", ".html")}"'
1461
+ ),
1462
  },
1463
  )
1464
 
 
1527
  the ``feedback`` record β€” the GDPR-gated explicit contribution is the
1528
  higher-quality signal.
1529
  """
 
 
1530
  # Body size guard
1531
  raw = await request.body()
1532
  if len(raw) > DEFAULT_MAX_BODY_BYTES:
 
1538
  raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") from exc
1539
 
1540
  if not isinstance(payload, dict):
1541
+ raise HTTPException(
1542
+ status_code=422, detail="Feedback body must be a JSON object."
1543
+ )
1544
 
1545
  # ── Distinguish retraction tombstones from regular ratings ───────────────
1546
  # Retractions are system-generated housekeeping records that invalidate a
 
1568
  logger.info(
1569
  json.dumps(
1570
  {
1571
+ "event": "feedback.retract",
1572
+ "ip": client_ip,
1573
+ "prevSessionId": payload.get("prevSessionId"),
1574
  "conversationId": payload.get("conversationId"),
1575
+ "answerIndex": payload.get("answerIndex"),
1576
+ "ts": payload.get("ts"),
1577
+ "persist": FEEDBACK_PERSIST_ENABLED,
1578
  }
1579
  )
1580
  )
 
1592
  for k in expired:
1593
  del _feedback_rl[k]
1594
  count, window_start = _feedback_rl.get(client_ip, (0, now))
1595
+ if now - window_start > 3600: # noqa: PLR2004
1596
  count, window_start = 0, now
1597
  count += 1
1598
  _feedback_rl[client_ip] = (count, window_start)
1599
+ if count > 30: # noqa: PLR2004
1600
+ logger.warning(
1601
+ json.dumps({"event": "feedback.ratelimit", "ip": client_ip})
1602
+ )
1603
  raise HTTPException(
1604
  status_code=429,
1605
  detail="Rate limit exceeded. Maximum 30 feedback submissions per hour.",
 
1609
  logger.info(
1610
  json.dumps(
1611
  {
1612
+ "event": "feedback.receive",
1613
+ "ip": client_ip,
1614
+ "ratingValue": payload.get("ratingValue"),
1615
+ "model": payload.get("model"),
1616
  "conversationId": payload.get("conversationId"),
1617
+ "answerIndex": payload.get("answerIndex"),
1618
+ "page": payload.get("page"),
1619
+ "ts": payload.get("ts"),
1620
+ "persist": FEEDBACK_PERSIST_ENABLED,
1621
  }
1622
  )
1623
  )
 
1635
  # rating also wasn't persisted, so there is nothing to suppress.
1636
  if FEEDBACK_PERSIST_ENABLED and TRAINING_DATASET_REPO and HF_DATASET_TOKEN:
1637
  try:
1638
+ from huggingface_hub import CommitOperationAdd, HfApi # noqa: PLC0415
1639
 
1640
  # ``conversationId`` is the stable per-page-load session UUID that
1641
  # the JS widget sets as ``detail.conversationId = _sessionId``.
 
1649
  record = json.dumps(
1650
  {
1651
  **payload,
1652
+ "_ts": int(_time.time() * 1000),
1653
  # Provenance β€” training pipelines must discard "feedback"
1654
  # records whenever a "contribution" record exists for the
1655
  # same _dedup_key. Retraction tombstones also use
 
1658
  # overrides retraction). deduplicate_dataset.py filters
1659
  # action="retract" winners from the clean output.
1660
  # See DATASET_COLLECTION_GUIDANCE.md Β§3 and Β§8.
1661
+ "_source": "feedback",
1662
  # "{conversationId}:{answerIndex}" β€” identical key format
1663
  # to contribution records; equality check is sufficient to
1664
  # detect cross-source duplicates.
1665
+ "_dedup_key": f"{conversation_id}:{answer_index}",
1666
  },
1667
  ensure_ascii=False,
1668
  )
 
1689
  logger.info(
1690
  json.dumps(
1691
  {
1692
+ "event": "feedback.persist_ok",
1693
+ "retract": is_retract,
1694
+ "dedup_key": f"{conversation_id}:{answer_index}",
1695
+ "ip": client_ip,
1696
  }
1697
  )
1698
  )
 
1700
  # Never propagate β€” the JS widget does not read the response body
1701
  # and the user's rating must always be acknowledged with 200.
1702
  logger.error(
1703
+ json.dumps({"event": "feedback.persist_fail", "error": str(exc)})
 
 
1704
  )
1705
 
1706
  return JSONResponse({"ok": True})