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'))), )