from __future__ import annotations
import json
import os
from pathlib import Path
import gradio as gr
from huggingface_hub.utils import HfHubHTTPError
try:
from .repo_ops import (
DEFAULT_REPO_ID,
allocate_next_task_id,
create_dataset_pr,
get_repo_head_sha,
list_existing_task_ids,
load_hf_token,
)
from .validator import (
DOMAINS,
PreparedSubmission,
SubmissionMetadata,
ValidationError,
build_public_report,
cleanup_stale_managed_files,
cleanup_submission_state,
cleanup_uploaded_archive,
cleanup_work_dir,
normalize_domain_token,
persist_uploaded_archive,
stage_submission,
validate_and_prepare_submission,
)
except ImportError:
from repo_ops import (
DEFAULT_REPO_ID,
allocate_next_task_id,
create_dataset_pr,
get_repo_head_sha,
list_existing_task_ids,
load_hf_token,
)
from validator import (
DOMAINS,
PreparedSubmission,
SubmissionMetadata,
ValidationError,
build_public_report,
cleanup_stale_managed_files,
cleanup_submission_state,
cleanup_uploaded_archive,
cleanup_work_dir,
normalize_domain_token,
persist_uploaded_archive,
stage_submission,
validate_and_prepare_submission,
)
SPACE_TITLE = 'ResearchClawBench Task Submission'
GITHUB_REPO_URL = 'https://github.com/InternScience/ResearchClawBench'
DATASET_URL = f'https://huggingface.co/datasets/{DEFAULT_REPO_ID}'
SPACE_URL = 'https://huggingface.co/spaces/InternScience/ResearchClawBench-Task-Submit'
STATE_TTL_SECONDS = int(os.environ.get('RCB_SPACE_STATE_TTL_SECONDS', '3600'))
STALE_WORK_DIR_TTL_SECONDS = int(
os.environ.get('RCB_SPACE_STALE_WORK_DIR_TTL_SECONDS', str(max(STATE_TTL_SECONDS * 2, 24 * 3600)))
)
_removed_stale_managed_files = cleanup_stale_managed_files(STALE_WORK_DIR_TTL_SECONDS)
if _removed_stale_managed_files:
print(
f'[startup] Removed {_removed_stale_managed_files} stale managed submission file(s) '
f'older than {STALE_WORK_DIR_TTL_SECONDS}s.',
flush=True,
)
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap');
:root {
--page-text: #0f172a;
--page-muted: #526075;
--page-line: rgba(15, 23, 42, 0.12);
--page-surface-strong: #ffffff;
}
body {
background:
radial-gradient(circle at top left, rgba(54, 107, 245, 0.12), transparent 34%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.08), transparent 28%),
linear-gradient(180deg, #f8fafc 0%, #f3f6fb 55%, #f6f8fb 100%);
color: var(--page-text);
}
body,
button,
input,
textarea {
font-family: 'Manrope', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif !important;
}
.gradio-container {
max-width: 1280px !important;
margin: 0 auto !important;
padding: 40px 40px 64px !important;
--block-background-fill: transparent;
--block-border-width: 0px;
--block-border-color: transparent;
--block-label-background-fill: transparent;
--block-label-border-width: 0px;
--panel-background-fill: transparent;
--panel-border-width: 0px;
--panel-border-color: transparent;
--background-fill-secondary: transparent;
--body-background-fill: transparent;
}
.page-shell {
margin-top: 26px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 22px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.05);
overflow: hidden;
padding: 34px 0 40px;
}
.page-shell-content {
gap: 0 !important;
}
.shell-spacer {
min-width: 44px !important;
}
.hero {
padding: 42px 48px 36px;
border-radius: 24px;
color: #f8fbff;
background:
radial-gradient(circle at 14% 18%, rgba(255, 255, 255, 0.16), transparent 18%),
linear-gradient(135deg, #0f274d 0%, #133c7c 46%, #124f75 100%);
box-shadow: 0 26px 60px rgba(15, 39, 77, 0.18);
}
.hero h1 {
margin: 0;
font-size: 2.4rem;
line-height: 1.02;
letter-spacing: -0.04em;
color: #f8fbff !important;
text-shadow: 0 1px 12px rgba(0, 0, 0, 0.14);
}
.hero-copy {
margin-top: 16px;
max-width: 860px;
font-size: 1.04rem;
line-height: 1.72;
color: rgba(248, 251, 255, 0.9) !important;
}
.hero-links {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-top: 22px;
}
.hero-links a {
color: #f8fbff !important;
text-decoration: none;
font-weight: 700;
letter-spacing: -0.01em;
}
.hero-links a:hover {
text-decoration: underline;
}
.hero-meta {
margin-top: 18px;
font-size: 0.93rem;
color: rgba(248, 251, 255, 0.72) !important;
}
.section-row {
margin-top: 34px;
gap: 30px;
}
.section-row,
.section-row > div,
.section-copy,
.section-copy > div,
.main-form,
.side-notes {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
.section-copy h2 {
margin: 0 0 10px;
font-size: 1.2rem;
letter-spacing: -0.03em;
}
.section-copy h3 {
margin: 24px 0 8px;
font-size: 1rem;
}
.section-copy p,
.section-copy li {
color: #5a667a;
line-height: 1.72;
}
.section-copy ul,
.section-copy ol {
margin: 10px 0 0;
padding-left: 1.2rem;
}
.section-copy code {
font-size: 0.95em;
}
.section-copy .prose {
max-width: 100%;
}
.subtle-block {
padding-bottom: 22px;
border-bottom: 1px solid var(--page-line);
}
.section-copy .prose,
.section-copy .prose *,
.section-copy .md,
.section-copy .md *,
.section-copy .markdown,
.section-copy .markdown * {
background: transparent !important;
}
.main-form {
padding-right: 0;
}
.side-notes {
padding-left: 0;
}
.main-form > div {
padding-right: 18px !important;
}
.side-notes > div {
padding-left: 18px !important;
}
.caption {
margin-top: 8px;
color: var(--page-muted);
font-size: 0.93rem;
line-height: 1.6;
}
.field-label {
margin: 18px 0 8px;
color: var(--page-text);
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.results-shell {
margin-top: 0;
padding-top: 22px;
border-top: 1px solid var(--page-line);
}
.results-shell > div {
margin-top: 26px;
}
.action-row {
margin-top: 10px;
}
.upload-row {
margin-top: 18px;
margin-bottom: 10px;
}
.upload-button button {
border-radius: 12px !important;
min-height: 48px !important;
padding: 0 18px !important;
background: #ffffff !important;
color: var(--page-text) !important;
border: 1px solid rgba(19, 70, 162, 0.16) !important;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04) !important;
}
.upload-status {
padding-top: 10px;
}
.upload-status p {
margin: 0 !important;
color: var(--page-muted) !important;
}
.primary-button button,
.secondary-button button {
border-radius: 12px !important;
min-height: 48px !important;
font-weight: 700 !important;
letter-spacing: -0.01em;
}
.primary-button button {
background: linear-gradient(135deg, #1346a2 0%, #155eef 100%) !important;
box-shadow: 0 16px 32px rgba(21, 94, 239, 0.2) !important;
}
.secondary-button button {
background: var(--page-surface-strong) !important;
color: var(--page-text) !important;
border: 1px solid rgba(15, 23, 42, 0.12) !important;
}
.gradio-container .block,
.gradio-container .gr-box,
.gradio-container .gr-form,
.gradio-container .gr-group,
.gradio-container .form,
.gradio-container .input-container,
.gradio-container .wrap,
.gradio-container .row,
.gradio-container .column,
.gradio-container fieldset {
background: transparent !important;
box-shadow: none !important;
border-color: transparent !important;
}
.gradio-container input:not([type="checkbox"]),
.gradio-container textarea,
.gradio-container button[aria-haspopup="listbox"],
.gradio-container button[role="listbox"],
.gradio-container .wrap:has(input:not([type="checkbox"])),
.gradio-container .wrap:has(textarea),
.gradio-container .wrap:has(button[aria-haspopup="listbox"]),
.gradio-container .wrap:has(button[role="listbox"]),
.gradio-container .wrap:has(select),
.gradio-container .input-container:has(input:not([type="checkbox"])),
.gradio-container .input-container:has(textarea),
.gradio-container .input-container:has(button[aria-haspopup="listbox"]),
.gradio-container .input-container:has(button[role="listbox"]),
.gradio-container input:not([type="checkbox"]),
.gradio-container textarea {
background: var(--page-surface-strong) !important;
border: 1px solid rgba(19, 70, 162, 0.16) !important;
border-radius: 10px !important;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02), 0 8px 22px rgba(15, 23, 42, 0.04) !important;
}
.gradio-container .block,
.gradio-container .wrap,
.gradio-container .gr-box,
.gradio-container .gr-form,
.gradio-container .gr-panel,
.gradio-container .gr-group,
.gradio-container .form,
.gradio-container .input-container,
.gradio-container .wrap-inner {
overflow: visible !important;
}
.gradio-container label,
.gradio-container .label-wrap,
.gradio-container .caption-label {
color: var(--page-text) !important;
}
@media (max-width: 900px) {
.gradio-container {
padding: 22px 18px 42px !important;
}
.page-shell {
padding: 24px 0 30px;
}
.shell-spacer {
min-width: 18px !important;
}
.hero {
padding: 30px 24px 26px;
border-radius: 20px;
}
.hero h1 {
font-size: 2rem;
}
.main-form,
.side-notes {
padding-right: 0;
padding-left: 0;
}
}
"""
def build_hero_html() -> str:
return f"""
{SPACE_TITLE}
Submit a new ResearchClawBench task as a single ZIP archive. This Space validates the full task
structure, checks JSON fields and referenced paths, allocates the next available task ID, and then
opens a PR against the official Hugging Face dataset for maintainer review.
ZIP upload only · full task-format validation · PR to dataset repo after passing checks
"""
def field_label_html(text: str) -> str:
return f'
{text}
'
def submission_guide_markdown() -> str:
return """
## Before You Upload
1. Put exactly one task directory at the top level of the ZIP.
2. Make sure the directory contains `task_info.json`, `data/`, `related_work/`, and `target_study/`.
3. Keep every data reference inside `task_info.json` in the `./data/...` format.
4. Make sure every checklist image path points to `target_study/images/...`.
5. Ensure that uploaded files can be redistributed through Hugging Face before submitting.
Example task in GitHub:
[tasks/Astronomy_000](https://github.com/InternScience/ResearchClawBench/tree/main/tasks/Astronomy_000)
---
## Expected ZIP Layout
```text
your_submission.zip
└── any_folder_name/
├── task_info.json
├── data/
├── related_work/
│ ├── paper_000.pdf
│ ├── paper_001.pdf
│ └── ...
└── target_study/
├── checklist.json
├── paper.pdf
└── images/
```
---
## What The Space Checks
- top-level folder structure and missing or extra files
- `task_info.json` and `checklist.json` parseability and required keys
- file naming conventions such as `related_work/paper_000.pdf`
- whether declared data paths actually exist
- whether image references actually exist
- whether invalid source paths or stale `/tasks/...` references remain in descriptions
"""
def final_task_help_html() -> str:
return (
''
'The final task ID is assigned automatically after the Space scans existing tasks/ folders. '
'You do not need to choose the numeric suffix yourself. The selected domain becomes the prefix, and if the '
'custom field is filled, it overrides the suggested domain.'
'
'
)
def resolve_domain(selected_domain: str, custom_domain: str) -> str:
raw_value = (custom_domain or '').strip() or (selected_domain or '').strip()
normalized = normalize_domain_token(raw_value)
if not normalized:
raise ValidationError('Please select a suggested domain or provide a custom domain.')
return normalized
def handle_archive_upload(archive_path: str | None, current_archive_path: str | None):
if current_archive_path and current_archive_path != archive_path:
cleanup_uploaded_archive(current_archive_path)
if not archive_path:
return '', 'No ZIP file selected yet.'
managed_archive_path = persist_uploaded_archive(archive_path)
original_path = Path(archive_path)
managed_name = managed_archive_path.name
if managed_archive_path.resolve() != original_path.resolve():
try:
original_path.unlink()
except OSError:
pass
return str(managed_archive_path), f'Selected ZIP: `{managed_name}`'
def archive_notice_text(archive_path: str | None) -> str:
if not archive_path:
return 'No ZIP file selected yet.'
return f'Selected ZIP: `{Path(archive_path).name}`'
def build_validation_markdown(prepared: PreparedSubmission) -> str:
metadata = prepared.metadata
return '\n'.join([
'## Validation passed',
'',
f'- Final task ID: `{prepared.assigned_task_id}`',
'- This is the folder name that will be created under `tasks/` in the dataset repo.',
f'- Domain token used for allocation: `{metadata.domain}`',
f'- Submitter: `{metadata.submitter}`',
f'- Archive file count: `{prepared.archive_stats.file_count}`',
f'- Archive total bytes: `{prepared.archive_stats.total_bytes}`',
'',
'You can now create a PR to the Hugging Face dataset repo.',
])
def build_failure_markdown(message: str) -> str:
items = [line.strip() for line in message.splitlines() if line.strip()]
bullets = '\n'.join(f'- {item}' for item in items) if items else '- Unknown validation error'
return f'## Validation failed\n\n{bullets}'
def refresh_prepared_submission_for_pr(
prepared: PreparedSubmission,
*,
repo_id: str,
token: str | None,
) -> tuple[PreparedSubmission, bool, str]:
head_sha = get_repo_head_sha(repo_id=repo_id, token=token)
existing_ids = list_existing_task_ids(repo_id=repo_id, token=token)
reassigned = False
final_task_id = prepared.assigned_task_id
if final_task_id in existing_ids:
final_task_id = allocate_next_task_id(prepared.metadata.domain, existing_ids)
prepared.assigned_task_id = final_task_id
prepared.staged_task_dir = str(
stage_submission(prepared.uploaded_task_dir, final_task_id, prepared.work_dir)
)
reassigned = True
return prepared, reassigned, head_sha
def is_retryable_pr_error(exc: Exception) -> bool:
if not isinstance(exc, HfHubHTTPError):
return False
status_code = getattr(getattr(exc, 'response', None), 'status_code', None)
message = str(exc).lower()
return status_code in {409, 412} or 'parent commit' in message or 'conflict' in message or 'stale' in message
def validate_submission(
archive_path: str,
suggested_domain: str,
custom_domain: str,
submitter: str,
email: str,
paper_title: str,
paper_url: str,
notes: str,
current_state: dict | None,
):
if current_state:
cleanup_work_dir(current_state.get('work_dir'))
if not archive_path:
return (
None,
'',
'',
'## Validation failed\n\n- Please upload a zip file.',
'{}',
gr.update(interactive=False),
'',
archive_notice_text(None),
)
domain = resolve_domain(suggested_domain, custom_domain)
token = load_hf_token()
metadata = SubmissionMetadata(
domain=domain,
submitter=submitter,
email=email,
paper_title=paper_title,
paper_url=paper_url,
notes=notes or '',
)
try:
existing_ids = list_existing_task_ids(repo_id=DEFAULT_REPO_ID, token=token)
assigned_task_id = allocate_next_task_id(domain, existing_ids)
prepared = validate_and_prepare_submission(archive_path, metadata, assigned_task_id)
pr_ready = bool(token)
return (
prepared.to_state(),
archive_path,
prepared.assigned_task_id,
build_validation_markdown(prepared),
json.dumps(build_public_report(prepared), indent=2, ensure_ascii=False),
gr.update(interactive=pr_ready),
'' if pr_ready else 'Validation passed, but PR creation is disabled until a write token is configured.',
archive_notice_text(archive_path),
)
except ValidationError as exc:
cleanup_uploaded_archive(archive_path)
return (
None,
'',
'',
build_failure_markdown(str(exc)),
json.dumps({'status': 'error', 'errors': str(exc).splitlines()}, indent=2, ensure_ascii=False),
gr.update(interactive=False),
'',
archive_notice_text(None),
)
except Exception as exc:
cleanup_uploaded_archive(archive_path)
return (
None,
'',
'',
build_failure_markdown(str(exc)),
json.dumps({'status': 'error', 'errors': [str(exc)]}, indent=2, ensure_ascii=False),
gr.update(interactive=False),
'',
archive_notice_text(None),
)
def create_pr(state: dict | None, archive_path: str | None):
if not state:
return (
None,
'',
gr.update(interactive=False),
'## PR creation failed\n\n- Validate a submission first.',
'No ZIP file selected yet.',
)
prepared = PreparedSubmission.from_state(state)
token = load_hf_token()
reassigned = False
for attempt in range(2):
try:
prepared, was_reassigned, head_sha = refresh_prepared_submission_for_pr(
prepared,
repo_id=DEFAULT_REPO_ID,
token=token,
)
reassigned = reassigned or was_reassigned
commit_info = create_dataset_pr(
prepared,
repo_id=DEFAULT_REPO_ID,
token=token,
parent_commit=head_sha,
)
pr_url = commit_info.pr_url or commit_info.commit_url
lines = [
'## PR created',
'',
f'- Task ID: `{prepared.assigned_task_id}`',
f'- PR: {pr_url}',
]
if reassigned:
lines.insert(3, '- The task ID was reassigned at PR time because the previously validated ID is no longer available on the dataset main branch.')
message = '\n'.join(lines)
cleanup_work_dir(prepared.work_dir)
cleanup_uploaded_archive(archive_path)
return None, '', gr.update(interactive=False), message, archive_notice_text(None)
except Exception as exc:
if attempt == 0 and is_retryable_pr_error(exc):
continue
message = str(exc).strip() or 'Unknown PR creation error'
if is_retryable_pr_error(exc):
message += '\nPlease click "Create Dataset PR" again. The dataset main branch changed while your PR was being created.'
return (
prepared.to_state(),
archive_path or '',
gr.update(interactive=bool(token)),
build_failure_markdown(message),
archive_notice_text(archive_path),
)
with gr.Blocks(title=SPACE_TITLE, fill_width=True) as demo:
state = gr.State(None, time_to_live=STATE_TTL_SECONDS, delete_callback=cleanup_submission_state)
archive_state = gr.State('', time_to_live=STATE_TTL_SECONDS, delete_callback=cleanup_uploaded_archive)
gr.HTML(build_hero_html())
with gr.Group(elem_classes=['page-shell']):
with gr.Row():
with gr.Column(scale=1, min_width=0, elem_classes=['shell-spacer']):
gr.HTML('')
with gr.Column(scale=30, min_width=0, elem_classes=['page-shell-content']):
with gr.Row(elem_classes=['section-row']):
with gr.Column(scale=7, elem_classes=['section-copy', 'main-form']):
gr.HTML(field_label_html('Task ZIP archive'))
with gr.Row(elem_classes=['upload-row']):
archive = gr.UploadButton(
'Select ZIP file',
file_types=['.zip'],
file_count='single',
type='filepath',
variant='secondary',
elem_classes=['upload-button'],
)
archive_notice = gr.Markdown('No ZIP file selected yet.', elem_classes=['upload-status'])
with gr.Row():
with gr.Column():
gr.HTML(field_label_html('Suggested domain'))
suggested_domain = gr.Dropdown(
choices=list(DOMAINS),
value='Astronomy',
show_label=False,
container=False,
)
with gr.Column():
gr.HTML(field_label_html('Custom domain (optional)'))
custom_domain = gr.Textbox(
placeholder='e.g. Robotics or Robot-Learning',
show_label=False,
container=False,
)
gr.Markdown(
'Use the custom field if your task does not belong to the suggested list. '
'If the custom field is filled, it overrides the suggested domain and becomes the prefix of the final task ID.
'
)
gr.HTML(field_label_html('Submitter name or HF username'))
submitter = gr.Textbox(
placeholder='e.g. your-hf-handle',
show_label=False,
container=False,
)
gr.HTML(field_label_html('Contact email'))
email = gr.Textbox(
placeholder='name@example.com',
show_label=False,
container=False,
)
gr.HTML(field_label_html('Target paper title'))
paper_title = gr.Textbox(show_label=False, container=False)
gr.HTML(field_label_html('Target paper URL or DOI'))
paper_url = gr.Textbox(
placeholder='https://... or DOI',
show_label=False,
container=False,
)
gr.HTML(field_label_html('Optional notes for reviewers'))
notes = gr.Textbox(
lines=4,
placeholder='Anything maintainers should know about licensing, preprocessing, or provenance.',
show_label=False,
container=False,
)
with gr.Column(scale=5, elem_classes=['section-copy', 'side-notes']):
gr.Markdown(submission_guide_markdown(), elem_classes=['subtle-block'])
with gr.Row(elem_classes=['action-row']):
validate_btn = gr.Button('Validate ZIP', variant='primary', elem_classes=['primary-button'])
create_pr_btn = gr.Button('Create Dataset PR', interactive=False, elem_classes=['secondary-button'])
with gr.Column(elem_classes=['section-copy', 'results-shell']):
gr.HTML(field_label_html('Final task ID (assigned automatically)'))
assigned_task_id = gr.Textbox(
interactive=False,
show_label=False,
container=False,
)
gr.Markdown(final_task_help_html())
validation_md = gr.Markdown()
gr.HTML(field_label_html('Validation report'))
validation_report = gr.Code(language='json', show_label=False, container=False)
pr_md = gr.Markdown()
with gr.Column(scale=1, min_width=0, elem_classes=['shell-spacer']):
gr.HTML('')
archive.upload(
fn=handle_archive_upload,
inputs=[archive, archive_state],
outputs=[archive_state, archive_notice],
)
validate_btn.click(
fn=validate_submission,
inputs=[
archive_state,
suggested_domain,
custom_domain,
submitter,
email,
paper_title,
paper_url,
notes,
state,
],
outputs=[
state,
archive_state,
assigned_task_id,
validation_md,
validation_report,
create_pr_btn,
pr_md,
archive_notice,
],
)
create_pr_btn.click(
fn=create_pr,
inputs=[
state,
archive_state,
],
outputs=[
state,
archive_state,
create_pr_btn,
pr_md,
archive_notice,
],
)
if __name__ == '__main__':
demo.launch(
theme=gr.themes.Base(),
css=CSS,
ssr_mode=False,
server_name=os.environ.get('GRADIO_SERVER_NAME', '0.0.0.0'),
server_port=int(os.environ.get('GRADIO_SERVER_PORT', os.environ.get('PORT', '7860'))),
)