Spaces:
Sleeping
Sleeping
Emma Scharfmann commited on
Commit ·
e2bb8e8
1
Parent(s): 6b1df02
add app
Browse files- .gitignore +1 -0
- README.md +40 -8
- about.py +13 -0
- app.py +191 -0
- formatters.py +136 -0
- github_pr.py +119 -0
- requirements.txt +4 -0
- submissions.py +83 -0
.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
.env
|
README.md
CHANGED
|
@@ -1,14 +1,46 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Hugging Science Requests Review
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.9.1
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
hf_oauth: true
|
| 11 |
+
hf_oauth_scopes:
|
| 12 |
+
- profile
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Hugging Science — requests review
|
| 16 |
+
|
| 17 |
+
Standalone moderation tool for huggingscience.co, decoupled from the site's
|
| 18 |
+
own deploy. It works alongside the existing `hugging-science/feedback-api`
|
| 19 |
+
Space (see `../feedback-api/`), not instead of it:
|
| 20 |
+
|
| 21 |
+
1. `FeedbackWidget` on the site POSTs every submission to
|
| 22 |
+
`hugging-science-feedback-api.hf.space/submit`, same as before.
|
| 23 |
+
2. That Space now routes `type: "feedback"` to `feedback.jsonl` (unchanged)
|
| 24 |
+
and everything else — `dataset`/`model`/`organization`/`blog`/`challenge`
|
| 25 |
+
— to a new `requests.jsonl`, both in the `hugging-science/request` dataset.
|
| 26 |
+
3. **This Space** reads `requests.jsonl`, lets the site owner approve or
|
| 27 |
+
reject each pending row, and on approve opens a GitHub pull request
|
| 28 |
+
against [`georgiachanning/huggingscience-dot-co`](https://github.com/georgiachanning/huggingscience-dot-co)
|
| 29 |
+
adding the entry to the right `src/data/*.js` file. The site owner still
|
| 30 |
+
reviews and merges that PR by hand — this tool only removes the manual
|
| 31 |
+
data-entry step, not the final review.
|
| 32 |
+
|
| 33 |
+
## Required Space secrets
|
| 34 |
+
|
| 35 |
+
| Secret | Purpose |
|
| 36 |
+
| --- | --- |
|
| 37 |
+
| `GITHUB_TOKEN` | Fine-grained PAT scoped to `georgiachanning/huggingscience-dot-co` with `contents:write` + `pull-requests:write`. |
|
| 38 |
+
| `HF_TOKEN` | Write-scoped HF token for the `hugging-science` org, used to read/update `requests.jsonl`. |
|
| 39 |
+
| `ADMIN_USERS` | Comma-separated Hugging Face usernames allowed into the admin UI. |
|
| 40 |
+
|
| 41 |
+
## Optional Space variables
|
| 42 |
+
|
| 43 |
+
| Variable | Default | Purpose |
|
| 44 |
+
| --- | --- | --- |
|
| 45 |
+
| `HF_DATASET_REPO` | `hugging-science/request` | Dataset repo holding both `feedback.jsonl` and `requests.jsonl`. |
|
| 46 |
+
| `GITHUB_REPO` | `georgiachanning/huggingscience-dot-co` | Repo that approved requests open PRs against. |
|
about.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
DATASET_REPO = os.environ.get("HF_DATASET_REPO", "hugging-science/request")
|
| 8 |
+
REQUESTS_FILE = "requests.jsonl"
|
| 9 |
+
TOKEN = os.environ.get("HF_TOKEN")
|
| 10 |
+
|
| 11 |
+
ADMIN_USERS = {u.strip() for u in os.environ.get("ADMIN_USERS", "").split(",") if u.strip()}
|
| 12 |
+
GITHUB_REPO = os.environ.get("GITHUB_REPO", "georgiachanning/huggingscience-dot-co")
|
| 13 |
+
GITHUB_API = "https://api.github.com"
|
app.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Science requests review — Gradio admin UI.
|
| 2 |
+
|
| 3 |
+
Ingestion happens in the separate feedback-api Space (/submit), which writes
|
| 4 |
+
non-"feedback" submissions into requests.jsonl in the hugging-science/feedback
|
| 5 |
+
dataset. This Space only reads that file and lets the site owner approve
|
| 6 |
+
(-> opens a GitHub PR) or reject each pending request.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
import formatters
|
| 12 |
+
import github_pr
|
| 13 |
+
import submissions
|
| 14 |
+
from about import ADMIN_USERS
|
| 15 |
+
|
| 16 |
+
# Types that map onto a src/data/*.js file and can go through the PR flow.
|
| 17 |
+
PR_TYPES = list(formatters.TARGETS.keys()) # organization, model, dataset, blog
|
| 18 |
+
# Everything else (e.g. "challenge") is acknowledge/reject only.
|
| 19 |
+
|
| 20 |
+
FIELD_VISIBILITY = {
|
| 21 |
+
"organization": {"id", "name", "link", "description", "tags"},
|
| 22 |
+
"model": {"id", "slug", "name", "orgId", "entry_type", "description", "tags"},
|
| 23 |
+
"dataset": {"id", "slug", "orgId", "entry_type", "description", "tags"},
|
| 24 |
+
"blog": {"id", "title", "slug", "orgId", "date", "excerpt", "link", "tags", "featured", "upvotes"},
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
ALL_FIELD_KEYS = [
|
| 28 |
+
"id", "name", "title", "slug", "orgId", "entry_type",
|
| 29 |
+
"description", "excerpt", "link", "date", "featured", "upvotes", "tags",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def is_admin(profile: gr.OAuthProfile | None) -> bool:
|
| 34 |
+
return profile is not None and profile.username in ADMIN_USERS
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _guess_slug(title: str | None) -> str:
|
| 38 |
+
if title and "/" in title:
|
| 39 |
+
return title.strip()
|
| 40 |
+
return ""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def pending_choices():
|
| 44 |
+
rows = submissions.list_submissions(status="pending")
|
| 45 |
+
return [(f"[{r['type']}] {r['title'] or r['description'][:40]}", r["id"]) for r in rows]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def history_rows():
|
| 49 |
+
rows = [r for r in submissions.list_submissions() if r["status"] != "pending"]
|
| 50 |
+
return [
|
| 51 |
+
[r["type"], r["title"], r["status"], r.get("reviewed_at"), r.get("pr_url") or r.get("reject_reason")]
|
| 52 |
+
for r in rows
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def load_submission(submission_id: str):
|
| 57 |
+
"""Populate the detail panel + editable fields for a selected submission."""
|
| 58 |
+
if not submission_id:
|
| 59 |
+
return (gr.update(value=""), *[gr.update(visible=False) for _ in ALL_FIELD_KEYS], gr.update(visible=False))
|
| 60 |
+
|
| 61 |
+
row = submissions.get_submission(submission_id)
|
| 62 |
+
if row is None:
|
| 63 |
+
return (gr.update(value="Not found."), *[gr.update(visible=False) for _ in ALL_FIELD_KEYS], gr.update(visible=False))
|
| 64 |
+
|
| 65 |
+
detail_md = (
|
| 66 |
+
f"**Type:** {row['type']} \n"
|
| 67 |
+
f"**Title/link:** {row['title'] or '_none given_'} \n"
|
| 68 |
+
f"**Submitted:** {row['submitted_at']} via {row['source']} \n\n"
|
| 69 |
+
f"{row['description']}"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
visible = FIELD_VISIBILITY.get(row["type"], set())
|
| 73 |
+
guessed_slug = _guess_slug(row["title"])
|
| 74 |
+
defaults = {
|
| 75 |
+
"id": guessed_slug.replace("/", "-").lower(),
|
| 76 |
+
"name": row["title"] or "",
|
| 77 |
+
"title": row["title"] or "",
|
| 78 |
+
"slug": guessed_slug,
|
| 79 |
+
"description": row["description"],
|
| 80 |
+
"excerpt": row["description"],
|
| 81 |
+
"link": row["title"] if row["title"] and row["title"].startswith("http") else "",
|
| 82 |
+
}
|
| 83 |
+
field_updates = []
|
| 84 |
+
for key in ALL_FIELD_KEYS:
|
| 85 |
+
if key in visible:
|
| 86 |
+
field_updates.append(gr.update(visible=True, value=defaults.get(key, "" if key != "tags" else [])))
|
| 87 |
+
else:
|
| 88 |
+
field_updates.append(gr.update(visible=False))
|
| 89 |
+
|
| 90 |
+
show_pr_button = row["type"] in PR_TYPES
|
| 91 |
+
return (gr.update(value=detail_md), *field_updates, gr.update(visible=show_pr_button))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
with gr.Blocks(title="Hugging Science — requests review") as demo:
|
| 95 |
+
gr.Markdown("## Hugging Science — requests review")
|
| 96 |
+
login_btn = gr.LoginButton()
|
| 97 |
+
|
| 98 |
+
with gr.Column(visible=False) as admin_panel:
|
| 99 |
+
with gr.Tab("Pending"):
|
| 100 |
+
refresh_btn = gr.Button("Refresh")
|
| 101 |
+
pending_dd = gr.Dropdown(label="Pending requests", choices=[], interactive=True)
|
| 102 |
+
detail = gr.Markdown()
|
| 103 |
+
|
| 104 |
+
with gr.Group():
|
| 105 |
+
f_id = gr.Textbox(label="id", visible=False)
|
| 106 |
+
f_name = gr.Textbox(label="name", visible=False)
|
| 107 |
+
f_title = gr.Textbox(label="title", visible=False)
|
| 108 |
+
f_slug = gr.Textbox(label="slug (org/repo)", visible=False)
|
| 109 |
+
f_orgId = gr.Textbox(label="orgId", visible=False)
|
| 110 |
+
f_entry_type = gr.Textbox(label="type (e.g. Genomics, Foundation Model)", visible=False)
|
| 111 |
+
f_description = gr.Textbox(label="description", lines=3, visible=False)
|
| 112 |
+
f_excerpt = gr.Textbox(label="excerpt", lines=3, visible=False)
|
| 113 |
+
f_link = gr.Textbox(label="link", visible=False)
|
| 114 |
+
f_date = gr.Textbox(label="date (YYYY-MM-DD)", visible=False)
|
| 115 |
+
f_featured = gr.Checkbox(label="featured", visible=False)
|
| 116 |
+
f_upvotes = gr.Number(label="upvotes", visible=False)
|
| 117 |
+
f_tags = gr.CheckboxGroup(label="tags", choices=formatters.VALID_TAGS, visible=False)
|
| 118 |
+
|
| 119 |
+
all_fields = [f_id, f_name, f_title, f_slug, f_orgId, f_entry_type,
|
| 120 |
+
f_description, f_excerpt, f_link, f_date, f_featured, f_upvotes, f_tags]
|
| 121 |
+
|
| 122 |
+
with gr.Row():
|
| 123 |
+
approve_btn = gr.Button("Approve → open PR", variant="primary", visible=False)
|
| 124 |
+
reject_reason = gr.Textbox(label="Reject reason (optional)", scale=2)
|
| 125 |
+
reject_btn = gr.Button("Reject", variant="stop")
|
| 126 |
+
|
| 127 |
+
result_md = gr.Markdown()
|
| 128 |
+
|
| 129 |
+
def do_refresh():
|
| 130 |
+
return gr.update(choices=pending_choices(), value=None)
|
| 131 |
+
|
| 132 |
+
refresh_btn.click(do_refresh, outputs=pending_dd)
|
| 133 |
+
demo.load(do_refresh, outputs=pending_dd)
|
| 134 |
+
|
| 135 |
+
pending_dd.change(load_submission, inputs=pending_dd, outputs=[detail, *all_fields, approve_btn])
|
| 136 |
+
|
| 137 |
+
def do_approve(submission_id, id_, name, title, slug, org_id, entry_type,
|
| 138 |
+
description, excerpt, link, date, featured, upvotes, tags):
|
| 139 |
+
row = submissions.get_submission(submission_id)
|
| 140 |
+
if row is None:
|
| 141 |
+
return "Submission not found.", gr.update(choices=pending_choices())
|
| 142 |
+
fields = {
|
| 143 |
+
"id": id_, "name": name, "title": title, "slug": slug, "orgId": org_id or None,
|
| 144 |
+
"type": entry_type, "description": description, "excerpt": excerpt,
|
| 145 |
+
"link": link, "date": date, "featured": featured, "upvotes": upvotes,
|
| 146 |
+
"tags": tags or [],
|
| 147 |
+
}
|
| 148 |
+
missing = formatters.missing_fields(row["type"], fields)
|
| 149 |
+
if missing:
|
| 150 |
+
return f"Missing required fields: {', '.join(missing)}", gr.update()
|
| 151 |
+
try:
|
| 152 |
+
pr_url = github_pr.open_pr_for_submission(row, fields)
|
| 153 |
+
except Exception as exc: # surfaced to the reviewer, not swallowed
|
| 154 |
+
return f"Failed to open PR: {exc}", gr.update()
|
| 155 |
+
submissions.mark_approved(submission_id, pr_url)
|
| 156 |
+
return f"Approved — [PR opened]({pr_url})", gr.update(choices=pending_choices(), value=None)
|
| 157 |
+
|
| 158 |
+
approve_btn.click(
|
| 159 |
+
do_approve,
|
| 160 |
+
inputs=[pending_dd, *all_fields],
|
| 161 |
+
outputs=[result_md, pending_dd],
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
def do_reject(submission_id, reason):
|
| 165 |
+
if not submission_id:
|
| 166 |
+
return "Nothing selected.", gr.update()
|
| 167 |
+
submissions.mark_rejected(submission_id, reason or None)
|
| 168 |
+
return "Rejected.", gr.update(choices=pending_choices(), value=None)
|
| 169 |
+
|
| 170 |
+
reject_btn.click(do_reject, inputs=[pending_dd, reject_reason], outputs=[result_md, pending_dd])
|
| 171 |
+
|
| 172 |
+
with gr.Tab("History"):
|
| 173 |
+
history_refresh = gr.Button("Refresh")
|
| 174 |
+
history_df = gr.Dataframe(
|
| 175 |
+
headers=["type", "title", "status", "reviewed_at", "pr_url / reason"],
|
| 176 |
+
interactive=False,
|
| 177 |
+
)
|
| 178 |
+
history_refresh.click(lambda: history_rows(), outputs=history_df)
|
| 179 |
+
demo.load(lambda: history_rows(), outputs=history_df)
|
| 180 |
+
|
| 181 |
+
unauthorized = gr.Markdown("Log in with an allow-listed Hugging Face account to review requests.")
|
| 182 |
+
|
| 183 |
+
def toggle_panel(profile: gr.OAuthProfile | None):
|
| 184 |
+
admin = is_admin(profile)
|
| 185 |
+
return gr.update(visible=admin), gr.update(visible=not admin)
|
| 186 |
+
|
| 187 |
+
demo.load(toggle_panel, outputs=[admin_panel, unauthorized])
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
demo.launch()
|
formatters.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn an approved submission into a src/data/*.js entry + a text splice.
|
| 2 |
+
|
| 3 |
+
The site's data files (src/data/organizations.js, models.js, datasets.js,
|
| 4 |
+
blogs.js) are hand-formatted flat JS arrays, not JSON — see the real
|
| 5 |
+
examples this module's tests are based on. Rather than parsing/rewriting
|
| 6 |
+
the whole file as an AST, we do a plain text insertion, since the existing
|
| 7 |
+
formatting is simple and consistent: 2-space indent for `{`/`}`, 4-space
|
| 8 |
+
indent for fields, `description`/`excerpt` always on their own line.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
# Canonical tag list — mirrors src/data/themes.js themeIds.
|
| 14 |
+
VALID_TAGS = [
|
| 15 |
+
"biology", "chemistry", "physics", "medicine", "mathematics",
|
| 16 |
+
"engineering", "earth-science", "astronomy", "genomics",
|
| 17 |
+
"biotechnology", "materials-science", "climate", "energy",
|
| 18 |
+
"ecology", "conservation", "benchmark", "scientific-reasoning",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
# type -> (file path relative to repo root, exported array name, required fields)
|
| 22 |
+
TARGETS = {
|
| 23 |
+
"organization": ("src/data/organizations.js", "organizations",
|
| 24 |
+
["id", "name", "link", "tags"]),
|
| 25 |
+
"model": ("src/data/models.js", "models",
|
| 26 |
+
["id", "slug", "name", "orgId", "type", "description", "tags"]),
|
| 27 |
+
"dataset": ("src/data/datasets.js", "datasets",
|
| 28 |
+
["id", "slug", "orgId", "type", "description", "tags"]),
|
| 29 |
+
"blog": ("src/data/blogs.js", "blogs",
|
| 30 |
+
["id", "title", "slug", "orgId", "date", "excerpt", "link", "tags"]),
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def target_file(type_: str) -> str:
|
| 35 |
+
return TARGETS[type_][0]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def missing_fields(type_: str, fields: dict) -> list[str]:
|
| 39 |
+
_, _, required = TARGETS[type_]
|
| 40 |
+
return [f for f in required if not fields.get(f)]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _js_string(value) -> str:
|
| 44 |
+
return json.dumps(value)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def render_entry(type_: str, fields: dict) -> str:
|
| 48 |
+
"""Render a single object literal matching the existing file style."""
|
| 49 |
+
if type_ == "organization":
|
| 50 |
+
lines = [
|
| 51 |
+
" {",
|
| 52 |
+
f' id: {_js_string(fields["id"])},',
|
| 53 |
+
f' name: {_js_string(fields["name"])},',
|
| 54 |
+
f' logo: getOrgLogo({_js_string(fields["id"])}),',
|
| 55 |
+
" description:",
|
| 56 |
+
f' {_js_string(fields.get("description", ""))},',
|
| 57 |
+
f' link: {_js_string(fields["link"])},',
|
| 58 |
+
f' tags: {json.dumps(fields["tags"])},',
|
| 59 |
+
" },",
|
| 60 |
+
]
|
| 61 |
+
elif type_ == "model":
|
| 62 |
+
lines = [
|
| 63 |
+
" {",
|
| 64 |
+
f' id: {_js_string(fields["id"])},',
|
| 65 |
+
f' slug: {_js_string(fields["slug"])},',
|
| 66 |
+
f' name: {_js_string(fields["name"])},',
|
| 67 |
+
f' orgId: {_js_string(fields["orgId"])},',
|
| 68 |
+
f' type: {_js_string(fields["type"])},',
|
| 69 |
+
" description:",
|
| 70 |
+
f' {_js_string(fields["description"])},',
|
| 71 |
+
f' tags: {json.dumps(fields["tags"])},',
|
| 72 |
+
" },",
|
| 73 |
+
]
|
| 74 |
+
elif type_ == "dataset":
|
| 75 |
+
lines = [
|
| 76 |
+
" {",
|
| 77 |
+
f' id: {_js_string(fields["id"])},',
|
| 78 |
+
f' slug: {_js_string(fields["slug"])},',
|
| 79 |
+
f' orgId: {_js_string(fields["orgId"])},',
|
| 80 |
+
f' type: {_js_string(fields["type"])},',
|
| 81 |
+
" description:",
|
| 82 |
+
f' {_js_string(fields["description"])},',
|
| 83 |
+
f' tags: {json.dumps(fields["tags"])},',
|
| 84 |
+
" },",
|
| 85 |
+
]
|
| 86 |
+
elif type_ == "blog":
|
| 87 |
+
lines = [
|
| 88 |
+
" {",
|
| 89 |
+
f' id: {_js_string(fields["id"])},',
|
| 90 |
+
f' title: {_js_string(fields["title"])},',
|
| 91 |
+
f' slug: {_js_string(fields["slug"])},',
|
| 92 |
+
f' orgId: {_js_string(fields["orgId"]) if fields.get("orgId") else "null"},',
|
| 93 |
+
f' date: {_js_string(fields["date"])},',
|
| 94 |
+
" excerpt:",
|
| 95 |
+
f' {_js_string(fields["excerpt"])},',
|
| 96 |
+
f' link: {_js_string(fields["link"])},',
|
| 97 |
+
f' tags: {json.dumps(fields["tags"])},',
|
| 98 |
+
f' featured: {"true" if fields.get("featured") else "false"},',
|
| 99 |
+
]
|
| 100 |
+
if fields.get("upvotes") is not None:
|
| 101 |
+
lines.append(f' upvotes: {int(fields["upvotes"])},')
|
| 102 |
+
lines.append(" },")
|
| 103 |
+
else:
|
| 104 |
+
raise ValueError(f"Unknown submission type: {type_}")
|
| 105 |
+
|
| 106 |
+
return "\n".join(lines)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def insert_entry(file_content: str, type_: str, entry_block: str, org_id: str | None) -> str:
|
| 110 |
+
"""Insert entry_block into file_content, grouped near org_id's other
|
| 111 |
+
entries when found, otherwise right after the array's opening line."""
|
| 112 |
+
_, array_name, _ = TARGETS[type_]
|
| 113 |
+
lines = file_content.split("\n")
|
| 114 |
+
opening = f"export const {array_name} = ["
|
| 115 |
+
|
| 116 |
+
insert_at = None
|
| 117 |
+
if org_id:
|
| 118 |
+
for i, line in enumerate(lines):
|
| 119 |
+
if f'orgId: "{org_id}"' in line:
|
| 120 |
+
# walk forward to this object's closing " },"
|
| 121 |
+
for j in range(i + 1, len(lines)):
|
| 122 |
+
if lines[j] == " },":
|
| 123 |
+
insert_at = j + 1
|
| 124 |
+
break
|
| 125 |
+
# keep scanning in case a later group is a better (last) match
|
| 126 |
+
if insert_at is None:
|
| 127 |
+
for i, line in enumerate(lines):
|
| 128 |
+
if line.strip() == opening:
|
| 129 |
+
insert_at = i + 1
|
| 130 |
+
break
|
| 131 |
+
|
| 132 |
+
if insert_at is None:
|
| 133 |
+
raise ValueError(f"Could not find `{opening}` in target file")
|
| 134 |
+
|
| 135 |
+
new_lines = lines[:insert_at] + entry_block.split("\n") + lines[insert_at:]
|
| 136 |
+
return "\n".join(new_lines)
|
github_pr.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Open a GitHub PR against the website repo for an approved submission.
|
| 2 |
+
|
| 3 |
+
The site owner still reviews and merges the PR by hand — this only removes
|
| 4 |
+
the manual data-entry step of splicing a new object into the right
|
| 5 |
+
src/data/*.js array.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import base64
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
import formatters
|
| 14 |
+
from about import GITHUB_API, GITHUB_REPO
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _headers() -> dict:
|
| 18 |
+
token = os.environ["GITHUB_TOKEN"]
|
| 19 |
+
return {
|
| 20 |
+
"Authorization": f"Bearer {token}",
|
| 21 |
+
"Accept": "application/vnd.github+json",
|
| 22 |
+
"X-GitHub-Api-Version": "2022-11-28",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _default_branch() -> str:
|
| 27 |
+
resp = requests.get(f"{GITHUB_API}/repos/{GITHUB_REPO}", headers=_headers())
|
| 28 |
+
resp.raise_for_status()
|
| 29 |
+
return resp.json()["default_branch"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _get_file(path: str, ref: str) -> tuple[str, str]:
|
| 33 |
+
"""Return (content, sha) of a file at ref."""
|
| 34 |
+
resp = requests.get(
|
| 35 |
+
f"{GITHUB_API}/repos/{GITHUB_REPO}/contents/{path}",
|
| 36 |
+
headers=_headers(),
|
| 37 |
+
params={"ref": ref},
|
| 38 |
+
)
|
| 39 |
+
resp.raise_for_status()
|
| 40 |
+
data = resp.json()
|
| 41 |
+
content = base64.b64decode(data["content"]).decode("utf-8")
|
| 42 |
+
return content, data["sha"]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _create_branch(branch: str, from_sha: str) -> None:
|
| 46 |
+
resp = requests.post(
|
| 47 |
+
f"{GITHUB_API}/repos/{GITHUB_REPO}/git/refs",
|
| 48 |
+
headers=_headers(),
|
| 49 |
+
json={"ref": f"refs/heads/{branch}", "sha": from_sha},
|
| 50 |
+
)
|
| 51 |
+
resp.raise_for_status()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _branch_sha(branch: str) -> str:
|
| 55 |
+
resp = requests.get(
|
| 56 |
+
f"{GITHUB_API}/repos/{GITHUB_REPO}/git/ref/heads/{branch}", headers=_headers()
|
| 57 |
+
)
|
| 58 |
+
resp.raise_for_status()
|
| 59 |
+
return resp.json()["object"]["sha"]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _update_file(path: str, branch: str, new_content: str, sha: str, message: str) -> None:
|
| 63 |
+
resp = requests.put(
|
| 64 |
+
f"{GITHUB_API}/repos/{GITHUB_REPO}/contents/{path}",
|
| 65 |
+
headers=_headers(),
|
| 66 |
+
json={
|
| 67 |
+
"message": message,
|
| 68 |
+
"content": base64.b64encode(new_content.encode("utf-8")).decode("ascii"),
|
| 69 |
+
"sha": sha,
|
| 70 |
+
"branch": branch,
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
resp.raise_for_status()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _open_pr(branch: str, base: str, title: str, body: str) -> str:
|
| 77 |
+
resp = requests.post(
|
| 78 |
+
f"{GITHUB_API}/repos/{GITHUB_REPO}/pulls",
|
| 79 |
+
headers=_headers(),
|
| 80 |
+
json={"title": title, "head": branch, "base": base, "body": body},
|
| 81 |
+
)
|
| 82 |
+
resp.raise_for_status()
|
| 83 |
+
return resp.json()["html_url"]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def open_pr_for_submission(submission: dict, fields: dict) -> str:
|
| 87 |
+
"""Splice the approved entry into the right data file and open a PR.
|
| 88 |
+
|
| 89 |
+
`submission` is the row from submissions.py; `fields` are the
|
| 90 |
+
reviewer-filled values matching formatters.TARGETS[type_]'s schema.
|
| 91 |
+
"""
|
| 92 |
+
type_ = submission["type"]
|
| 93 |
+
file_path = formatters.target_file(type_)
|
| 94 |
+
entry_block = formatters.render_entry(type_, fields)
|
| 95 |
+
org_id = fields.get("orgId")
|
| 96 |
+
|
| 97 |
+
base = _default_branch()
|
| 98 |
+
base_sha = _branch_sha(base)
|
| 99 |
+
|
| 100 |
+
slug = fields.get("id", submission["id"][:8])
|
| 101 |
+
branch = f"add/{type_}-{slug}"
|
| 102 |
+
_create_branch(branch, base_sha)
|
| 103 |
+
|
| 104 |
+
content, sha = _get_file(file_path, branch)
|
| 105 |
+
new_content = formatters.insert_entry(content, type_, entry_block, org_id)
|
| 106 |
+
_update_file(
|
| 107 |
+
file_path, branch, new_content, sha,
|
| 108 |
+
message=f"Add {type_}: {fields.get('name') or fields.get('title') or slug}",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
title = f"Add {type_}: {fields.get('name') or fields.get('title') or slug}"
|
| 112 |
+
body = (
|
| 113 |
+
f"Auto-generated from a huggingscience.co submission via the review Space.\n\n"
|
| 114 |
+
f"- **Submission id:** `{submission['id']}`\n"
|
| 115 |
+
f"- **Submitted at:** {submission['submitted_at']}\n"
|
| 116 |
+
f"- **Source:** {submission['source']}\n\n"
|
| 117 |
+
f"**Original description:**\n{submission['description']}\n"
|
| 118 |
+
)
|
| 119 |
+
return _open_pr(branch, base, title, body)
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio[oauth]>=5.0.0
|
| 2 |
+
huggingface_hub>=0.25.0
|
| 3 |
+
requests>=2.31.0
|
| 4 |
+
dotenv
|
submissions.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Requests queue backed by the existing hugging-science/feedback HF Dataset.
|
| 2 |
+
|
| 3 |
+
`requests.jsonl` holds rows written by the feedback-api Space's /submit
|
| 4 |
+
endpoint for every non-"feedback" type (dataset/model/organization/blog/
|
| 5 |
+
challenge). Ingestion happens there, not here — this module only reads that
|
| 6 |
+
file and updates a row's status when the reviewer approves/rejects it.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import threading
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
|
| 13 |
+
from huggingface_hub import HfApi
|
| 14 |
+
from huggingface_hub.utils import EntryNotFoundError
|
| 15 |
+
|
| 16 |
+
from about import DATASET_REPO, REQUESTS_FILE, TOKEN
|
| 17 |
+
|
| 18 |
+
_api = HfApi(token=TOKEN)
|
| 19 |
+
_lock = threading.Lock()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _now() -> str:
|
| 23 |
+
return datetime.now(timezone.utc).isoformat()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _read_all() -> list[dict]:
|
| 27 |
+
try:
|
| 28 |
+
path = _api.hf_hub_download(
|
| 29 |
+
repo_id=DATASET_REPO, repo_type="dataset", filename=REQUESTS_FILE
|
| 30 |
+
)
|
| 31 |
+
except EntryNotFoundError:
|
| 32 |
+
return []
|
| 33 |
+
with open(path, encoding="utf-8") as f:
|
| 34 |
+
return [json.loads(line) for line in f if line.strip()]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _write_all(rows: list[dict]) -> None:
|
| 38 |
+
content = "\n".join(json.dumps(row) for row in rows) + "\n"
|
| 39 |
+
_api.upload_file(
|
| 40 |
+
path_or_fileobj=content.encode("utf-8"),
|
| 41 |
+
path_in_repo=REQUESTS_FILE,
|
| 42 |
+
repo_id=DATASET_REPO,
|
| 43 |
+
repo_type="dataset",
|
| 44 |
+
commit_message="Update requests queue",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def list_submissions(status: str | None = None) -> list[dict]:
|
| 49 |
+
rows = _read_all()
|
| 50 |
+
if status is None:
|
| 51 |
+
return rows
|
| 52 |
+
return [r for r in rows if r["status"] == status]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_submission(submission_id: str) -> dict | None:
|
| 56 |
+
for row in _read_all():
|
| 57 |
+
if row["id"] == submission_id:
|
| 58 |
+
return row
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def mark_approved(submission_id: str, pr_url: str) -> None:
|
| 63 |
+
with _lock:
|
| 64 |
+
rows = _read_all()
|
| 65 |
+
for row in rows:
|
| 66 |
+
if row["id"] == submission_id:
|
| 67 |
+
row["status"] = "approved"
|
| 68 |
+
row["reviewed_at"] = _now()
|
| 69 |
+
row["pr_url"] = pr_url
|
| 70 |
+
break
|
| 71 |
+
_write_all(rows)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def mark_rejected(submission_id: str, reason: str | None) -> None:
|
| 75 |
+
with _lock:
|
| 76 |
+
rows = _read_all()
|
| 77 |
+
for row in rows:
|
| 78 |
+
if row["id"] == submission_id:
|
| 79 |
+
row["status"] = "rejected"
|
| 80 |
+
row["reviewed_at"] = _now()
|
| 81 |
+
row["reject_reason"] = reason
|
| 82 |
+
break
|
| 83 |
+
_write_all(rows)
|