File size: 11,114 Bytes
7a7515d
 
 
 
 
13525d6
 
7a7515d
13525d6
 
7a7515d
 
02a536f
 
 
 
 
 
7a7515d
 
 
 
 
 
 
 
13525d6
7a7515d
 
 
 
 
 
13525d6
7a7515d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13525d6
7a7515d
02a536f
13525d6
02a536f
 
 
 
 
 
 
 
 
 
7a7515d
 
 
 
 
13525d6
 
7a7515d
 
 
02a536f
 
 
 
 
 
 
 
 
 
7a7515d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13525d6
 
 
 
 
 
 
 
 
 
 
 
 
02a536f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a7515d
 
02a536f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a7515d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13525d6
 
 
02a536f
7a7515d
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
"""
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

    @field_validator("type")
    @classmethod
    def validate_type(cls, v):
        if v not in VALID_TYPES:
            raise ValueError(f"type must be one of {VALID_TYPES}")
        return v

    @field_validator("description")
    @classmethod
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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 ────────────────────────────────────────────────────────────────────

@app.get("/")
def root():
    return {"status": "ok", "service": "Hugging Science Feedback API"}


@app.post("/submit", status_code=201)
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"]}