Surface stale deadlines instead of showing them as upcoming
All checks were successful
Build and deploy static pages / build-and-push (push) Successful in 14s

The calendar's deadline table rendered every recorded submission cycle, so
elapsed rounds sorted to the top and read as the next thing due — 13 of its 17
rows were already in the past. Underneath that, the fetcher preserves
`source: manual` entries forever without ever checking whether they still
describe a future deadline, and 11 of 13 cloud-edge venues are manual.

Preserving them is correct: WikiCFP cannot express multi-cycle venues and does
not carry most systems conferences at all, so re-fetching would replace
researched rounds with worse data or nothing. What was missing is a signal.

- Extract the cycle helpers into cycles.py so the fetcher and the generator
  share one definition of which round a reader should act on. cycles_of()
  reads an entry without folding flat fields into a cycles list, which would
  otherwise rewrite every single-round entry the fetcher writes back.
- The calendar table lists open cycles only; a venue whose every round has
  elapsed moves to an "Awaiting the next call" table instead of vanishing.
- pa-fetch-deadlines records those venues under a new stale: key and probes
  the year-bumped CFP URL to say which ones already have a next edition
  online. A candidate must return 200, mention the target year, and be newer
  than any year the entry records — venues.yaml still points SC at sc24.
- README: Task 7 and a matching agent prompt for keeping deadlines current,
  since nothing in this repo refreshes them on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:25:24 +02:00
parent 827c432dc0
commit 5b06f484f5
6 changed files with 431 additions and 122 deletions

View File

@@ -30,6 +30,14 @@ from pathlib import Path
import yaml
from publish_assistant.cycles import (
cycle_deadline,
next_cycle,
normalise_cycles,
open_cycles,
parse_date,
)
PRESERVED_VENUE_FIELDS = {"notes", "deadline_source"}
# Fields removed from existing front matter on every regeneration:
@@ -120,26 +128,6 @@ def scimago_lookup(data: dict[str, dict], full_name: str, issn: str) -> dict:
return {}
# Fields describing one submission cycle. Everything else on a deadline entry
# (event_dates, location, cfp_url, source) describes the event itself.
CYCLE_FIELDS = ("name", "abstract_deadline", "submission_deadline", "notification",
"camera_ready")
def _normalise_cycles(entry: dict) -> None:
"""Fold a flat, single-cycle entry into a one-element `cycles` list, in place.
Venues with a single submission round stay flat in YAML; consumers below only
ever see `cycles`. `cfp_url` stays at the top level — it describes the call,
not one round — but a cycle may override it where a venue publishes separate
calls per round.
"""
if entry.get("cycles"):
return
cycle = {k: entry.pop(k) for k in CYCLE_FIELDS if k in entry}
entry["cycles"] = [cycle] if cycle else []
def load_deadlines(path: str) -> dict[str, dict]:
p = Path(path)
if not p.exists():
@@ -149,7 +137,7 @@ def load_deadlines(path: str) -> dict[str, dict]:
deadlines = data.get("deadlines") or {}
for entry in deadlines.values():
if entry:
_normalise_cycles(entry)
normalise_cycles(entry)
return deadlines
@@ -353,52 +341,6 @@ def _journal_body(fm: dict, digests: list[dict] | None = None, base_path: str =
# Date helpers
# ---------------------------------------------------------------------------
def _parse_date(s: str, month_only: bool = False) -> str | None:
"""'Apr 1, 2026''2026-04-01', or None if unparseable.
With month_only, also accepts a bare month and year ('Jan 2027'
'2027-01-01'). Journal special issues often announce notifications at
month granularity; conference deadlines are always full dates, so this
stays opt-in to keep their output unchanged.
"""
for fmt in ("%b %d, %Y", "%B %d, %Y"):
try:
return datetime.strptime(s.strip(), fmt).strftime("%Y-%m-%d")
except ValueError:
pass
if month_only:
for fmt in ("%b %Y", "%B %Y"):
try:
return datetime.strptime(s.strip(), fmt).strftime("%Y-%m-%d")
except ValueError:
pass
return None
def _cycle_deadline(cycle: dict) -> str:
"""The date a cycle is judged by: its paper deadline, else its abstract deadline."""
return cycle.get("submission_deadline") or cycle.get("abstract_deadline") or ""
def _next_cycle(entry: dict, today: str | None = None) -> dict:
"""The cycle a reader should act on.
The earliest cycle whose paper deadline has not passed; if every cycle has
elapsed, the last one, so a page shows the most recent round rather than
going blank. Cycles with unparseable dates sort last.
"""
cycles = entry.get("cycles") or []
if not cycles:
return {}
today = today or datetime.now().strftime("%Y-%m-%d")
ordered = sorted(cycles, key=lambda c: _parse_date(_cycle_deadline(c)) or "9999-12-31")
for cycle in ordered:
iso = _parse_date(_cycle_deadline(cycle))
if iso and iso >= today:
return cycle
return ordered[-1]
def _parse_date_range(s: str) -> tuple[str | None, str | None]:
"""Return (start_iso, end_iso_exclusive) from a date-range string.
@@ -410,22 +352,22 @@ def _parse_date_range(s: str) -> tuple[str | None, str | None]:
s = s.strip()
if " - " in s:
left, right = s.split(" - ", 1)
start = _parse_date(left)
end_s = _parse_date(right)
start = parse_date(left)
end_s = parse_date(right)
if start and end_s:
end_dt = datetime.strptime(end_s, "%Y-%m-%d") + timedelta(days=1)
return start, end_dt.strftime("%Y-%m-%d")
m = re.match(r"(\w+)\s+(\d+)-(\d+),\s+(\d{4})", s)
if m:
month, d1, d2, year = m.groups()
start = _parse_date(f"{month} {d1}, {year}")
start = parse_date(f"{month} {d1}, {year}")
try:
end_dt = datetime.strptime(f"{month} {d2}, {year}", "%b %d, %Y") + timedelta(days=1)
if start:
return start, end_dt.strftime("%Y-%m-%d")
except ValueError:
pass
return _parse_date(s), None
return parse_date(s), None
# ---------------------------------------------------------------------------
@@ -455,7 +397,7 @@ def _conferences_section_body(venues: dict, icore: dict, deadlines: dict,
dl = deadlines.get(acronym, {})
# The venue index lists venues, not cycles: show the next open round and
# name it, so a passed round never reads as "you missed this conference".
nxt = _next_cycle(dl)
nxt = next_cycle(dl)
abstract = nxt.get("abstract_deadline") or ""
paper = nxt.get("submission_deadline") or nxt.get("abstract_deadline") or "TBD"
if len(dl.get("cycles") or []) > 1 and nxt.get("name"):
@@ -539,7 +481,7 @@ def _calendar_events(venues: dict, deadlines: dict, base_path: str = "") -> list
("notification", "notification", "notification"),
]:
color, text_color = EVENT_COLORS[kind]
iso = _parse_date(cycle.get(field) or "")
iso = parse_date(cycle.get(field) or "")
if iso:
ev: dict = {"title": f"{prefix}{label}", "start": iso,
"url": url, "color": color,
@@ -580,7 +522,7 @@ def _calendar_events(venues: dict, deadlines: dict, base_path: str = "") -> list
("notification", "notification"),
]:
# Notifications are often month-only ('Jan 2027'); deadlines are not.
iso = _parse_date(si.get(field) or "", month_only=(field == "notification"))
iso = parse_date(si.get(field) or "", month_only=(field == "notification"))
if iso:
ev = {"title": f"{acronym} SI — {si_title}{label}",
"start": iso, "url": url, "color": journal_color,
@@ -603,14 +545,26 @@ def _calendar_body(venues: dict, icore: dict, deadlines: dict, base_path: str =
lines: list[str] = []
conf_rows = []
stale_rows = []
for conf in venues.get("conferences") or []:
acronym = conf.get("acronym", "")
rank = icore.get(acronym.upper(), "") or ""
dl_data = deadlines.get(acronym, {})
event = dl_data.get("event_dates") or ""
location = dl_data.get("location") or ""
# One row per submission cycle — a venue's later rounds are the point.
for cycle in dl_data.get("cycles") or [{}]:
# One row per still-open submission cycle — a venue's later rounds are
# the point, but an elapsed round is not something a reader can act on,
# and sorting by deadline parks it at the top of the table.
still_open = open_cycles(dl_data)
if not still_open:
# Every recorded round has passed: the venue is still tracked, its
# next CFP just isn't out (or isn't fetched) yet. Listed separately
# below so a stale date never reads as an upcoming deadline.
last = next_cycle(dl_data)
stale_rows.append((acronym, conf.get("full_name", ""),
cycle_deadline(last) or "TBD", event, location, rank))
continue
for cycle in still_open:
abstract = cycle.get("abstract_deadline") or ""
paper = cycle.get("submission_deadline") or cycle.get("abstract_deadline") or "TBD"
notif = cycle.get("notification") or ""
@@ -618,19 +572,38 @@ def _calendar_body(venues: dict, icore: dict, deadlines: dict, base_path: str =
abstract, paper, notif, event, location, rank))
conf_rows.sort(key=lambda r: (parse_deadline(r[4]), r[0]))
stale_rows.sort(key=lambda r: (parse_deadline(r[2]), r[0]))
lines += [
"\n## Conference Deadlines\n",
"| Venue | Name | Cycle | Abstract | Paper deadline | Notification | Event dates | Location | ICORE |",
"|---|---|---|---|---|---|---|---|---|",
]
for acronym, full_name, cycle_name, abstract, paper, notif, event, location, rank in conf_rows:
abs_cell = abstract if (abstract and abstract != paper) else ""
link = f"[{acronym}]({base_path}/venues/conferences/{acronym.lower()}/)"
lines.append(
f"| {link} | {full_name} | {cycle_name} | {abs_cell} | {paper} | {notif} "
f"| {event} | {location} | {rank} |"
)
lines.append("\n## Conference Deadlines\n")
if conf_rows:
lines += [
"| Venue | Name | Cycle | Abstract | Paper deadline | Notification | Event dates | Location | ICORE |",
"|---|---|---|---|---|---|---|---|---|",
]
for acronym, full_name, cycle_name, abstract, paper, notif, event, location, rank in conf_rows:
abs_cell = abstract if (abstract and abstract != paper) else ""
link = f"[{acronym}]({base_path}/venues/conferences/{acronym.lower()}/)"
lines.append(
f"| {link} | {full_name} | {cycle_name} | {abs_cell} | {paper} | {notif} "
f"| {event} | {location} | {rank} |"
)
else:
lines.append("No open call at any tracked conference.")
if stale_rows:
lines += [
"\n### Awaiting the next call\n",
"Tracked venues whose recorded rounds have all elapsed — the next CFP "
"has not been published or picked up yet. Dates below are the last "
"round that ran, kept for reference only.\n",
"| Venue | Name | Last recorded deadline | Event dates | Location | ICORE |",
"|---|---|---|---|---|---|",
]
for acronym, full_name, last_deadline, event, location, rank in stale_rows:
link = f"[{acronym}]({base_path}/venues/conferences/{acronym.lower()}/)"
lines.append(
f"| {link} | {full_name} | {last_deadline} | {event} | {location} | {rank} |"
)
si_rows = []
for journal in venues.get("journals") or []:
@@ -732,7 +705,7 @@ def gen_conferences(venues: dict, icore: dict, deadlines: dict, root: Path,
}
dl = deadlines.get(acronym, {})
cycles = dl.get("cycles") or []
nxt = _next_cycle(dl)
nxt = next_cycle(dl)
if nxt.get("submission_deadline"):
new_fm["next_deadline"] = nxt["submission_deadline"]
elif nxt.get("abstract_deadline"):