celik-muhammed commited on
Commit
3e24698
·
verified ·
1 Parent(s): a0225bd

Upload 5 files

Browse files
Files changed (4) hide show
  1. README.md +5 -5
  2. _shared_logic.py +3 -1
  3. app.py +303 -57
  4. requirements.txt +5 -1
README.md CHANGED
@@ -1,16 +1,16 @@
1
  ---
2
- pinned: false
3
  title: sphinx-ai-assistant proxy
4
  emoji: 🔁
5
  colorFrom: blue
6
  colorTo: green
7
- license: bsd-3-clause
8
- short_description: ai
 
9
  hf_oauth: true
10
  hf_oauth_scopes:
11
  - inference-api
12
- sdk: docker
13
- app_port: 7860
14
  ---
15
 
16
  # sphinx-ai-assistant proxy
 
1
  ---
 
2
  title: sphinx-ai-assistant proxy
3
  emoji: 🔁
4
  colorFrom: blue
5
  colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
  hf_oauth: true
10
  hf_oauth_scopes:
11
  - inference-api
12
+ license: bsd-3-clause
13
+ short_description: ai
14
  ---
15
 
16
  # sphinx-ai-assistant proxy
_shared_logic.py CHANGED
@@ -482,7 +482,9 @@ def _resolve_upstream_url(
482
  hf_base: str = DEFAULT_HF_BASE,
483
  default_model: str = DEFAULT_MODEL,
484
  hf_spaces_model_url: str = DEFAULT_HF_SPACES_MODEL_URL,
485
- hf_spaces_model_namespaces: tuple[str, ...] | list[str] = DEFAULT_HF_SPACES_MODEL_NAMESPACES,
 
 
486
  proxy_timeout: float = float(DEFAULT_PROXY_TIMEOUT),
487
  path2_read_timeout: float = DEFAULT_PATH2_READ_TIMEOUT,
488
  path3_read_timeout: float = DEFAULT_PATH3_READ_TIMEOUT,
 
482
  hf_base: str = DEFAULT_HF_BASE,
483
  default_model: str = DEFAULT_MODEL,
484
  hf_spaces_model_url: str = DEFAULT_HF_SPACES_MODEL_URL,
485
+ hf_spaces_model_namespaces: (
486
+ tuple[str, ...] | list[str]
487
+ ) = DEFAULT_HF_SPACES_MODEL_NAMESPACES,
488
  proxy_timeout: float = float(DEFAULT_PROXY_TIMEOUT),
489
  path2_read_timeout: float = DEFAULT_PATH2_READ_TIMEOUT,
490
  path3_read_timeout: float = DEFAULT_PATH3_READ_TIMEOUT,
app.py CHANGED
@@ -97,6 +97,7 @@ Developer note — explicit error handling
97
 
98
  from __future__ import annotations
99
 
 
100
  import json
101
  import logging
102
  import os
@@ -147,14 +148,76 @@ except ImportError:
147
  )
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  # ─────────────────────────────────────────────────────────────────────────────
151
  # Logging
152
  # ─────────────────────────────────────────────────────────────────────────────
153
 
154
- logging.basicConfig(
155
- level=logging.INFO,
156
- format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
157
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  logger = logging.getLogger(__name__)
159
 
160
 
@@ -172,23 +235,22 @@ HF_TOKEN: str = os.environ.get("HF_TOKEN", "").strip()
172
  HF_BASE: str = os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/")
173
 
174
  #: Fallback model when request body omits ``model``.
175
- DEFAULT_MODEL = (
176
- os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
177
- )
178
 
179
  #: Path 2 destination URL — the custom ai-model HF Space.
180
- HF_SPACES_MODEL_URL: str = (
181
- os.environ.get("HF_SPACES_MODEL_URL", DEFAULT_HF_SPACES_MODEL_URL).strip()
182
- )
183
 
184
  #: Parsed model owner namespaces routed to HF_SPACES_MODEL_URL (Path 2).
185
  _raw_namespaces: str = os.environ.get(
186
  "HF_SPACES_MODEL_NAMESPACES",
187
  ",".join(DEFAULT_HF_SPACES_MODEL_NAMESPACES),
188
  )
189
- HF_SPACES_MODEL_NAMESPACES: tuple[str, ...] = tuple(
190
- ns.strip() for ns in _raw_namespaces.split(",") if ns.strip()
191
- ) or DEFAULT_HF_SPACES_MODEL_NAMESPACES
 
192
 
193
  #: Maximum accepted request body size (bytes).
194
  MAX_BODY_BYTES: int = _safe_int(
@@ -198,10 +260,12 @@ MAX_BODY_BYTES: int = _safe_int(
198
 
199
  # ── Per-path read timeouts ────────────────────────────────────────────────────
200
  #: Path 1 (BACKEND_URL) read timeout in seconds.
201
- _proxy_timeout_secs: float = float(_safe_int(
202
- os.environ.get("PROXY_TIMEOUT"),
203
- DEFAULT_PROXY_TIMEOUT,
204
- ))
 
 
205
 
206
  #: Path 2 (ai-model Space, CPU inference) read timeout in seconds.
207
  #: Default 600 s — covers 4-5 min CPU inference with 1 min headroom.
@@ -237,6 +301,22 @@ _allowed_origins: list[str] = (
237
  else [o.strip() for o in _raw_origins.split(",") if o.strip()]
238
  )
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
  # ─────────────────────────────────────────────────────────────────────────────
242
  # Startup validation — fail fast with actionable messages
@@ -334,7 +414,10 @@ app.add_middleware(
334
  CORSMiddleware,
335
  allow_origins=_allowed_origins,
336
  allow_methods=["GET", "POST", "OPTIONS"],
337
- allow_headers=["Content-Type"],
 
 
 
338
  allow_credentials=False,
339
  )
340
 
@@ -515,13 +598,15 @@ async def _forward(body: bytes) -> Response:
515
  ) as upstream:
516
  if upstream.status_code != 200: # noqa: PLR2004
517
  err_body = await upstream.aread()
518
- error_payload = json.dumps({
519
- "id": f"err-{uuid.uuid4().hex}",
520
- "error": {
521
- "status": upstream.status_code,
522
- "message": err_body.decode(errors="replace")[:500],
523
- },
524
- })
 
 
525
  yield f"data: {error_payload}\n\n".encode()
526
  else:
527
  async for chunk in upstream.aiter_bytes():
@@ -531,12 +616,14 @@ async def _forward(body: bytes) -> Response:
531
  err_id = uuid.uuid4().hex
532
  logger.warning(
533
  "ReadTimeout after %.0f s on streaming request to %s [%s]",
534
- read_timeout_s, url, err_id,
 
 
535
  )
536
  yield f'data: {{"id":"err-{err_id}","error":{{"status":504,"message":'
537
  yield (
538
  f'"Upstream timed out after {read_timeout_s:.0f} s. '
539
- f'CPU inference can take 4-5 minutes. '
540
  f'If using the ai-model Space, the model may still be loading."}}}}\n\n'
541
  ).encode()
542
 
@@ -555,7 +642,9 @@ async def _forward(body: bytes) -> Response:
555
  err_id = uuid.uuid4().hex
556
  logger.warning(
557
  "RequestError on streaming request to %s: %s [%s]",
558
- url, exc, err_id,
 
 
559
  )
560
  yield (
561
  f'data: {{"id":"err-{err_id}","error":{{"status":502,"message":'
@@ -567,7 +656,7 @@ async def _forward(body: bytes) -> Response:
567
  status_code=200,
568
  media_type="text/event-stream",
569
  headers={
570
- "Cache-Control": "no-cache",
571
  "X-Accel-Buffering": "no",
572
  },
573
  )
@@ -580,13 +669,14 @@ async def _forward(body: bytes) -> Response:
580
  except httpx.ReadTimeout:
581
  logger.warning(
582
  "ReadTimeout after %.0f s on non-streaming request to %s",
583
- read_timeout_s, url,
 
584
  )
585
  return JSONResponse(
586
  status_code=504,
587
  content={
588
  "error": {
589
- "type": "timeout_error",
590
  "message": (
591
  f"Upstream timed out after {read_timeout_s:.0f} s. "
592
  "CPU inference on the ai-model Space can take 4-5 minutes. "
@@ -601,7 +691,7 @@ async def _forward(body: bytes) -> Response:
601
  status_code=504,
602
  content={
603
  "error": {
604
- "type": "timeout_error",
605
  "message": (
606
  f"Connection timed out reaching {url}. "
607
  "The HF Space may be cold-starting — retry in 30 seconds."
@@ -615,7 +705,7 @@ async def _forward(body: bytes) -> Response:
615
  status_code=502,
616
  content={
617
  "error": {
618
- "type": "upstream_error",
619
  "message": f"Failed to reach upstream: {type(exc).__name__}",
620
  }
621
  },
@@ -650,30 +740,32 @@ async def root() -> JSONResponse:
650
  default 600 s). ``path3`` corresponds to the HF Serverless API (GPU,
651
  default 120 s).
652
  """
653
- return JSONResponse({
654
- "status": "ok",
655
- "service": f"sphinx-ai-assistant proxy v{PROXY_VERSION}",
656
- "routing": {
657
- "path_1_backend_url": BACKEND_URL or None,
658
- "path_2_model_space_url": HF_SPACES_MODEL_URL or None,
659
- "path_2_namespaces": list(HF_SPACES_MODEL_NAMESPACES),
660
- "path_3_hf_api_base": HF_BASE,
661
- "path_3_hf_token_set": bool(HF_TOKEN),
662
- },
663
- "timeouts": {
664
- "path1_s": _proxy_timeout_secs,
665
- "path2_s": _path2_timeout_secs,
666
- "path3_s": _path3_timeout_secs,
667
- "connect_s": _connect_timeout_secs,
668
- "write_s": _write_timeout_secs,
669
- },
670
- "cors_origins": _allowed_origins,
671
- "endpoints": {
672
- "chat": "POST /v1/chat/completions (primary)",
673
- "alias": "POST / (path-agnostic alias)",
674
- "health": "GET /health (liveness probe)",
675
- },
676
- })
 
 
677
 
678
 
679
  @app.get("/health")
@@ -746,3 +838,157 @@ async def chat_completions_alias(body: bytes = Depends(_validated_body)) -> Resp
746
  to the bare Space URL without the path suffix.
747
  """
748
  return await _forward(body)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  from __future__ import annotations
99
 
100
+ import asyncio
101
  import json
102
  import logging
103
  import os
 
148
  )
149
 
150
 
151
+ # ─────────────────────────────────────────────────────────────────────────────
152
+ # Helpers — placed before configuration so they are available at module scope
153
+ # ─────────────────────────────────────────────────────────────────────────────
154
+
155
+ def _client_ip(request: Request) -> str:
156
+ """Extract the real client IP from the request.
157
+
158
+ Parameters
159
+ ----------
160
+ request : fastapi.Request
161
+ Incoming HTTP request.
162
+
163
+ Returns
164
+ -------
165
+ str
166
+ Best-effort client IP string; ``"unknown"`` when unavailable.
167
+
168
+ Notes
169
+ -----
170
+ Developer: HF Spaces sits behind a proxy so ``request.client.host``
171
+ is the proxy IP, not the user IP. ``X-Forwarded-For`` is the correct
172
+ source — take the FIRST value only (leftmost = original client;
173
+ rightmost values can be spoofed by intermediaries).
174
+ """
175
+ xff = request.headers.get("x-forwarded-for", "")
176
+ if xff:
177
+ return xff.split(",")[0].strip()
178
+ if request.client:
179
+ return request.client.host or "unknown"
180
+ return "unknown"
181
+
182
+
183
  # ─────────────────────────────────────────────────────────────────────────────
184
  # Logging
185
  # ─────────────────────────────────────────────────────────────────────────────
186
 
187
+ class _StructuredFormatter(logging.Formatter):
188
+ """Emit one JSON object per log record to stdout.
189
+
190
+ Parameters
191
+ ----------
192
+ *args, **kwargs
193
+ Forwarded to :class:`logging.Formatter`.
194
+
195
+ Notes
196
+ -----
197
+ Developer: JSON format is required for machine-parseable log ingestion
198
+ (HF Spaces log export, Datadog, etc.). Text-format lines require regex
199
+ in log queries; JSON fields are natively queryable.
200
+
201
+ The ``exc_info`` key is omitted entirely when no exception is attached
202
+ so log consumers do not need to handle a null field on every record.
203
+ """
204
+
205
+ def format(self, record: logging.LogRecord) -> str: # noqa: A003
206
+ payload: dict = {
207
+ "ts": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
208
+ "level": record.levelname,
209
+ "logger": record.name,
210
+ "event": record.getMessage(),
211
+ }
212
+ if record.exc_info:
213
+ payload["exc_info"] = self.formatException(record.exc_info)
214
+ return json.dumps(payload, ensure_ascii=False)
215
+
216
+
217
+ _handler = logging.StreamHandler()
218
+ _handler.setFormatter(_StructuredFormatter())
219
+ logging.root.handlers = [_handler]
220
+ logging.root.setLevel(logging.INFO)
221
  logger = logging.getLogger(__name__)
222
 
223
 
 
235
  HF_BASE: str = os.environ.get("HF_BASE", DEFAULT_HF_BASE).rstrip("/")
236
 
237
  #: Fallback model when request body omits ``model``.
238
+ DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", DEFAULT_MODEL).strip() or DEFAULT_MODEL
 
 
239
 
240
  #: Path 2 destination URL — the custom ai-model HF Space.
241
+ HF_SPACES_MODEL_URL: str = os.environ.get(
242
+ "HF_SPACES_MODEL_URL", DEFAULT_HF_SPACES_MODEL_URL
243
+ ).strip()
244
 
245
  #: Parsed model owner namespaces routed to HF_SPACES_MODEL_URL (Path 2).
246
  _raw_namespaces: str = os.environ.get(
247
  "HF_SPACES_MODEL_NAMESPACES",
248
  ",".join(DEFAULT_HF_SPACES_MODEL_NAMESPACES),
249
  )
250
+ HF_SPACES_MODEL_NAMESPACES: tuple[str, ...] = (
251
+ tuple(ns.strip() for ns in _raw_namespaces.split(",") if ns.strip())
252
+ or DEFAULT_HF_SPACES_MODEL_NAMESPACES
253
+ )
254
 
255
  #: Maximum accepted request body size (bytes).
256
  MAX_BODY_BYTES: int = _safe_int(
 
260
 
261
  # ── Per-path read timeouts ────────────────────────────────────────────────────
262
  #: Path 1 (BACKEND_URL) read timeout in seconds.
263
+ _proxy_timeout_secs: float = float(
264
+ _safe_int(
265
+ os.environ.get("PROXY_TIMEOUT"),
266
+ DEFAULT_PROXY_TIMEOUT,
267
+ )
268
+ )
269
 
270
  #: Path 2 (ai-model Space, CPU inference) read timeout in seconds.
271
  #: Default 600 s — covers 4-5 min CPU inference with 1 min headroom.
 
301
  else [o.strip() for o in _raw_origins.split(",") if o.strip()]
302
  )
303
 
304
+ #: HuggingFace Dataset repo for training contributions.
305
+ #: Must be set if POST /v1/contribute is expected to succeed.
306
+ TRAINING_DATASET_REPO: str = os.environ.get("TRAINING_DATASET_REPO", "").strip()
307
+
308
+ #: Current consent version string. Must match the JS constant _TRAINING_CONSENT_VERSION.
309
+ #: Increment when the consent UI text changes materially.
310
+ TRAINING_CONSENT_VERSION: str = "v1.0"
311
+
312
+ #: Maximum records per contribution POST.
313
+ MAX_CONTRIBUTION_RECORDS: int = 100
314
+
315
+ #: In-memory per-IP rate-limit store for contribution endpoint.
316
+ #: Keys: IP string. Values: (count, window_start_timestamp).
317
+ _contrib_rl: dict[str, tuple[int, float]] = {}
318
+ _contrib_rl_lock = asyncio.Lock()
319
+
320
 
321
  # ─────────────────────────────────────────────────────────────────────────────
322
  # Startup validation — fail fast with actionable messages
 
414
  CORSMiddleware,
415
  allow_origins=_allowed_origins,
416
  allow_methods=["GET", "POST", "OPTIONS"],
417
+ # Authorization added for write endpoints (POST /v1/feedback, POST /v1/contribute)
418
+ # that validate a Bearer token. Without this the browser preflight rejects
419
+ # requests containing Authorization headers before the handler runs.
420
+ allow_headers=["Content-Type", "Authorization"],
421
  allow_credentials=False,
422
  )
423
 
 
598
  ) as upstream:
599
  if upstream.status_code != 200: # noqa: PLR2004
600
  err_body = await upstream.aread()
601
+ error_payload = json.dumps(
602
+ {
603
+ "id": f"err-{uuid.uuid4().hex}",
604
+ "error": {
605
+ "status": upstream.status_code,
606
+ "message": err_body.decode(errors="replace")[:500],
607
+ },
608
+ }
609
+ )
610
  yield f"data: {error_payload}\n\n".encode()
611
  else:
612
  async for chunk in upstream.aiter_bytes():
 
616
  err_id = uuid.uuid4().hex
617
  logger.warning(
618
  "ReadTimeout after %.0f s on streaming request to %s [%s]",
619
+ read_timeout_s,
620
+ url,
621
+ err_id,
622
  )
623
  yield f'data: {{"id":"err-{err_id}","error":{{"status":504,"message":'
624
  yield (
625
  f'"Upstream timed out after {read_timeout_s:.0f} s. '
626
+ f"CPU inference can take 4-5 minutes. "
627
  f'If using the ai-model Space, the model may still be loading."}}}}\n\n'
628
  ).encode()
629
 
 
642
  err_id = uuid.uuid4().hex
643
  logger.warning(
644
  "RequestError on streaming request to %s: %s [%s]",
645
+ url,
646
+ exc,
647
+ err_id,
648
  )
649
  yield (
650
  f'data: {{"id":"err-{err_id}","error":{{"status":502,"message":'
 
656
  status_code=200,
657
  media_type="text/event-stream",
658
  headers={
659
+ "Cache-Control": "no-cache",
660
  "X-Accel-Buffering": "no",
661
  },
662
  )
 
669
  except httpx.ReadTimeout:
670
  logger.warning(
671
  "ReadTimeout after %.0f s on non-streaming request to %s",
672
+ read_timeout_s,
673
+ url,
674
  )
675
  return JSONResponse(
676
  status_code=504,
677
  content={
678
  "error": {
679
+ "type": "timeout_error",
680
  "message": (
681
  f"Upstream timed out after {read_timeout_s:.0f} s. "
682
  "CPU inference on the ai-model Space can take 4-5 minutes. "
 
691
  status_code=504,
692
  content={
693
  "error": {
694
+ "type": "timeout_error",
695
  "message": (
696
  f"Connection timed out reaching {url}. "
697
  "The HF Space may be cold-starting — retry in 30 seconds."
 
705
  status_code=502,
706
  content={
707
  "error": {
708
+ "type": "upstream_error",
709
  "message": f"Failed to reach upstream: {type(exc).__name__}",
710
  }
711
  },
 
740
  default 600 s). ``path3`` corresponds to the HF Serverless API (GPU,
741
  default 120 s).
742
  """
743
+ return JSONResponse(
744
+ {
745
+ "status": "ok",
746
+ "service": f"sphinx-ai-assistant proxy v{PROXY_VERSION}",
747
+ "routing": {
748
+ "path_1_backend_url": BACKEND_URL or None,
749
+ "path_2_model_space_url": HF_SPACES_MODEL_URL or None,
750
+ "path_2_namespaces": list(HF_SPACES_MODEL_NAMESPACES),
751
+ "path_3_hf_api_base": HF_BASE,
752
+ "path_3_hf_token_set": bool(HF_TOKEN),
753
+ },
754
+ "timeouts": {
755
+ "path1_s": _proxy_timeout_secs,
756
+ "path2_s": _path2_timeout_secs,
757
+ "path3_s": _path3_timeout_secs,
758
+ "connect_s": _connect_timeout_secs,
759
+ "write_s": _write_timeout_secs,
760
+ },
761
+ "cors_origins": _allowed_origins,
762
+ "endpoints": {
763
+ "chat": "POST /v1/chat/completions (primary)",
764
+ "alias": "POST / (path-agnostic alias)",
765
+ "health": "GET /health (liveness probe)",
766
+ },
767
+ }
768
+ )
769
 
770
 
771
  @app.get("/health")
 
838
  to the bare Space URL without the path suffix.
839
  """
840
  return await _forward(body)
841
+
842
+
843
+ @app.post("/v1/contribute")
844
+ async def contribute(request: Request) -> JSONResponse:
845
+ """Accept a training data contribution from the AI assistant browser widget.
846
+
847
+ Parameters
848
+ ----------
849
+ request : fastapi.Request
850
+ HTTP request. Body must be JSON conforming to the contribution schema.
851
+
852
+ Returns
853
+ -------
854
+ fastapi.responses.JSONResponse
855
+ ``{"contributed": true, "rows": N}`` on success.
856
+
857
+ Raises
858
+ ------
859
+ fastapi.HTTPException
860
+ 422 when consent is absent/false, schemaVersion is unsupported, or
861
+ records exceed the maximum allowed count.
862
+ 429 when the IP rate limit is exceeded.
863
+ 503 when the HF Dataset push fails.
864
+
865
+ Notes
866
+ -----
867
+ Developer: ``consentFlag`` and ``consentVersion`` are checked before any
868
+ other validation. A missing or mismatched consent version is a hard
869
+ rejection — the UI must always send the current version string so that
870
+ old cached pages cannot submit records that would silently bypass a
871
+ consent-text update.
872
+
873
+ Developer: The in-memory rate-limit store (``_contrib_rl``) is per-process
874
+ only. HF Spaces may run multiple replicas. The limit (5 per hour) is
875
+ intentionally loose; the primary defence is the GDPR consent gate.
876
+
877
+ Developer: ``huggingface_hub.HfApi.commit`` is called synchronously inside
878
+ an async handler. This blocks the event loop for the duration of the HTTP
879
+ round-trip to HF (~200 ms on a warm connection). For the current traffic
880
+ level this is acceptable; if throughput grows, wrap in
881
+ ``asyncio.get_event_loop().run_in_executor(None, ...)`` instead.
882
+ """
883
+ import time as _time
884
+
885
+ # Body size guard
886
+ raw = await request.body()
887
+ if len(raw) > DEFAULT_MAX_BODY_BYTES:
888
+ raise HTTPException(status_code=413, detail="Payload too large.")
889
+ try:
890
+ payload = json.loads(raw)
891
+ except json.JSONDecodeError as exc:
892
+ raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") from exc
893
+
894
+ # Consent guard — GDPR Article 7: explicit consent required
895
+ if not payload.get("consentFlag"):
896
+ raise HTTPException(
897
+ status_code=422,
898
+ detail="consentFlag must be true. Contribution requires explicit user consent.",
899
+ )
900
+ if payload.get("consentVersion") != TRAINING_CONSENT_VERSION:
901
+ raise HTTPException(
902
+ status_code=422,
903
+ detail=(
904
+ f"consentVersion {payload.get('consentVersion')!r} is not current. "
905
+ f"Expected {TRAINING_CONSENT_VERSION!r}. Reload the page and try again."
906
+ ),
907
+ )
908
+
909
+ # Schema version guard
910
+ supported_versions: frozenset[int] = frozenset({1})
911
+ if payload.get("schemaVersion") not in supported_versions:
912
+ raise HTTPException(
913
+ status_code=422,
914
+ detail=(
915
+ f"Unsupported schemaVersion {payload.get('schemaVersion')!r}. "
916
+ f"Supported: {sorted(supported_versions)}"
917
+ ),
918
+ )
919
+
920
+ records = payload.get("records", [])
921
+ if not isinstance(records, list):
922
+ raise HTTPException(status_code=422, detail="records must be a list.")
923
+ if len(records) > MAX_CONTRIBUTION_RECORDS:
924
+ raise HTTPException(
925
+ status_code=422,
926
+ detail=f"Too many records. Maximum {MAX_CONTRIBUTION_RECORDS} per request.",
927
+ )
928
+
929
+ # Per-IP rate limit: 5 contributions per hour
930
+ client_ip = _client_ip(request)
931
+ async with _contrib_rl_lock:
932
+ now = _time.time()
933
+ count, window_start = _contrib_rl.get(client_ip, (0, now))
934
+ if now - window_start > 3600:
935
+ count, window_start = 0, now
936
+ count += 1
937
+ _contrib_rl[client_ip] = (count, window_start)
938
+ if count > 5:
939
+ logger.warning(json.dumps({"event": "contribute.ratelimit", "ip": client_ip}))
940
+ raise HTTPException(
941
+ status_code=429,
942
+ detail="Rate limit exceeded. Maximum 5 contributions per hour.",
943
+ headers={"Retry-After": "3600"},
944
+ )
945
+
946
+ if not TRAINING_DATASET_REPO:
947
+ raise HTTPException(
948
+ status_code=503,
949
+ detail="Training endpoint not configured (TRAINING_DATASET_REPO not set).",
950
+ )
951
+ if not HF_TOKEN:
952
+ raise HTTPException(status_code=503, detail="HF_TOKEN not set.")
953
+
954
+ # Push to HF Dataset
955
+ from huggingface_hub import CommitOperationAdd, HfApi
956
+
957
+ api = HfApi(token=HF_TOKEN)
958
+ rows_jsonl = "\n".join(
959
+ json.dumps(
960
+ {
961
+ **rec,
962
+ "_sessionId": payload.get("sessionId", ""),
963
+ "_page": payload.get("page", ""),
964
+ "_model": payload.get("model"),
965
+ "_consentVersion": payload.get("consentVersion"),
966
+ "_ts": int(_time.time() * 1000),
967
+ },
968
+ ensure_ascii=False,
969
+ )
970
+ for rec in records
971
+ if isinstance(rec, dict)
972
+ )
973
+ filename = f"contributions/{int(_time.time() * 1000)}.jsonl"
974
+ try:
975
+ api.commit(
976
+ repo_id=TRAINING_DATASET_REPO,
977
+ repo_type="dataset",
978
+ operations=[
979
+ CommitOperationAdd(
980
+ path_in_repo=filename,
981
+ path_or_fileobj=rows_jsonl.encode(),
982
+ )
983
+ ],
984
+ commit_message=f"Add {len(records)} feedback record(s)",
985
+ )
986
+ except Exception as exc:
987
+ logger.error(json.dumps({"event": "contribute.hf_fail", "error": str(exc)}))
988
+ raise HTTPException(
989
+ status_code=503,
990
+ detail="Failed to store contribution. Try again later.",
991
+ ) from exc
992
+
993
+ logger.info(json.dumps({"event": "contribute.write", "rows": len(records), "ip": client_ip}))
994
+ return JSONResponse({"contributed": True, "rows": len(records)})
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- # scikit-plots/ai · requirements.txt v3.1.0
2
  #
3
  # Pin with ~= (compatible release) to allow patch upgrades while preventing
4
  # breaking minor/major version changes across HF Space restarts.
@@ -11,3 +11,7 @@
11
  fastapi~=0.111.0
12
  uvicorn[standard]~=0.29.0
13
  httpx~=0.27.0
 
 
 
 
 
1
+ # scikit-plots/ai · requirements.txt v3.2.0
2
  #
3
  # Pin with ~= (compatible release) to allow patch upgrades while preventing
4
  # breaking minor/major version changes across HF Space restarts.
 
11
  fastapi~=0.111.0
12
  uvicorn[standard]~=0.29.0
13
  httpx~=0.27.0
14
+ # Required for POST /v1/contribute: push Q&A training records to a HF Dataset.
15
+ # HF_TOKEN (Space secret) is used server-side; no token exposed to the browser.
16
+ # Upper bound omitted — huggingface_hub follows semver and is stable across minors.
17
+ huggingface_hub~=0.23.0