submit / app.py
sahel-sh's picture
deploy portal
8b5b245 verified
Raw History Blame Contribute Delete
19.9 kB
"""MAST @ FIRE 2026 run submission portal (Gradio Space).
One form: upload one ``.jsonl`` (or ``.jsonl.gz``), or an archive (zip or tar)
holding one such file per language. Language, LLM and retriever are read from the records.
One button validates and records: every clean file goes to its language's next
free slot in one atomic commit, and the page shows either the errors or the receipt.
Files are validated with ``mast_validate`` in memory; only clean files reach
storage. Team names are honor-system against a private roster never rendered.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
import os
import re
import tarfile
import zipfile
from typing import Any, Optional
import gradio as gr
from mast_validate import __version__ as validator_version
from mast_validate.languages import TRACKS, display_name
from mast_validate.report import render
from mast_validate.runner import UsageProblem, archive_kind, single_file_report, validate_archive
from portal.config import FIRE_URL, ORGANIZER_EMAIL, REGISTRATION_URL, SITE_URL, Settings
from portal.mailer import send_receipt
from portal import tmpfiles
from portal.ratelimit import Limiter, client_address
from portal.rejected import RejectedLog
from portal.roster import Roster, Team
from portal.slots import Meta, Prepared, SlotError, SlotService, prepare_stream
from portal.storage import make_storage
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
log = logging.getLogger("portal")
settings = Settings.from_env()
storage = make_storage(settings)
roster = Roster(storage, settings.roster_ttl_seconds)
slots = SlotService(storage, max_slots=settings.max_slots, max_uploads=settings.max_uploads_per_key,
attempts=settings.commit_attempts)
rejected = RejectedLog(storage)
limit_validate_ip = Limiter(settings.rate_validate_per_ip)
limit_record_ip = Limiter(settings.rate_record_per_ip)
limit_record_team = Limiter(settings.rate_record_per_team)
TRACK_LABELS = {"multilingual": "MAST Multilingual (15 languages)", "indic": "MAST Indic (9 languages)"}
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def deadline_text() -> str:
d = settings.deadline.astimezone(dt.timezone.utc)
return f"{d:%A %d %B %Y, %H:%M} UTC (23:59 AoE on {(d - dt.timedelta(hours=12)):%d %B})"
def closed_message() -> str:
return (f"### Submissions are closed\nThe run submission deadline was {deadline_text()}. "
f"Contact {ORGANIZER_EMAIL} if you believe this is an error.")
HEADER = f"""
# MAST @ FIRE 2026 Β· Run submission
Up to **3 runs per language per team**. Upload **one `.jsonl` (or `.jsonl.gz`)**, or **one archive** (`.zip`, `.tar`,
`.tar.gz`, `.tgz`) holding one such file per language, named however you like: the records' `language`, `llm` and `retriever` fields say what
each run is. Every file is checked against the official query ids and the corpus before it is stored; a file with
errors is never recorded.
Deadline: **{deadline_text()}**.
Check files offline first with [`mast-validate`](https://github.com/mast-benchmark/mast-validate) (same checks,
same output). Format and guidelines: [{SITE_URL}#submission-format]({SITE_URL}#submission-format).
> **Working notes are not submitted here.** Each team submits one working note per track (ACM format,
> 2–4 pages) centrally through the [FIRE 2026]({FIRE_URL}) submission system, as announced by FIRE.
> Teams that submit runs but no working note may be excluded from the final leaderboard.
"""
def _tmp_json(prefix: str, payload: dict[str, Any]) -> str:
path = tmpfiles.new_path(prefix, ".json")
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
return str(path)
def _upload_path(file) -> Optional[str]:
path = file if isinstance(file, str) else getattr(file, "name", None)
return path if path and os.path.isfile(path) else None
def _common_checks(team_name, track, email, file):
"""Shared preamble. Returns (error_message or None, team, path)."""
if settings.is_closed():
return closed_message(), None, None
fields = [("team name", team_name), ("track", track), ("contact email", email), ("file", file)]
missing = [label for label, v in fields if not v or (isinstance(v, str) and not v.strip())]
if missing:
return f"Please fill in: {', '.join(missing)}.", None, None
if not EMAIL_RE.match(email.strip()):
return "The contact email does not look like an email address.", None, None
m = roster.match(team_name)
if not m.team:
rejected.add(team_name, track)
hint = (f" Did you mean **{m.suggestion}**?" if m.suggestion
else " Make sure the team name is exactly as you registered it.")
return (f"No registered team matches **{team_name.strip()}**.{hint} "
f"If your team is new, [register here]({REGISTRATION_URL}) and try again in two minutes so the "
f"registration propagates. Still not recognized? Email {ORGANIZER_EMAIL}.", None, None)
team = m.team
if team.tracks and track not in team.tracks:
log.warning("track refused team=%s registered=%s requested=%s", team.slug, team.tracks, track)
return (f"**{team.name}** is registered for the **{' and '.join(team.tracks)}** track"
f"{'s' if len(team.tracks) > 1 else ''} only; this upload is for **{track}**. "
f"Email {ORGANIZER_EMAIL} to add a track to your registration. Nothing was recorded.", None, None)
if not team.knows_email(email, track):
log.warning("email refused team=%s", team.slug)
return (f"The contact email is not one of the addresses registered for **{team.name}** on the {track} track. "
f"Use an address from your team's latest registration, or email {ORGANIZER_EMAIL} to update it. "
f"Nothing was recorded.",
None, None)
path = _upload_path(file)
if not path:
return "The upload did not arrive; please try again.", team, None
return None, team, path
def _coverage_line(track: str, team: Team) -> str:
done = slots.languages_submitted(track, team.slug)
total = len(TRACKS[track])
line = f"**{track}** coverage for *{team.name}*: **{len(done)} / {total}** languages have at least one run"
return line + (f" ({', '.join(sorted(done))})" if done else "")
# ---------------------------------------------------------------- one form for one file or a zip
def _verdict(fr: dict[str, Any]) -> str:
if not fr["present"]:
return "missing"
if fr["errors"]:
return f"βœ— {fr['errors']} error{'s' if fr['errors'] != 1 else ''}"
return "βœ“" + (f" {fr['warnings']} warning{'s' if fr['warnings'] != 1 else ''}" if fr["warnings"] else "")
def _prepare_members(path: str, report, rd: dict[str, Any], track: str) -> tuple[dict[str, Prepared], dict[str, str]]:
"""Stream every clean member (or the single file) into a temp gzip. Returns (prepared by language, failures)."""
items: dict[str, Prepared] = {}
failed: dict[str, str] = {}
clean = [fr for fr in report.files if fr.present and fr.language and not fr.errors]
def sub(fr):
return {"validator": rd["validator"], "generated_at": rd["generated_at"], "track": track, "source": fr.name,
"status": "warnings" if fr.warnings else "ok", "files": [fr.to_dict()]}
def one(fr, opener):
try:
with opener() as fh:
items[fr.language] = prepare_stream(fh, fr.name, sub(fr))
except Exception as exc: # a member that validated but cannot be re-read (truncated archive, I/O error)
failed[fr.language] = f"{type(exc).__name__}: {exc}"
log.warning("member %s unreadable on second pass: %s", fr.name, exc)
kind = archive_kind(path)
try:
if kind == "zip":
with zipfile.ZipFile(path) as zf:
for fr in clean:
one(fr, lambda fr=fr: zf.open(fr.name))
elif kind == "tar":
with tarfile.open(path, "r:*") as tf:
for fr in clean:
def opener(fr=fr):
fh = tf.extractfile(fr.name)
if fh is None:
raise OSError("not a regular file")
return fh
one(fr, opener)
else:
for fr in clean:
one(fr, lambda: open(path, "rb"))
except Exception as exc:
for fr in clean:
failed.setdefault(fr.language, f"{type(exc).__name__}: {exc}")
return items, failed
def _too_many(what: str, limiter: Limiter, retry: int) -> str:
return (f"**Too many requests:** the limit is {limiter.limit.describe()} for {what}. "
f"Try again in about {max(1, retry // 60)} minute{'s' if retry >= 120 else ''}. Nothing was recorded.")
def _results_table(files: list[dict[str, Any]], results: dict[str, str]) -> str:
rows = ["| Language | File | Records | LLM Β· retriever | Validation | Result |", "|---|---|---|---|---|---|"]
for fr in files:
lang = fr["language"]
if not fr["present"]:
rows.append(f"| {display_name(lang)} | | | | missing | β€” (no file in the archive) |")
continue
meta = f"{fr.get('llm') or ''} Β· {fr.get('retriever') or ''}"
result = results.get(lang or "", "not submitted (errors)")
rows.append(f"| {display_name(lang) if lang else '?'} | `{fr['name']}` | {fr['records']} | {meta} | {_verdict(fr)} | {result} |")
return "\n".join(rows)
RESULT_TEXT = {"slot": "**submitted** β†’ slot {slot}", "replace": "**submitted** β†’ slot {slot}, replaced the oldest run",
"unchanged": "unchanged, identical to slot {slot}", "skipped_full": "not submitted: {note}", "capped": "not submitted: {note}"}
def on_submit(team_name, track, email, replace_oldest, file, request: gr.Request = None):
"""One step: validate the upload and record every clean file. Shows either the errors or the receipt."""
hidden = gr.update(visible=False)
def fail(msg: str, report_path: Optional[str] = None):
return msg, gr.update(value=report_path, visible=report_path is not None), hidden
tmpfiles.sweep()
addr = client_address(request)
allowed, retry = limit_validate_ip.hit(addr)
if not allowed:
log.warning("rate limit: submit addr=%s", addr)
return fail(_too_many("submissions from one address", limit_validate_ip, retry))
err, team, path = _common_checks(team_name, track, email, file)
if err:
return fail(err)
original = os.path.basename(path)
try:
report = validate_archive(path, track=track) if archive_kind(path) else single_file_report(path, track=track, lang=None, name=original)
except UsageProblem as exc:
return fail(f"Could not validate: {exc}")
log.info("validated team=%s track=%s upload=%s size=%d status=%s errors=%d warnings=%d files=%d", team.slug, track,
original, os.path.getsize(path), report.status, report.n_errors, report.n_warnings, len(report.files))
rd = report.to_dict()
report_path = _tmp_json(f"mast-report-{track}-", rd)
text = render(report, color=False, unicode=True)
present = [fr for fr in rd["files"] if fr["present"]]
zip_level = "\n".join(f"* {f['message']}" + (f" (e.g. {', '.join(f['examples'][:5])})" if f["examples"] else "")
for f in rd["findings"])
details = f"<details><summary>Full validator output</summary>\n\n```text\n{text}```\n</details>"
items, unreadable = _prepare_members(path, report, rd, track)
results = {lang: f"not submitted (unreadable: {why[:60]})" for lang, why in unreadable.items()}
def page(headline: str, note: str = "") -> str:
return (f"### {headline}\n\n" + (note + "\n\n" if note else "") + f"{_coverage_line(track, team)}\n\n"
f"{_results_table(rd['files'], results)}\n\n" + (zip_level + "\n\n" if zip_level else "") + details)
if not items:
tmpfiles.remove(p.gz_path for p in items.values())
return fail(page(f"βœ— Not submitted β€” team **{team.name}**",
"**No file passed validation.** Fix the errors listed in the table and the validator output, then submit again."), report_path)
allowed, retry = limit_record_ip.hit(addr)
what, limiter = "submissions from one address", limit_record_ip
if allowed:
allowed, retry = limit_record_team.hit(team.slug)
what, limiter = "submissions by one team", limit_record_team
if not allowed:
log.warning("rate limit: record addr=%s team=%s", addr, team.slug)
tmpfiles.remove(p.gz_path for p in items.values())
return fail(page(f"βœ— Not submitted β€” team **{team.name}**", _too_many(what, limiter, retry)), report_path)
try:
summary = slots.submit_many(track, team.slug, team.name, items, Meta(submitter_email=email.strip()),
replace_oldest=bool(replace_oldest))
except SlotError as exc:
tmpfiles.remove(p.gz_path for p in items.values())
return fail(page(f"βœ— Not submitted β€” team **{team.name}**", f"**Not submitted:** {exc}"), report_path)
except Exception as exc:
log.exception("submit failed")
tmpfiles.remove(p.gz_path for p in items.values())
return fail(page(f"βœ— Not submitted β€” team **{team.name}**",
f"**Not submitted:** the storage backend failed ({type(exc).__name__}). Nothing was saved; "
f"please retry in a minute or email {ORGANIZER_EMAIL}."), report_path)
tmpfiles.remove(p.gz_path for p in items.values())
for i in summary["items"]:
results[i["language"]] = RESULT_TEXT[i["action"]].format(slot=i["slot"], receipt=i["receipt_id"], note=i["note"])
written = [i for i in summary["items"] if i["action"] in ("slot", "replace")]
if not summary.get("stored") or not written:
why = ("every file that passed is already recorded (identical to a stored run), or its language is full "
"(tick *replace the oldest run* to overwrite) or capped.")
return fail(page(f"Β· Nothing new to submit β€” team **{team.name}**", f"**Nothing was written:** {why}"), report_path)
log.info("recorded team=%s track=%s langs=%s receipt=%s", team.slug, track, [i["language"] for i in written], summary["bulk_receipt_id"])
receipt_path = _tmp_json(f"mast-receipt-{summary['bulk_receipt_id']}-", summary)
body = (f"MAST 2026 submission receipt\n\nTeam: {team.name}\nTrack: {track}\nReceipt id: {summary['bulk_receipt_id']}\n"
f"Uploaded at: {summary['uploaded_at']}\nSubmitted by: {summary['submitter_email']}\n\n"
+ "\n".join(f"{i['language']}: {i['action']}" + (f" slot {i['slot']} receipt {i['receipt_id']} sha256 {i['sha256']} llm {i['llm']} retriever {i['retriever']}"
if i['receipt_id'] else f" ({i['note']})") for i in summary["items"]) + "\n")
mailed = send_receipt(settings, [summary["submitter_email"], *team.notify_emails(track)],
f"[MAST 2026] receipt {summary['bulk_receipt_id']} Β· {team.name} Β· {track}", body)
n = len(written)
headline = f"βœ“ Submitted: {n} language{'s' if n != 1 else ''} recorded β€” team **{team.name}** Β· receipt `{summary['bulk_receipt_id']}`"
note = ("A copy of the receipt was emailed to the submitter and the team contact." if mailed
else "Download the receipt below and keep it; it is your proof of submission.")
if any(fr["errors"] for fr in present):
note += " Files with errors were **not** submitted; fix them and submit again (unchanged languages are skipped automatically)."
return page(headline, note), gr.update(value=report_path, visible=True), gr.update(value=receipt_path, visible=True)
def on_load():
return gr.update(value=closed_message(), visible=True) if settings.is_closed() else gr.update(visible=False)
RULES = f"""
* Three slots per (team, track, language). A new run takes the next free slot. When all three are filled the
language is skipped, unless you tick *replace the oldest run*; a replaced run's receipt is kept, so nothing is
lost silently.
* A file's language, LLM and retriever are read from its records; every record must carry the same values
(`llm` and `retriever` are compared exactly, case-sensitive). Filenames carry no meaning. An archive (zip or
tar) may hold one file per language, named however you like.
* Team names are matched ignoring case and punctuation, but word order matters: "Sahel Test" is not "Test Sahel".
The track must be one your team registered for, and the contact email must be one of the addresses on the
registration form.
* Only agentic runs are accepted.
* Every upload gets a receipt id and a sha256 per language. Keep the receipt. Organizers evaluate exactly the
stored bytes.
* One button: Submit validates the upload and records every file that passes. Files with errors are never
stored; fix them and submit again, unchanged languages are skipped automatically.
* Errors block a file; warnings do not, but each one costs score. `Exact Answer:` must appear in the final
`output_text`; unknown docids score as misses; unprefixed `query_id`s are accepted and reconstructed.
* Overlap languages (bn, hi, ta) exist in both tracks; submit to each track you take part in.
* Rate limits: {settings.rate_validate_per_ip.describe() if settings.rate_validate_per_ip.enabled else 'none'} for validations per address,
{settings.rate_record_per_ip.describe() if settings.rate_record_per_ip.enabled else 'none'} for submissions per address,
{settings.rate_record_per_team.describe() if settings.rate_record_per_team.enabled else 'none'} for submissions per team.
* Questions: {ORGANIZER_EMAIL}. Validator version {validator_version}.
"""
with gr.Blocks(title="MAST 2026 submission", analytics_enabled=False) as demo:
gr.Markdown(HEADER)
closed_banner = gr.Markdown(visible=False)
with gr.Row():
with gr.Column():
team_name = gr.Textbox(label="Team name", placeholder="as on the registration form")
track = gr.Radio(choices=[(v, k) for k, v in TRACK_LABELS.items()], label="Track", value=None)
email = gr.Textbox(label="Contact email", placeholder="an address from your team's registration")
with gr.Column():
upload = gr.File(label="Run file(s): one .jsonl / .jsonl.gz, or a .zip / .tar / .tar.gz / .tgz with one per language",
file_types=[".jsonl", ".gz", ".zip", ".tar", ".tgz"], type="filepath")
replace_oldest = gr.Checkbox(label="Replace the oldest run for languages whose 3 slots are already full", value=False)
submit_btn = gr.Button("Submit", variant="primary")
status = gr.Markdown()
with gr.Row():
report_dl = gr.DownloadButton("Download validation report (JSON)", visible=False)
receipt_dl = gr.DownloadButton("Download receipt (JSON)", visible=False)
with gr.Accordion("Rules and help", open=False):
gr.Markdown(RULES)
gr.Markdown("<small>Registration closes with the run deadline. New registrations are recognized within a minute.</small>")
demo.load(on_load, outputs=[closed_banner])
submit_btn.click(on_submit, inputs=[team_name, track, email, replace_oldest, upload],
outputs=[status, report_dl, receipt_dl])
CSS = ".prose table { width: 100%; } .prose th { white-space: nowrap; } .prose td { vertical-align: top; }"
if __name__ == "__main__":
demo.queue(default_concurrency_limit=4).launch(max_file_size="200mb", show_error=True, css=CSS)