Spaces:
Sleeping
Sleeping
| """ | |
| Hugging Science Feedback API | |
| A minimal FastAPI app that accepts feedback and content-addition submissions. | |
| `type: "feedback"` submissions go to feedback.jsonl (unchanged behavior). | |
| Every other type (dataset/model/organization/blog/challenge/collaboration) is | |
| a request that goes to requests.jsonl instead β the | |
| hugging-science/requests-review Space reads that file to approve/reject them. | |
| `collaboration` requests additionally require `email` and `institution`, and | |
| are acknowledged rather than turned into a PR (see that Space's PR_TYPES). | |
| Both files live in the same hugging-science/feedback dataset. | |
| `dataset`/`model`/`organization`/`blog` requests carry the full structured | |
| fields the moderator Space needs to render a src/data/*.js entry (slug, | |
| org_id, entry_type, tags, ...) so the reviewer only has to click Approve β | |
| this module derives the remaining fields (entry_id, org_id, link, blog slug) | |
| that submitters shouldn't have to type by hand. | |
| Deploy as a HF Space (Docker SDK): | |
| hugging-science/feedback-api | |
| Required Space secret: | |
| HF_TOKEN β a write-scoped token for the hugging-science org | |
| """ | |
| import os | |
| import json | |
| import re | |
| import uuid | |
| from datetime import datetime, timezone | |
| from typing import Optional | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, field_validator, model_validator | |
| from huggingface_hub import HfApi, hf_hub_download | |
| import tempfile | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| # ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DATASET_REPO = "hugging-science/request" | |
| FEEDBACK_FILE = "feedback.jsonl" | |
| REQUESTS_FILE = "requests.jsonl" | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| if not HF_TOKEN: | |
| raise RuntimeError("HF_TOKEN secret is not set") | |
| api = HfApi(token=HF_TOKEN) | |
| # ββ App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="Hugging Science Feedback API", version="1.1.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["https://huggingscience.co", "http://localhost:5173"], | |
| allow_methods=["POST", "GET"], | |
| allow_headers=["Content-Type"], | |
| ) | |
| # ββ Schema ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| VALID_TYPES = {"dataset", "model", "organization", "blog", "challenge", "collaboration", "feedback"} | |
| REQUEST_TYPES = VALID_TYPES - {"feedback"} | |
| PR_TYPES = {"dataset", "model", "organization", "blog"} | |
| EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") | |
| DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") | |
| # Mirrors formatters.VALID_TAGS / src/data/themes.js themeIds in the other | |
| # two repos β kept in sync by hand since each Space deploys independently. | |
| VALID_TAGS = { | |
| "biology", "chemistry", "physics", "medicine", "mathematics", | |
| "engineering", "earth-science", "astronomy", "genomics", | |
| "biotechnology", "materials-science", "climate", "energy", | |
| "ecology", "conservation", "benchmark", "scientific-reasoning", | |
| } | |
| class FeedbackItem(BaseModel): | |
| type: str | |
| title: Optional[str] = None | |
| description: str | |
| email: Optional[str] = None | |
| institution: Optional[str] = None | |
| submitted_at: Optional[str] = None | |
| source: Optional[str] = "huggingscience.co" | |
| # dataset / model / organization / blog β structured fields so the | |
| # moderator Space can open a PR without the reviewer typing anything. | |
| slug: Optional[str] = None | |
| name: Optional[str] = None | |
| org_id: Optional[str] = None | |
| entry_type: Optional[str] = None | |
| link: Optional[str] = None | |
| date: Optional[str] = None | |
| tags: Optional[list[str]] = None | |
| def validate_type(cls, v): | |
| if v not in VALID_TYPES: | |
| raise ValueError(f"type must be one of {VALID_TYPES}") | |
| return v | |
| def validate_description(cls, v): | |
| v = v.strip() | |
| if len(v) < 5: | |
| raise ValueError("description must be at least 5 characters") | |
| if len(v) > 2000: | |
| raise ValueError("description must be under 2000 characters") | |
| return v | |
| def validate_collaboration_fields(self): | |
| if self.type == "collaboration": | |
| email = (self.email or "").strip() | |
| institution = (self.institution or "").strip() | |
| if not email or not EMAIL_RE.match(email): | |
| raise ValueError("a valid email is required for collaboration requests") | |
| if not institution: | |
| raise ValueError("institution is required for collaboration requests") | |
| self.email = email | |
| self.institution = institution | |
| return self | |
| def validate_pr_fields(self): | |
| if self.type not in PR_TYPES: | |
| return self | |
| tags = [t.strip() for t in (self.tags or []) if t and t.strip()] | |
| if not tags: | |
| raise ValueError(f"at least one tag is required for {self.type} requests") | |
| invalid = sorted(set(tags) - VALID_TAGS) | |
| if invalid: | |
| raise ValueError(f"invalid tags: {invalid}") | |
| self.tags = tags | |
| if self.type == "organization": | |
| self.org_id = (self.org_id or "").strip() | |
| self.name = (self.name or "").strip() | |
| if not self.org_id: | |
| raise ValueError("org_id is required for organization requests") | |
| if not self.name: | |
| raise ValueError("name is required for organization requests") | |
| if self.type in {"model", "dataset"}: | |
| self.slug = (self.slug or "").strip() | |
| parts = self.slug.split("/") | |
| if len(parts) != 2 or not parts[0] or not parts[1]: | |
| raise ValueError(f"slug must be an 'org/repo' path for {self.type} requests") | |
| self.entry_type = (self.entry_type or "").strip() | |
| if not self.entry_type: | |
| raise ValueError(f"entry_type is required for {self.type} requests") | |
| if self.type == "model": | |
| self.name = (self.name or "").strip() | |
| if not self.name: | |
| raise ValueError("name is required for model requests") | |
| if self.type == "blog": | |
| self.title = (self.title or "").strip() | |
| self.link = (self.link or "").strip() | |
| self.date = (self.date or "").strip() | |
| if not self.title: | |
| raise ValueError("title is required for blog requests") | |
| if not self.link: | |
| raise ValueError("link is required for blog requests") | |
| if not DATE_RE.match(self.date): | |
| raise ValueError("date must be in YYYY-MM-DD format") | |
| return self | |
| # ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _slugify(text: str) -> str: | |
| text = re.sub(r"[^a-zA-Z0-9]+", "-", text.strip()).strip("-").lower() | |
| return text or "entry" | |
| def _pr_fields(item: "FeedbackItem") -> dict: | |
| """Derive the fields a submitter shouldn't have to type by hand.""" | |
| if item.type == "organization": | |
| return { | |
| "title": item.name, | |
| "entry_id": item.org_id, | |
| "name": item.name, | |
| "org_id": item.org_id, | |
| "link": f"https://huggingface.co/{item.org_id}", | |
| "tags": item.tags, | |
| } | |
| if item.type in {"model", "dataset"}: | |
| org_id = item.slug.split("/", 1)[0] | |
| fields = { | |
| "title": f"{item.name} β {item.slug}" if item.name else item.slug, | |
| "entry_id": _slugify(item.slug), | |
| "slug": item.slug, | |
| "org_id": org_id, | |
| "entry_type": item.entry_type, | |
| "tags": item.tags, | |
| } | |
| if item.name: | |
| fields["name"] = item.name | |
| return fields | |
| if item.type == "blog": | |
| match = re.match(r"^https?://huggingface\.co/blog/(.+?)/?$", item.link) | |
| blog_slug = match.group(1) if match else None | |
| return { | |
| "title": item.title, | |
| "entry_id": _slugify(blog_slug or item.title), | |
| "slug": blog_slug, | |
| "link": item.link, | |
| "date": item.date, | |
| "tags": item.tags, | |
| } | |
| return {} | |
| def load_existing(filename: str) -> list[dict]: | |
| """Download the given jsonl file from the dataset, return as a list.""" | |
| try: | |
| path = hf_hub_download( | |
| repo_id=DATASET_REPO, | |
| filename=filename, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| ) | |
| with open(path) as f: | |
| return [json.loads(line) for line in f if line.strip()] | |
| except Exception: | |
| # File doesn't exist yet β start fresh | |
| return [] | |
| def save_rows(filename: str, rows: list[dict]) -> None: | |
| """Upload the full jsonl file back to the dataset.""" | |
| content = "\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n" | |
| with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: | |
| f.write(content) | |
| tmp_path = f.name | |
| api.upload_file( | |
| path_or_fileobj=tmp_path, | |
| path_in_repo=filename, | |
| repo_id=DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message=f"Add entry ({rows[-1]['id'][:8]}) to {filename}", | |
| ) | |
| # ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def root(): | |
| return {"status": "ok", "service": "Hugging Science Feedback API"} | |
| def submit_feedback(item: FeedbackItem): | |
| entry = { | |
| "id": str(uuid.uuid4()), | |
| "type": item.type, | |
| "title": item.title or "", | |
| "description": item.description, | |
| "submitted_at": item.submitted_at or datetime.now(timezone.utc).isoformat(), | |
| "source": item.source or "huggingscience.co", | |
| "status": "pending", | |
| } | |
| if item.type == "collaboration": | |
| entry["email"] = item.email | |
| entry["institution"] = item.institution | |
| entry.update(_pr_fields(item)) | |
| target_file = REQUESTS_FILE if item.type in REQUEST_TYPES else FEEDBACK_FILE | |
| try: | |
| rows = load_existing(target_file) | |
| rows.append(entry) | |
| save_rows(target_file, rows) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Failed to save {item.type}: {e}") | |
| return {"ok": True, "id": entry["id"]} | |