ar0s commited on
Commit
fecaa05
·
1 Parent(s): 68fc3c9

Refactor event handling and caching mechanism

Browse files

- Removed unused dependencies from requirements.txt.
- Updated event data processing in attending.py to include new fields: Category and Tags.
- Replaced CacheStore with direct file handling for event data in attending.py.
- Introduced hf_sync.py for handling Hugging Face dataset interactions.
- Removed cache_store.py and fetcher.py as they are no longer needed.
- Simplified org_colors.py to improve color assignment logic.
- Updated models.py to streamline event data loading and writing.
- Enhanced tests to cover new event structure and functionality.

ARCHITECTURE.md DELETED
@@ -1,264 +0,0 @@
1
- # Architecture / Code Flow (with ASCII maps)
2
-
3
- This repo is intentionally small:
4
-
5
- - `app.py` = UI + HTTP API (Gradio mounted into FastAPI)
6
- - `src/fetcher.py` = crawling + LLM extraction + validation + caching
7
- - `sources.json` = list of org sources to crawl
8
- - `./data/events.json` (golden format also stored in `.data/events.json`) = cache / golden output format
9
-
10
- ---
11
-
12
- ## 1) High-level module map
13
-
14
- ```
15
- +--------------------+ imports / calls +---------------------+
16
- | app.py | ---------------------------> | src/fetcher.py |
17
- | | | |
18
- | SeminarsWebApp | | SeminarFetcher |
19
- | - Gradio UI | | + refresh_all* |
20
- | - /refresh API | | + models + cache |
21
- +--------------------+ +---------------------+
22
- |
23
- | reads
24
- v
25
- +--------------------+
26
- | sources.json |
27
- | list[OrgSource] |
28
- +--------------------+
29
-
30
- Cache file (written/read by fetcher):
31
-
32
- +--------------------+
33
- | ./data/events.json | (golden snapshot at .data/events.json)
34
- | meta + results |
35
- +--------------------+
36
- ```
37
-
38
- ---
39
-
40
- ## 2) Runtime flow (what happens when you open the UI)
41
-
42
- ### 2.1 Gradio initial load
43
-
44
- ```
45
- Browser
46
- |
47
- v
48
- Gradio page load
49
- |
50
- v
51
- app.py: demo.load(load_initial)
52
- |
53
- v
54
- SeminarsWebApp._stream_refresh(force=False) [generator]
55
- |
56
- v
57
- src/fetcher.py: refresh_all_stream(... force=False)
58
- |
59
- +--> if cache usable -> yield logs + cached results -> done
60
- |
61
- \--> else -> crawl + LLM -> write cache -> done
62
- ```
63
-
64
- ### 2.2 Manual refresh button
65
-
66
- ```
67
- User clicks "Refresh now"
68
- |
69
- v
70
- app.py: refresh_btn.click(refresh_click)
71
- |
72
- v
73
- SeminarsWebApp._stream_refresh(force=True)
74
- |
75
- v
76
- src/fetcher.py: refresh_all_stream(... force=True)
77
- |
78
- v
79
- Always crawls + LLM, then writes cache
80
- ```
81
-
82
- ---
83
-
84
- ## 3) Cache flow (explicit in the app)
85
-
86
- The app explicitly checks cache before crawling.
87
-
88
- ```
89
- SeminarsWebApp.stream_refresh(force=False)
90
- |
91
- v
92
- cache = CacheStore(config=..., ttl_hours=...)
93
- |
94
- +--> if cache.is_usable(): cache.load() -> UI updates
95
- |
96
- \--> else: crawl + LLM -> cache.write(results)
97
- ```
98
-
99
- ---
100
-
101
- ## 4) Detailed fetcher flow (one org)
102
-
103
- The fetcher is designed around a *stream* of events:
104
-
105
- - log event: `("log", level, message)`
106
- - result event: `("result", EventResult)`
107
-
108
- ### 4.1 One-org pipeline
109
-
110
- ```
111
- SeminarFetcher.fetch_next_event_for_org_stream(org)
112
- |
113
- v
114
- for hop in 1..max_hops:
115
- |
116
- +--> fetch_html(url)
117
- | - httpx GET
118
- | - if 403: optional curl fallback
119
- |
120
- +--> llm_extract(...)
121
- | - text_and_links(html)
122
- | - LiteLLM completion(...)
123
- | - safe_json() + normalize_llm_payload()
124
- | - Pydantic validation -> LlmHopResult
125
- |
126
- +--> validate_events(hop, now)
127
- | - parse_dt(start_time)
128
- | - filter to future events only
129
- | - ensure evidence + http(s) URL
130
- |
131
- +--> yield ("result", EventResult)
132
- |
133
- \--> (optional) follow hop.next_url_to_check if provided
134
- ```
135
-
136
- ### 4.2 Key idea: strictness retry
137
-
138
- If the LLM returns something that is not valid JSON or doesn’t validate, the code retries once in “strict” mode.
139
-
140
- ```
141
- llm_extract(strict=False)
142
- |
143
- +--> (fails JSON / schema) => retry
144
- v
145
- llm_extract(strict=True)
146
- ```
147
-
148
- ---
149
-
150
- ## 5) Data model / JSON shapes
151
-
152
- ### 5.1 Source input (`sources.json`)
153
-
154
- ```
155
- [
156
- {
157
- "id": "utoronto",
158
- "name": "UofT Robotics",
159
- "url": "https://...",
160
- "tags": ["canada", "university"]
161
- },
162
- ...
163
- ]
164
- ```
165
-
166
- Validated into `OrgSource`.
167
-
168
- ### 5.2 Per-org output (`EventResult`)
169
-
170
- ```
171
- EventResult:
172
- org_id, org_name, source_url
173
- status: "ok" | "no_upcoming" | "error"
174
- events: [LlmEvent, ...]
175
- checked_at
176
- hops
177
- visited_urls
178
- error (optional)
179
- ```
180
-
181
- ### 5.3 Cache file (`./data/events.json`)
182
-
183
- ```
184
- {
185
- "meta": {
186
- "model": "...",
187
- "schema_version": 3,
188
- "cached_at": "...",
189
- "ttl_hours": 12
190
- },
191
- "results": [ EventResult, ... ]
192
- }
193
- ```
194
-
195
- Cache is considered usable when:
196
- - file exists
197
- - file age < ttl
198
- - `meta.schema_version == 3`
199
- - `meta.model == current LLM model`
200
-
201
- ---
202
-
203
- ## 6) Where logs come from
204
-
205
- Logs are generated in two layers:
206
-
207
- 1) Fetcher (per hop / per org)
208
-
209
- ```
210
- "{org}: hop i/j — HTTP GET start: ..."
211
- "{org}: hop i/j — HTTP GET done (...)"
212
- "{org}: hop i/j — LLM call start (model=...)"
213
- "{org}: hop i/j — LLM call done (...)"
214
- "{org}: hop i/j — validating extracted event(s)"
215
- "{org}: success (...)" OR "no upcoming events" OR "error (...)"
216
- ```
217
-
218
- 2) App wrapper (per org result summary)
219
-
220
- ```
221
- "{org}: ok (k event(s))"
222
- "{org}: no upcoming events found"
223
- "{org}: <error message>"
224
- ```
225
-
226
- ---
227
-
228
- ## 7) Environment variables (practical cheat-sheet)
229
-
230
- ### App / paths
231
-
232
- - `SOURCES_PATH` (default `sources.json`)
233
- - `DATA_DIR` (default `./data`)
234
- - `CACHE_TTL_HOURS` (default `12`)
235
- - `PORT` (default `7860`)
236
-
237
- ### `/refresh` auth
238
-
239
- - `REFRESH_TOKEN` (required to use `/refresh`)
240
-
241
- ### LLM (LiteLLM)
242
-
243
- - `LITELLM_MODEL` (or `GEMINI_MODEL` fallback)
244
- - `LITELLM_API_KEY` (or `GEMINI_API_KEY` fallback)
245
- - `LITELLM_API_BASE` (optional)
246
-
247
- Optional knobs:
248
- - `LLM_TEMPERATURE` (default `0`)
249
- - `LLM_SEED` (optional)
250
- - `LLM_MIN_INTERVAL_SECONDS` (optional throttling)
251
- - `NOW_ISO` (optional override of “current time” for deterministic runs)
252
-
253
- ---
254
-
255
- ## 8) Quick “read order” (if you’re new)
256
-
257
- 1) `app.py`:
258
- - `SeminarsWebApp._stream_refresh()` to see end-to-end UI flow
259
- - `build_fastapi()` for `/refresh`
260
-
261
- 2) `src/fetcher.py`:
262
- - `refresh_all_stream()` to see caching vs crawling
263
- - `SeminarFetcher.fetch_next_event_for_org_stream()` for the main pipeline
264
- - `llm_extract()` + `validate_events()` for correctness guarantees
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -9,58 +9,32 @@ app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- # Robotic seminars (HF Space)
13
-
14
- This Hugging Face Space aggregates the **next upcoming event** per organization using an LLM via **LiteLLM**. It fetches each page as HTML, extracts a compact text context + candidate links, and asks the model to return structured JSON (up to 3 hops).
15
 
 
16
  ## Files
17
 
18
- - [sources.json](sources.json): list of orgs + starting URLs
19
- - [app.py](app.py): Gradio UI
20
- - [src/fetcher.py](src/fetcher.py): LiteLLM hop loop, validation, caching
21
-
22
- ## Environment variables (HF Space “Secrets”)
23
-
24
- - `LITELLM_MODEL` (recommended): LiteLLM model string, e.g. `gemini/gemini-2.0-flash`, `openai/gpt-4o-mini`, `anthropic/claude-3-5-sonnet-20241022`
25
- - `LITELLM_FALLBACK_MODELS` (optional): comma-separated fallback models to try if the primary hits a rate limit
26
- - `LLM_MIN_INTERVAL_SECONDS` (optional): minimum delay between LLM calls (useful for very low RPM limits)
27
- - `LITELLM_API_KEY` (optional): explicit API key to pass to LiteLLM
28
- - `LITELLM_API_BASE` (optional): custom base URL (useful for proxies/self-hosted endpoints)
29
-
30
- Backwards-compatible (still accepted):
31
- - `GEMINI_API_KEY` (optional): used as a fallback for `LITELLM_API_KEY`
32
- - `GEMINI_MODEL` (optional): used as a fallback for `LITELLM_MODEL`
33
-
34
- Provider-specific env vars also work (recommended):
35
- - `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, etc.
36
-
37
- - `CACHE_TTL_HOURS` (optional): defaults to `12`
38
 
39
- ## Cache
40
 
41
- The app writes its cache to `./data/events.json`.
42
 
43
- - Locally: create `./data` and ensure it’s writable by your user.
44
-
45
- If you don't want to pay for persistent storage, you can sync the cache to a dataset repo instead:
46
-
47
- - `HF_CACHE_REPO`: dataset repo id like `username/robotic-seminars-cache`
48
- - `HUGGINGFACE_HUB_TOKEN` (or `HF_TOKEN`): token with write access to the dataset repo
49
-
50
- On startup and refresh, the app pulls the cache from the dataset first. If a refresh runs and writes a new cache, it pushes the updated file back to the dataset repo.
51
 
52
  ## Local run
53
 
54
  ```bash
55
- python -m venv .venv
56
- source .venv/bin/activate
57
  pip install -r requirements.txt
58
-
59
- # Create a .env from the template and set your keys/model:
60
- cp .env.example .env
61
-
62
- # Or export env vars manually if you prefer.
63
  python app.py
64
  ```
65
 
66
- Open http://localhost:7860
 
9
  pinned: false
10
  ---
11
 
12
+ # Robotic seminars
 
 
13
 
14
+ This app reads `assets/events.json` and shows the upcoming seminar list.
15
  ## Files
16
 
17
+ - `app.py`: Gradio UI
18
+ - `assets/events.json`: event data read by the app and updated when an attending count is clicked
19
+ - `assets/sources.json`: source metadata used for tags
20
+ - `assets/prompt.txt`: prompt for your manual event-generation workflow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ ## HF dataset sync
23
 
24
+ The app pulls the latest remote events file before rendering and pushes updates back after an attending click.
25
 
26
+ - Dataset repo is fixed to `ar0s/robotic-seminars-cache`
27
+ - `HF_TOKEN` or `HUGGINGFACE_HUB_TOKEN`: token with dataset write access
28
+ - Remote file name is fixed to `events.json`
29
+ - Manual sync commands:
30
+ `python -m src.hf_sync pull`
31
+ `python -m src.hf_sync push`
 
 
32
 
33
  ## Local run
34
 
35
  ```bash
 
 
36
  pip install -r requirements.txt
 
 
 
 
 
37
  python app.py
38
  ```
39
 
40
+ Open `http://localhost:7860`.
app.py CHANGED
@@ -1,173 +1,72 @@
1
  from __future__ import annotations
2
 
3
- import json
4
  import os
5
- import threading
6
- import time
7
- import traceback
8
- from datetime import datetime, timezone
9
  from pathlib import Path
10
 
11
  import gradio as gr
12
  from dotenv import load_dotenv
13
- from fastapi import FastAPI
14
 
15
- from src.cache_store import CacheStore
16
- from src.fetcher import SeminarFetcher
17
  from src.attending import COLS, inc_attending, results_table
18
- from src.models import EventResult, LlmConfig, OrgSource, parse_dt_utc
 
19
  from src.org_colors import org_colors
20
- from src.ui_log import clear, error, info, lines
21
-
22
- # import debugpy
23
- # print("Waiting for debugger attach...")
24
- # debugpy.listen(5678)
25
- # debugpy.wait_for_client()
26
- # print("Debugger attached.")
27
 
28
  load_dotenv(Path(__file__).with_name(".env"))
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- def ts() -> str:
32
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
33
-
34
- def env_strip(name: str, default: str = "") -> str:
35
- return (os.environ.get(name, default) or "").strip()
36
-
37
- SOURCES_PATH = os.environ.get("SOURCES_PATH", "sources.json")
38
- TTL_HOURS = float(os.environ.get("CACHE_TTL_HOURS", "12"))
39
- LLM = LlmConfig(
40
- model=env_strip("LITELLM_MODEL", "gemini/gemini-2.0-flash"),
41
- api_key=env_strip("LITELLM_API_KEY") or None,
42
- api_base=env_strip("LITELLM_API_BASE") or None,
43
- )
44
- HF_CACHE_REPO = env_strip("HF_CACHE_REPO")
45
- HF_TOKEN = env_strip("HF_TOKEN") or None
46
- REFRESH_INTERVAL_HOURS = float(os.environ.get("REFRESH_INTERVAL_HOURS", "48"))
47
-
48
- SOURCES_RAW = json.loads(Path(SOURCES_PATH).read_text(encoding="utf-8"))
49
- COLORS = org_colors(SOURCES_RAW)
50
-
51
- SOURCES_MD = "\n".join(
52
- f"- [{s['name']}]({s['url']})"
53
- for s in SOURCES_RAW
54
- )
55
-
56
-
57
- def stream_refresh(force: bool):
58
- logs = lines()
59
- clear()
60
- results: list[dict] = []
61
- colors = COLORS
62
-
63
- def emit(status: str):
64
- df, row_map = results_table(results, colors)
65
- return status, df, "<br>\n".join(logs), results, row_map
66
-
67
- try:
68
- info(f"refresh(force={force}, ttl_hours={TTL_HOURS})")
69
- info(f"model={LLM.model}")
70
- yield emit("Starting refresh…")
71
-
72
- done = 0
73
- sources = [OrgSource.model_validate(s) for s in SOURCES_RAW]
74
-
75
- started = ts()
76
- cache = CacheStore(config=LLM, ttl_hours=TTL_HOURS, repo_id=HF_CACHE_REPO, hf_token=HF_TOKEN)
77
- cache.pull_from_hf()
78
- if not force and cache.is_usable():
79
- results.extend(json.loads(cache.path.read_text(encoding="utf-8"))["results"])
80
- info(f"used cache: {cache.path}")
81
- yield emit(f"Refreshed: {ts()} (started {started})")
82
- return
83
- info("cache miss, crawling")
84
-
85
- old_results = json.loads(cache.path.read_text(encoding="utf-8"))["results"] if cache.path.exists() else []
86
- prev_by_org = {r["org_id"]: r["events"] for r in old_results}
87
-
88
- old_by_exact: dict[tuple[str, str, str], int] = {}
89
- old_by_dt: dict[tuple[str, str], int] = {}
90
- old_by_dt_n: dict[tuple[str, str], int] = {}
91
- for r in old_results:
92
- for ev in r["events"]:
93
- dt = parse_dt_utc(ev["start_time"]).isoformat(timespec="minutes")
94
- url = ev["event_url"] or ""
95
- old_by_exact[(r["org_id"], dt, url)] = ev["attending_count"]
96
- k2 = (r["org_id"], dt)
97
- try:
98
- old_by_dt_n[k2] += 1
99
- except KeyError:
100
- old_by_dt_n[k2] = 1
101
- if k2 not in old_by_dt:
102
- old_by_dt[k2] = ev["attending_count"]
103
-
104
- fetcher = SeminarFetcher(config=LLM, now=None, max_hops=3, max_events=3, previous_events_by_org=prev_by_org)
105
- event_results = []
106
- for org in sources:
107
- try:
108
- done += 1
109
- r = fetcher.fetch_next_event_for_org(org)
110
-
111
- for ev in r.events:
112
- dt = parse_dt_utc(ev.start_time).isoformat(timespec="minutes")
113
- url = ev.event_url or ""
114
- k1 = (r.org_id, dt, url)
115
- if k1 in old_by_exact:
116
- ev.attending_count = old_by_exact[k1]
117
- continue
118
- k2 = (r.org_id, dt)
119
- if k2 in old_by_dt and old_by_dt_n[k2] == 1:
120
- ev.attending_count = old_by_dt[k2]
121
- event_results.append(r)
122
- results.append(r.model_dump())
123
- yield emit(f"Refreshed {done}/{len(sources)} (started {started})")
124
- except Exception:
125
- error(f"Inspecting {org.name}:\n{traceback.format_exc()}")
126
- continue
127
-
128
- cache.write(results=event_results)
129
- info(f"wrote cache: {cache.path}")
130
- yield emit(f"Refreshed: {ts()} (started {started})")
131
- except Exception:
132
- error(f"Unhandled exception:\n{traceback.format_exc()}")
133
- yield emit("Refresh failed.")
134
- return
135
 
136
 
137
  with gr.Blocks(
138
  css=".gradio-container table thead th,.gradio-container table thead th *{white-space:nowrap;word-break:keep-all;}"
139
  ) as demo:
140
- gr.Markdown("# Robotic seminars\nNext upcoming event per org.")
141
- status = gr.Markdown("")
142
  table = gr.Dataframe(
143
  headers=COLS,
144
- datatype=["str", "markdown", "str", "markdown", "str"],
145
- column_widths=[140, 360, 200, 200, 120],
146
  interactive=True,
147
  wrap=True,
148
  )
149
  results_state = gr.State([])
150
  row_map_state = gr.State([])
151
- refresh_btn = gr.Button("Refresh now")
152
  with gr.Accordion("Sources", open=False):
153
- gr.Markdown(SOURCES_MD)
154
- with gr.Accordion("Logs", open=False):
155
- logs_box = gr.Markdown()
156
-
157
- def on_select(results: list[dict], row_map: list[tuple[int, int]], evt: gr.SelectData):
158
- return inc_attending(
159
- evt,
160
- results,
161
- row_map,
162
- colors=COLORS,
163
- llm=LLM,
164
- ttl_hours=TTL_HOURS,
165
- cache_repo_id=HF_CACHE_REPO,
166
- cache_hf_token=HF_TOKEN,
167
- )
168
-
169
- demo.load(stream_refresh, inputs=[gr.State(False)], outputs=[status, table, logs_box, results_state, row_map_state])
170
- refresh_btn.click(stream_refresh, inputs=[gr.State(True)], outputs=[status, table, logs_box, results_state, row_map_state])
171
  table.select(
172
  on_select,
173
  inputs=[results_state, row_map_state],
@@ -176,16 +75,4 @@ with gr.Blocks(
176
 
177
 
178
  if __name__ == "__main__":
179
- # import uvicorn
180
- # HF Spaces runs Gradio apps itself; avoid mounting + running Uvicorn here.
181
- # app = gr.mount_gradio_app(FastAPI(), demo, path="/")
182
- # uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))
183
-
184
- def _periodic_refresh():
185
- while True:
186
- for _ in stream_refresh(True):
187
- pass
188
- time.sleep(REFRESH_INTERVAL_HOURS * 3600)
189
-
190
- threading.Thread(target=_periodic_refresh, daemon=True).start()
191
  demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))
 
1
  from __future__ import annotations
2
 
 
3
  import os
 
 
 
 
4
  from pathlib import Path
5
 
6
  import gradio as gr
7
  from dotenv import load_dotenv
 
8
 
 
 
9
  from src.attending import COLS, inc_attending, results_table
10
+ from src.hf_sync import hf_enabled, pull_events
11
+ from src.models import infer_category, load_payload
12
  from src.org_colors import org_colors
 
 
 
 
 
 
 
13
 
14
  load_dotenv(Path(__file__).with_name(".env"))
15
 
16
+ ASSETS_DIR = Path(os.environ.get("ASSETS_DIR", "assets"))
17
+ EVENTS_PATH = Path(os.environ.get("EVENTS_PATH", ASSETS_DIR / "events.json"))
18
+
19
+
20
+ def prepare_results(results: list[dict]) -> list[dict]:
21
+ for result in results:
22
+ for event in result["events"]:
23
+ event.setdefault(
24
+ "category",
25
+ infer_category(
26
+ title=event["title"],
27
+ org_name=result["org_name"],
28
+ event_url=event["event_url"] or "",
29
+ ),
30
+ )
31
+ event.setdefault("tags", [])
32
+ return results
33
+
34
 
35
+ def render():
36
+ if hf_enabled():
37
+ pull_events(EVENTS_PATH)
38
+ payload = load_payload(EVENTS_PATH)
39
+ results = prepare_results(payload["results"])
40
+ colors = org_colors(result["org_name"] for result in results)
41
+ table, row_map = results_table(results, colors)
42
+ sources = "\n".join(f"- [{result['org_name']}]({result['source_url']})" for result in results)
43
+ status = f"Loaded {len(results)} organizations from `{EVENTS_PATH.name}`.\n\nUpdated: {payload['meta']['cached_at']}"
44
+ return status, table, results, row_map, sources
45
+
46
+
47
+ def on_select(results: list[dict], row_map: list[tuple[int, int]], evt: gr.SelectData):
48
+ colors = org_colors(result["org_name"] for result in results)
49
+ return inc_attending(evt, results, row_map, colors=colors, events_path=EVENTS_PATH)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  with gr.Blocks(
53
  css=".gradio-container table thead th,.gradio-container table thead th *{white-space:nowrap;word-break:keep-all;}"
54
  ) as demo:
55
+ gr.Markdown("# Robotic seminars\nUpcoming events loaded directly from `assets/events.json`.")
56
+ status = gr.Markdown()
57
  table = gr.Dataframe(
58
  headers=COLS,
59
+ datatype=["str", "str", "markdown", "str", "str", "str", "markdown", "str"],
60
+ column_widths=[140, 140, 360, 200, 140, 160, 200, 120],
61
  interactive=True,
62
  wrap=True,
63
  )
64
  results_state = gr.State([])
65
  row_map_state = gr.State([])
 
66
  with gr.Accordion("Sources", open=False):
67
+ sources_box = gr.Markdown()
68
+
69
+ demo.load(render, outputs=[status, table, results_state, row_map_state, sources_box])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  table.select(
71
  on_select,
72
  inputs=[results_state, row_map_state],
 
75
 
76
 
77
  if __name__ == "__main__":
 
 
 
 
 
 
 
 
 
 
 
 
78
  demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))
assets/events.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "meta": {
3
+ "schema_version": 5,
4
+ "cached_at": "2026-04-12T22:28:10+00:00",
5
+ "ttl_hours": 12.0
6
+ },
7
+ "results": [
8
+ ]
9
+ }
assets/prompt.txt ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Inspect the current working tree before editing anything. Treat the current repo files as the source of truth for behavior and schema, especially `assets/sources.json`, `assets/events.json`, `app.py`, `src/models.py`, `src/attending.py`, `src/hf_sync.py`, and `tests/test_golden.py`. If `README.md` or any older docs disagree with the code, trust the code.
2
+
3
+ Your task is to crawl the live event sources and update only the events payload in `assets/events.json` in this repo. Do not edit docs, tests, source code, `.data`, or any other file unless I explicitly ask.
4
+
5
+ Before you crawl, use `src/hf_sync.py` to fetch the latest remote events payload by calling `pull_events`, so your work starts from the current dataset-backed `assets/events.json`. After you finish creating the new `assets/events.json`, push it back through `push_events`.
6
+
7
+ Execution requirements:
8
+ 1. Inspect the current contents of `assets/sources.json` and `assets/events.json` first.
9
+ 2. Call `src/hf_sync.py` `pull_events` before editing `assets/events.json`.
10
+ 3. For each source in `assets/sources.json`, browse the live `source_url` and follow up to 3 same-domain hops when needed to find upcoming events.
11
+ 4. Record the real `visited_urls` and actual `hops` used for each org.
12
+ 5. Build exactly one result entry per source id in `assets/sources.json`. Do not silently omit any org.
13
+ 6. If an org cannot be resolved cleanly, still include it with `status: "error"` and a short `error` message instead of skipping it.
14
+ 7. After writing the new `assets/events.json`, call `src/hf_sync.py` `push_events`.
15
+
16
+ `assets/events.json` contract:
17
+
18
+ - Write only `assets/events.json`.
19
+ - Preserve the existing `meta.ttl_hours`.
20
+ - Set `meta.schema_version` to `5`.
21
+ - Refresh `meta.cached_at` to the current UTC ISO-8601 timestamp.
22
+ - `results` must contain exactly one object per source with:
23
+ - `org_id`
24
+ - `org_name`
25
+ - `source_url`
26
+ - `status`
27
+ - `events`
28
+ - `checked_at`
29
+ - `hops`
30
+ - `visited_urls`
31
+ - `error`
32
+ - `status` must be one of `ok`, `no_upcoming`, or `error`.
33
+ - For `no_upcoming`, use `events: []` and `error: null`.
34
+ - Put `tags` on each event object, not on the organization-level result object.
35
+ - Infer `tags` from each event's actual content.
36
+ - Do not just copy the source-level tags from `assets/sources.json`.
37
+ - Use 2 to 6 concise lowercase tags when the content supports it.
38
+ - If the tags cannot be inferred with reasonable confidence, it is acceptable to leave an event's `tags` empty.
39
+ - Each event object must include:
40
+ - `title`
41
+ - `start_time`
42
+ - `event_url`
43
+ - `speaker`
44
+ - `affiliation`
45
+ - `category`
46
+ - `tags`
47
+ - `attending_count`
48
+
49
+ Extraction rules:
50
+
51
+ - Extract only events strictly after the actual current time at execution.
52
+ - Keep all future events, sorted by `start_time` ascending.
53
+ - If only a date is known, use `YYYY-MM-DD`.
54
+ - If the page gives a timezone offset, preserve it.
55
+ - If the page gives a local time without an offset, normalize it using that org's `timezone` from `assets/sources.json`.
56
+ - Never invent timezone offsets.
57
+ - Never invent missing dates, titles, speakers, affiliations, categories, tags, or event URLs.
58
+ - Avoid using series names, page headings, site headings, navigation labels, or organization names as event titles.
59
+ - Prefer the most specific event page as `event_url`; fall back to the source URL only when there is no dedicated event URL.
60
+ - Fill `category` with the best specific event type supported by the source, for example `PhD defense`, `PhD proposal`, `Seminar`, `Talk`, `Lecture`, or `Podcast`.
61
+ - Infer tags primarily from the abstract or description of each event when available, and use the title only as a secondary signal.
62
+ - Broad umbrella tags are allowed when appropriate, and one or two more specific tags may be added when they are clearly supported by the event content.
63
+
64
+ Attending-count preservation rules:
65
+
66
+ - Preserve existing `attending_count` values from the current `assets/events.json` wherever possible.
67
+ - Match first on `(org_id, normalized start_time to minute in UTC, event_url)`.
68
+ - If there is no exact match, reuse the count only when there is exactly one prior event for the same `(org_id, normalized start_time to minute in UTC)`.
69
+ - Otherwise leave `attending_count` at `0`.
70
+
71
+ Validation requirements before finishing:
72
+
73
+ 1. Confirm every `assets/sources.json` id appears exactly once in `assets/events.json.results`.
74
+ 2. Confirm every `start_time` is ISO-parseable and sorts correctly in UTC after normalization.
75
+ 3. Confirm preserved `attending_count` values remain attached to matched events.
76
+ 4. Confirm every event has `category` and `tags`.
77
+ 5. Run `conda run -n robotic-seminars pytest -q`.
78
+ 6. Manually spot-check at least 3 orgs:
79
+ - one with explicit timezone offsets in the source page
80
+ - one with local times that need timezone normalization
81
+ - one with date-only entries
82
+
83
+ Working style:
84
+
85
+ - Use the web tool to browse live pages and the repo files together.
86
+ - Do the work end-to-end without stopping for confirmation unless you hit a real blocker that prevents writing a complete `assets/events.json`.
87
+ - In your final response, summarize what changed in `assets/events.json`, note any orgs that ended up with `status: "error"` or `status: "no_upcoming"`, and include the validation results plus whether `pull_events` and `push_events` succeeded.
sources.json → assets/sources.json RENAMED
@@ -3,59 +3,69 @@
3
  "id": "cmu-ri-seminar",
4
  "name": "Carnegie Mellon University Robotics Institute Seminar Series",
5
  "url": "https://www.ri.cmu.edu/events/",
 
6
  "tags": ["robotics"]
7
  },
8
  {
9
  "id": "rig",
10
  "name": "Robotics Institute Germany (RIG) Lecture Series",
11
  "url": "https://robotics-institute-germany.de/rig-lecture-series-weekly-online-lectures-on-robotics/",
 
12
  "tags": ["robotics"]
13
  },
14
  {
15
  "id": "stanford-engr319",
16
  "name": "Stanford Robotics & Autonomous Systems Seminar (ENGR319)",
17
  "url": "https://stanfordasl.github.io/robotics_seminar/",
 
18
  "tags": ["robotics"]
19
  },
20
  {
21
  "id": "utoronto-ri",
22
  "name": "University of Toronto Robotics Institute Seminar Series",
23
  "url": "https://robotics.utoronto.ca/seminar-series/",
 
24
  "tags": ["robotics"]
25
  },
26
  {
27
  "id": "eth-rvc-talks",
28
  "name": "ETH Zürich Robotics, Vision, and Controls Talks",
29
  "url": "https://robotics-talks.com/",
 
30
  "tags": ["robotics"]
31
  },
32
  {
33
  "id": "umd-mrc-seminars",
34
  "name": "Maryland Robotics Center (UMD) Robotics Seminar Series",
35
  "url": "https://robotics.umd.edu/events/mrc-seminars",
 
36
  "tags": ["robotics"]
37
  },
38
  {
39
  "id": "imperial-rl-seminar",
40
  "name": "Imperial College London Robot Learning Seminar Series",
41
  "url": "https://www.robot-learning.uk/seminar-series",
 
42
  "tags": ["robotics"]
43
  },
44
  {
45
  "id": "gatech-irim-seminar",
46
  "name": "Georgia Tech IRIM Seminar Series",
47
  "url": "https://research.gatech.edu/robotics/irim-seminar-series",
 
48
  "tags": ["robotics"]
49
  },
50
  { "id": "robot-talk",
51
  "name": "Robot Talk",
52
  "url": "https://www.robottalk.org/latest-episodes/",
 
53
  "tags": ["robotics"]
54
  },
55
  {
56
  "id": "montreal-robotics",
57
  "name": "Montréal Robotics / Mila Robot Learning Seminar",
58
  "url": "https://montrealrobotics.ca/robotlearningseries/",
 
59
  "tags": ["robotics"]
60
  }
61
- ]
 
3
  "id": "cmu-ri-seminar",
4
  "name": "Carnegie Mellon University Robotics Institute Seminar Series",
5
  "url": "https://www.ri.cmu.edu/events/",
6
+ "timezone": "America/New_York",
7
  "tags": ["robotics"]
8
  },
9
  {
10
  "id": "rig",
11
  "name": "Robotics Institute Germany (RIG) Lecture Series",
12
  "url": "https://robotics-institute-germany.de/rig-lecture-series-weekly-online-lectures-on-robotics/",
13
+ "timezone": "Europe/Berlin",
14
  "tags": ["robotics"]
15
  },
16
  {
17
  "id": "stanford-engr319",
18
  "name": "Stanford Robotics & Autonomous Systems Seminar (ENGR319)",
19
  "url": "https://stanfordasl.github.io/robotics_seminar/",
20
+ "timezone": "America/Los_Angeles",
21
  "tags": ["robotics"]
22
  },
23
  {
24
  "id": "utoronto-ri",
25
  "name": "University of Toronto Robotics Institute Seminar Series",
26
  "url": "https://robotics.utoronto.ca/seminar-series/",
27
+ "timezone": "America/Toronto",
28
  "tags": ["robotics"]
29
  },
30
  {
31
  "id": "eth-rvc-talks",
32
  "name": "ETH Zürich Robotics, Vision, and Controls Talks",
33
  "url": "https://robotics-talks.com/",
34
+ "timezone": "Europe/Zurich",
35
  "tags": ["robotics"]
36
  },
37
  {
38
  "id": "umd-mrc-seminars",
39
  "name": "Maryland Robotics Center (UMD) Robotics Seminar Series",
40
  "url": "https://robotics.umd.edu/events/mrc-seminars",
41
+ "timezone": "America/New_York",
42
  "tags": ["robotics"]
43
  },
44
  {
45
  "id": "imperial-rl-seminar",
46
  "name": "Imperial College London Robot Learning Seminar Series",
47
  "url": "https://www.robot-learning.uk/seminar-series",
48
+ "timezone": "Europe/London",
49
  "tags": ["robotics"]
50
  },
51
  {
52
  "id": "gatech-irim-seminar",
53
  "name": "Georgia Tech IRIM Seminar Series",
54
  "url": "https://research.gatech.edu/robotics/irim-seminar-series",
55
+ "timezone": "America/New_York",
56
  "tags": ["robotics"]
57
  },
58
  { "id": "robot-talk",
59
  "name": "Robot Talk",
60
  "url": "https://www.robottalk.org/latest-episodes/",
61
+ "timezone": "UTC",
62
  "tags": ["robotics"]
63
  },
64
  {
65
  "id": "montreal-robotics",
66
  "name": "Montréal Robotics / Mila Robot Learning Seminar",
67
  "url": "https://montrealrobotics.ca/robotlearningseries/",
68
+ "timezone": "America/Toronto",
69
  "tags": ["robotics"]
70
  }
71
+ ]
events.json DELETED
@@ -1,270 +0,0 @@
1
- {
2
- "meta": {
3
- "schema_version": 4,
4
- "cached_at": "2026-01-14T11:51:02.611033+00:00",
5
- "ttl_hours": 12.0
6
- },
7
- "results": [
8
- {
9
- "org_id": "cmu-ri-seminar",
10
- "org_name": "Carnegie Mellon University Robotics Institute Seminar Series",
11
- "source_url": "https://www.ri.cmu.edu/events/",
12
- "status": "ok",
13
- "events": [
14
- {
15
- "title": "PhD Thesis Proposal",
16
- "start_time": "2026-01-15T08:30:00-05:00",
17
- "event_url": "https://www.ri.cmu.edu/events/",
18
- "speaker": "Hsu-kuang Chiu",
19
- "affiliation": "PhD Student, Robotics Institute, Carnegie Mellon University",
20
- "attending_count": 0
21
- },
22
- {
23
- "title": "Faculty Events",
24
- "start_time": "2026-01-16T12:00:00-05:00",
25
- "event_url": "https://www.ri.cmu.edu/events/",
26
- "speaker": "Jean Hyaejin Oh",
27
- "affiliation": "Associate Research Professor, Robotics Institute, Carnegie Mellon University",
28
- "attending_count": 0
29
- },
30
- {
31
- "title": "PhD Thesis Defense",
32
- "start_time": "2026-01-20T12:00:00-05:00",
33
- "event_url": "https://www.ri.cmu.edu/events/",
34
- "speaker": "Muhammad Suhail Saleem",
35
- "affiliation": "PhD Student, Robotics Institute, Carnegie Mellon University",
36
- "attending_count": 0
37
- }
38
- ],
39
- "checked_at": "2026-01-14T11:50:06.854624+00:00",
40
- "hops": 1,
41
- "visited_urls": [],
42
- "error": null
43
- },
44
- {
45
- "org_id": "rig",
46
- "org_name": "Robotics Institute Germany (RIG) Lecture Series",
47
- "source_url": "https://robotics-institute-germany.de/rig-lecture-series-weekly-online-lectures-on-robotics/",
48
- "status": "ok",
49
- "events": [
50
- {
51
- "title": "Distributed Petri Nets for Cobotics",
52
- "start_time": "2026-01-15T14:15+00:00",
53
- "event_url": "https://tu-berlin.zoom-x.de/j/63606623807?pwd=01u0brYFzcrZVRM60LOoAXW1GxAQXg.1",
54
- "speaker": "Uwe Aßmann, Sebastian Ebert",
55
- "affiliation": "TU Dresden",
56
- "attending_count": 0
57
- },
58
- {
59
- "title": "Robotics and Society: Lessons Learned from the Responsible Robotics Project",
60
- "start_time": "2026-01-22T14:15+00:00",
61
- "event_url": "https://tu-berlin.zoom-x.de/j/63606623807?pwd=01u0brYFzcrZVRM60LOoAXW1GxAQXg.1",
62
- "speaker": "Svenja Breuer, Ruth Müller",
63
- "affiliation": "TU München",
64
- "attending_count": 0
65
- },
66
- {
67
- "title": "Commercializing Robotic Innovations: Insights and Lessons from Three Startup Frontlines",
68
- "start_time": "2026-01-29T14:15+00:00",
69
- "event_url": "https://tu-berlin.zoom-x.de/j/63606623807?pwd=01u0brYFzcrZVRM60LOoAXW1GxAQXg.1",
70
- "speaker": "Oskar von Stryk",
71
- "affiliation": "TU Darmstadt",
72
- "attending_count": 0
73
- }
74
- ],
75
- "checked_at": "2026-01-14T11:50:25.769949+00:00",
76
- "hops": 1,
77
- "visited_urls": [],
78
- "error": null
79
- },
80
- {
81
- "org_id": "stanford-engr319",
82
- "org_name": "Stanford Robotics & Autonomous Systems Seminar (ENGR319)",
83
- "source_url": "https://stanfordasl.github.io/robotics_seminar/",
84
- "status": "ok",
85
- "events": [
86
- {
87
- "title": "Resilient Autonomy for Extreme and Uncertain Environments",
88
- "start_time": "2026-01-16T15:00:00",
89
- "event_url": "https://stanfordasl.github.io/robotics_seminar/",
90
- "speaker": "Sebastian Scherer",
91
- "affiliation": "CMU",
92
- "attending_count": 0
93
- },
94
- {
95
- "title": "TBD",
96
- "start_time": "2026-01-30T15:00:00",
97
- "event_url": "https://stanfordasl.github.io/robotics_seminar/",
98
- "speaker": "Madhur Behl",
99
- "affiliation": "UVirginia",
100
- "attending_count": 0
101
- },
102
- {
103
- "title": "TBD",
104
- "start_time": "2026-02-06T15:00:00",
105
- "event_url": "https://stanfordasl.github.io/robotics_seminar/",
106
- "speaker": "Koushil Sreenath",
107
- "affiliation": "UC Berkeley",
108
- "attending_count": 0
109
- }
110
- ],
111
- "checked_at": "2026-01-14T11:50:32.288175+00:00",
112
- "hops": 1,
113
- "visited_urls": [],
114
- "error": null
115
- },
116
- {
117
- "org_id": "utoronto-ri",
118
- "org_name": "University of Toronto Robotics Institute Seminar Series",
119
- "source_url": "https://robotics.utoronto.ca/seminar-series/",
120
- "status": "ok",
121
- "events": [
122
- {
123
- "title": "Robotics Institute Seminar: Mahdi Tavakoli (University of Alberta)",
124
- "start_time": "2026-01-23T15:00:00-05:00",
125
- "event_url": "https://robotics.utoronto.ca/event/robotics-institute-seminar-series-mahdi-tavakoli-university-of-alberta/",
126
- "speaker": "Mahdi Tavakoli",
127
- "affiliation": "University of Alberta",
128
- "attending_count": 0
129
- },
130
- {
131
- "title": "Robotics Institute Seminar: Sergey Stavisky (UC Davis)",
132
- "start_time": "2026-03-20T15:00:00-05:00",
133
- "event_url": "https://robotics.utoronto.ca/event/robotics-institute-seminar-series-sergey-stavisky-uc-davis/",
134
- "speaker": "Sergey Stavisky",
135
- "affiliation": "UC Davis",
136
- "attending_count": 0
137
- }
138
- ],
139
- "checked_at": "2026-01-14T11:50:37.362828+00:00",
140
- "hops": 1,
141
- "visited_urls": [],
142
- "error": null
143
- },
144
- {
145
- "org_id": "eth-rvc-talks",
146
- "org_name": "ETH Zürich Robotics, Vision, and Controls Talks",
147
- "source_url": "https://robotics-talks.com/",
148
- "status": "ok",
149
- "events": [
150
- {
151
- "title": "TBD",
152
- "start_time": "2026-02-20T16:00:00+01:00",
153
- "event_url": "https://robotics-talks.com/",
154
- "speaker": "TBD",
155
- "affiliation": null,
156
- "attending_count": 0
157
- },
158
- {
159
- "title": "TBD",
160
- "start_time": "2026-03-06T16:00:00+01:00",
161
- "event_url": "https://robotics-talks.com/",
162
- "speaker": "TBD",
163
- "affiliation": null,
164
- "attending_count": 0
165
- },
166
- {
167
- "title": "TBD",
168
- "start_time": "2026-03-20T16:00:00+01:00",
169
- "event_url": "https://robotics-talks.com/",
170
- "speaker": "TBD",
171
- "affiliation": null,
172
- "attending_count": 0
173
- }
174
- ],
175
- "checked_at": "2026-01-14T11:50:41.039165+00:00",
176
- "hops": 1,
177
- "visited_urls": [],
178
- "error": null
179
- },
180
- {
181
- "org_id": "umd-mrc-seminars",
182
- "org_name": "Maryland Robotics Center (UMD) Robotics Seminar Series",
183
- "source_url": "https://robotics.umd.edu/events/mrc-seminars",
184
- "status": "ok",
185
- "events": [
186
- {
187
- "title": "Robot Motion Learning with Physics-Based PDE Priors",
188
- "start_time": "2026-02-02",
189
- "event_url": "https://robotics.umd.edu/events/mrc-seminars#main-content",
190
- "speaker": "Dr. Ahmed Qureshi",
191
- "affiliation": "Purdue University",
192
- "attending_count": 0
193
- }
194
- ],
195
- "checked_at": "2026-01-14T11:50:44.115684+00:00",
196
- "hops": 1,
197
- "visited_urls": [],
198
- "error": null
199
- },
200
- {
201
- "org_id": "imperial-rl-seminar",
202
- "org_name": "Imperial College London Robot Learning Seminar Series",
203
- "source_url": "https://www.robot-learning.uk/seminar-series",
204
- "status": "no_upcoming",
205
- "events": [],
206
- "checked_at": "2026-01-14T11:50:47.251095+00:00",
207
- "hops": 1,
208
- "visited_urls": [],
209
- "error": null
210
- },
211
- {
212
- "org_id": "gatech-irim-seminar",
213
- "org_name": "Georgia Tech IRIM Seminar Series",
214
- "source_url": "https://research.gatech.edu/robotics/irim-seminar-series",
215
- "status": "ok",
216
- "events": [
217
- {
218
- "title": "Beyond Scaling: Exploration, Guidance, and Symmetry in Robot Perception",
219
- "start_time": "2026-01-14T12:15:00+00:00",
220
- "event_url": "https://research.gatech.edu/robotics/irim-seminar-series",
221
- "speaker": "Kostas Daniilidis",
222
- "affiliation": "UPenn",
223
- "attending_count": 0
224
- },
225
- {
226
- "title": "TBA",
227
- "start_time": "2026-01-28T12:15:00+00:00",
228
- "event_url": "https://research.gatech.edu/robotics/irim-seminar-series",
229
- "speaker": "Alexander Leonessa",
230
- "affiliation": "Clemson",
231
- "attending_count": 0
232
- },
233
- {
234
- "title": "TBA",
235
- "start_time": "2026-02-11T12:15:00+00:00",
236
- "event_url": "https://research.gatech.edu/robotics/irim-seminar-series",
237
- "speaker": "Robert Stuart-Smith",
238
- "affiliation": "UPenn",
239
- "attending_count": 0
240
- }
241
- ],
242
- "checked_at": "2026-01-14T11:50:48.269849+00:00",
243
- "hops": 1,
244
- "visited_urls": [],
245
- "error": null
246
- },
247
- {
248
- "org_id": "robot-talk",
249
- "org_name": "Robot Talk",
250
- "source_url": "https://www.robottalk.org/latest-episodes/",
251
- "status": "no_upcoming",
252
- "events": [],
253
- "checked_at": "2026-01-14T11:50:53.174374+00:00",
254
- "hops": 1,
255
- "visited_urls": [],
256
- "error": null
257
- },
258
- {
259
- "org_id": "montreal-robotics",
260
- "org_name": "Montréal Robotics / Mila Robot Learning Seminar",
261
- "source_url": "https://montrealrobotics.ca/robotlearningseries/",
262
- "status": "no_upcoming",
263
- "events": [],
264
- "checked_at": "2026-01-14T11:50:58.296781+00:00",
265
- "hops": 1,
266
- "visited_urls": [],
267
- "error": null
268
- }
269
- ]
270
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,14 +1,5 @@
1
  gradio==5.12.0
2
- fastapi==0.115.6
3
- uvicorn==0.34.0
4
- pydantic==2.10.5
5
- python-dateutil==2.9.0.post0
6
  pandas==2.2.3
7
- httpx==0.28.1
8
- h2>=4.1.0
9
- litellm>=1.0.0
10
  python-dotenv>=1.0.0
11
- beautifulsoup4>=4.12.2
12
  pytest>=8.0.0
13
- curl_cffi>=0.14.0
14
- huggingface_hub>=0.22.0
 
1
  gradio==5.12.0
 
 
 
 
2
  pandas==2.2.3
3
+ huggingface_hub>=0.22.0
 
 
4
  python-dotenv>=1.0.0
 
5
  pytest>=8.0.0
 
 
src/attending.py CHANGED
@@ -1,84 +1,88 @@
1
  from __future__ import annotations
2
 
3
  import os
 
4
 
5
- import gradio as gr
6
  import pandas as pd
7
 
8
- from .cache_store import CacheStore
9
- from .models import EventResult, LlmConfig
10
  from .org_colors import org_tag
11
 
12
- COLS = ["Date/Time (UTC)", "Title", "Speaker", "Organization", "Attending"]
 
 
13
 
14
 
15
- # (Previously had helper functions here; removed to keep file minimal.)
 
 
 
 
 
 
16
 
17
 
18
  def results_table(results: list[dict], colors: dict[str, str]) -> tuple[pd.DataFrame, list[tuple[int, int]]]:
19
  rows: list[dict[str, object]] = []
20
- offset_h = int(os.environ.get("DISPLAY_UTC_OFFSET_HOURS", "0"))
21
- tz_label = f"UTC{offset_h:+d}" if offset_h else "UTC"
22
- for r_i, r in enumerate(results):
23
- if r["status"] != "ok":
24
  continue
25
- for ev_i, ev in enumerate(r["events"]):
26
- sp = ev["speaker"] or ""
27
- aff = ev["affiliation"] or ""
28
- speaker = f"{sp} ({aff})" if sp and aff else sp or aff
29
  rows.append(
30
  {
31
- "Date/Time (UTC)": ev["start_time"],
32
- "_iso": ev["start_time"],
33
- "Title": f"[{ev['title']}]({ev['event_url']})" if ev["event_url"] else ev["title"],
34
- "Speaker": speaker,
35
- "Organization": org_tag(r["org_name"], colors),
36
- "Attending": f"{ev['attending_count']} (+)",
37
- "_org": r["org_name"],
38
- "_r": r_i,
39
- "_e": ev_i,
 
 
 
40
  }
41
  )
 
42
  if not rows:
43
  return pd.DataFrame(columns=COLS), []
44
- df = pd.DataFrame(rows)
45
- df["_sort"] = pd.to_datetime(df["_iso"], utc=True, format="ISO8601", errors="raise")
46
 
47
- disp = df["_sort"] + pd.Timedelta(hours=offset_h)
48
- date_only = ~df["_iso"].astype(str).str.contains("T")
49
- df.loc[date_only, "Date/Time (UTC)"] = disp[date_only].dt.strftime("%b %d").str.replace(" 0", " ")
50
- df.loc[~date_only, "Date/Time (UTC)"] = disp[~date_only].dt.strftime("%b %d at %H:%M").str.replace(" 0", " ")
51
- df = df.sort_values(by=["_sort", "_org"], na_position="last").reset_index(drop=True)
52
- row_map = list(zip(df["_r"].astype(int).tolist(), df["_e"].astype(int).tolist()))
53
- return df.drop(columns=["_iso", "_sort", "_org", "_r", "_e"])[COLS], row_map
54
 
55
 
56
  def inc_attending(
57
- evt: gr.SelectData | None,
58
  results: list[dict],
59
  row_map: list[tuple[int, int]],
60
  *,
61
  colors: dict[str, str],
62
- llm: LlmConfig,
63
- ttl_hours: float,
64
- cache_repo_id: str,
65
- cache_hf_token: str | None,
66
  ):
67
  if evt is None or evt.index is None:
68
- df, row_map = results_table(results, colors)
69
- return df, results, row_map
70
- r = int(evt.index[0])
71
- c = int(evt.index[1])
72
- if c != COLS.index("Attending"):
73
- df, row_map = results_table(results, colors)
74
- return df, results, row_map
75
 
76
- r_i, ev_i = row_map[r]
77
- results[r_i]["events"][ev_i]["attending_count"] += 1
 
 
78
 
79
- cache = CacheStore(config=llm, ttl_hours=ttl_hours, repo_id=cache_repo_id, hf_token=cache_hf_token)
 
80
 
81
- cache.write(results=[EventResult.model_validate(x) for x in results])
 
 
 
 
82
 
83
- df, row_map = results_table(results, colors)
84
- return df, results, row_map
 
1
  from __future__ import annotations
2
 
3
  import os
4
+ from pathlib import Path
5
 
 
6
  import pandas as pd
7
 
8
+ from .hf_sync import hf_enabled, push_events
9
+ from .models import load_payload, parse_dt_utc, write_payload
10
  from .org_colors import org_tag
11
 
12
+ LOCAL_DISPLAY_TZ = os.environ.get("LOCAL_DISPLAY_TZ", "Europe/Rome")
13
+ LOCAL_TIME_COL = f"Local Time ({LOCAL_DISPLAY_TZ})"
14
+ COLS = ["Source Time", LOCAL_TIME_COL, "Title", "Speaker", "Category", "Tags", "Organization", "Attending"]
15
 
16
 
17
+ def format_event_time(value: str, *, tz_name: str | None = None) -> str:
18
+ if "T" not in value:
19
+ return pd.Timestamp(value).strftime("%b %d").replace(" 0", " ")
20
+ dt = pd.Timestamp(value)
21
+ if tz_name and dt.tz is not None:
22
+ dt = dt.tz_convert(tz_name)
23
+ return dt.strftime("%b %d at %H:%M").replace(" 0", " ")
24
 
25
 
26
  def results_table(results: list[dict], colors: dict[str, str]) -> tuple[pd.DataFrame, list[tuple[int, int]]]:
27
  rows: list[dict[str, object]] = []
28
+ for result_index, result in enumerate(results):
29
+ if result["status"] != "ok":
 
 
30
  continue
31
+ for event_index, event in enumerate(result["events"]):
32
+ speaker = event["speaker"] or ""
33
+ affiliation = event["affiliation"] or ""
34
+ event_tags = event.get("tags") or []
35
  rows.append(
36
  {
37
+ "Source Time": format_event_time(event["start_time"]),
38
+ LOCAL_TIME_COL: format_event_time(event["start_time"], tz_name=LOCAL_DISPLAY_TZ),
39
+ "Title": f"[{event['title']}]({event['event_url']})" if event["event_url"] else event["title"],
40
+ "Speaker": f"{speaker} ({affiliation})" if speaker and affiliation else speaker or affiliation,
41
+ "Category": event["category"],
42
+ "Tags": ", ".join(event_tags),
43
+ "Organization": org_tag(result["org_name"], colors),
44
+ "Attending": f"{event['attending_count']} (+)",
45
+ "_sort": parse_dt_utc(event["start_time"]),
46
+ "_org": result["org_name"],
47
+ "_result": result_index,
48
+ "_event": event_index,
49
  }
50
  )
51
+
52
  if not rows:
53
  return pd.DataFrame(columns=COLS), []
 
 
54
 
55
+ df = pd.DataFrame(rows).sort_values(by=["_sort", "_org"]).reset_index(drop=True)
56
+ row_map = list(zip(df["_result"].astype(int).tolist(), df["_event"].astype(int).tolist()))
57
+ return df.drop(columns=["_sort", "_org", "_result", "_event"])[COLS], row_map
 
 
 
 
58
 
59
 
60
  def inc_attending(
61
+ evt,
62
  results: list[dict],
63
  row_map: list[tuple[int, int]],
64
  *,
65
  colors: dict[str, str],
66
+ events_path: str | Path = Path(os.environ.get("EVENTS_PATH", Path(os.environ.get("ASSETS_DIR", "assets")) / "events.json")),
67
+ **_ignored,
 
 
68
  ):
69
  if evt is None or evt.index is None:
70
+ table, row_map = results_table(results, colors)
71
+ return table, results, row_map
 
 
 
 
 
72
 
73
+ row_index, column_index = map(int, evt.index)
74
+ if column_index != COLS.index("Attending"):
75
+ table, row_map = results_table(results, colors)
76
+ return table, results, row_map
77
 
78
+ result_index, event_index = row_map[row_index]
79
+ results[result_index]["events"][event_index]["attending_count"] += 1
80
 
81
+ payload = load_payload(events_path)
82
+ payload["results"] = results
83
+ write_payload(payload, events_path)
84
+ if hf_enabled():
85
+ push_events(events_path)
86
 
87
+ table, row_map = results_table(results, colors)
88
+ return table, results, row_map
src/cache_store.py DELETED
@@ -1,80 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import shutil
5
- from datetime import datetime, timezone
6
- from pathlib import Path
7
-
8
- from huggingface_hub import hf_hub_download, HfApi # type: ignore
9
-
10
- from .models import EventResult, LlmConfig
11
- from .ui_log import info
12
-
13
- HF_CACHE_PATH = "events.json"
14
- PATH = HF_CACHE_PATH
15
- CACHE_SCHEMA_VERSION = 4
16
-
17
-
18
- class CacheStore:
19
- def __init__(
20
- self,
21
- config: LlmConfig,
22
- ttl_hours: float,
23
- repo_id: str,
24
- hf_token: str | None = None,
25
- ):
26
- self.config = config
27
- self.ttl_hours = ttl_hours
28
- self.path = Path(PATH)
29
- self.hf_repo_id = repo_id
30
- self.hf_path = HF_CACHE_PATH
31
- self.hf_token = hf_token
32
-
33
- def pull_from_hf(self) -> None:
34
-
35
- info(f"Pulling cache from HF dataset: {self.hf_repo_id}/{self.hf_path}")
36
- downloaded = hf_hub_download(
37
- repo_id=self.hf_repo_id,
38
- filename=self.hf_path,
39
- repo_type="dataset",
40
- token=self.hf_token or None,
41
- )
42
- self.path.parent.mkdir(parents=True, exist_ok=True)
43
- shutil.copy2(downloaded, self.path)
44
-
45
- def _push_to_hf(self) -> None:
46
-
47
- info(f"Pushing cache to HF dataset: {self.hf_repo_id}/{self.hf_path}")
48
- api = HfApi(token=self.hf_token or None)
49
- api.upload_file(
50
- path_or_fileobj=str(self.path),
51
- path_in_repo=self.hf_path,
52
- repo_id=self.hf_repo_id,
53
- repo_type="dataset",
54
- commit_message="Update robotic_seminars cache",
55
- )
56
-
57
- def is_fresh(self) -> bool:
58
- if not self.path.exists():
59
- return False
60
- cached_at = datetime.fromtimestamp(self.path.stat().st_mtime, tz=timezone.utc)
61
- return (datetime.now(timezone.utc) - cached_at).total_seconds() < self.ttl_hours * 3600
62
-
63
- def is_usable(self) -> bool:
64
- if not self.is_fresh():
65
- return False
66
- meta = json.loads(self.path.read_text(encoding="utf-8"))["meta"]
67
- return meta["schema_version"] == CACHE_SCHEMA_VERSION
68
-
69
- def write(self, *, results: list[EventResult]) -> None:
70
- self.path.parent.mkdir(parents=True, exist_ok=True)
71
- payload = {
72
- "meta": {
73
- "schema_version": CACHE_SCHEMA_VERSION,
74
- "cached_at": datetime.now(timezone.utc).isoformat(),
75
- "ttl_hours": self.ttl_hours,
76
- },
77
- "results": [r.model_dump() for r in results],
78
- }
79
- self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
80
- self._push_to_hf()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/fetcher.py DELETED
@@ -1,115 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import time
5
- from datetime import datetime, timezone
6
- from typing import Iterator
7
-
8
- from urllib.parse import urlparse
9
- from curl_cffi import requests as r # type: ignore
10
-
11
- from dateutil import parser as dtparser
12
-
13
- from .models import (
14
- EventResult,
15
- LlmConfig,
16
- LlmEvent,
17
- OrgSource,
18
- USER_AGENT,
19
- llm_extract,
20
- parse_dt_utc,
21
- )
22
- from .ui_log import error, info, warn
23
-
24
-
25
- class SeminarFetcher:
26
- def __init__(
27
- self,
28
- config: LlmConfig,
29
- now: datetime | None = None,
30
- max_hops: int = 3,
31
- max_events: int = 3,
32
- previous_events_by_org: dict[str, list[dict]] | None = None,
33
- ):
34
- self.config = config
35
- self.max_hops = max_hops
36
- self.max_events = max_events
37
- self.previous_events_by_org = previous_events_by_org or {}
38
-
39
- if now is None:
40
- raw = (os.environ.get("NOW_ISO") or "").strip()
41
- now = dtparser.isoparse(raw) if raw else datetime.now(timezone.utc)
42
- if now.tzinfo is None:
43
- now = now.replace(tzinfo=timezone.utc)
44
- self.now = now.astimezone(timezone.utc)
45
-
46
- def fetch_html(self, url: str) -> str:
47
- p = urlparse(url)
48
- headers={"User-Agent": USER_AGENT, "Accept": "text/html", "Referer": f"{p.scheme}://{p.netloc}/"}
49
- for i in range(3):
50
- try:
51
- resp = r.get(url, timeout=20, allow_redirects=True, impersonate="chrome120", headers=headers)
52
- resp.raise_for_status()
53
- return resp.text
54
- except r.exceptions.RequestException as e:
55
- if i == 2:
56
- raise
57
- warn(f"HTTP fetch failed: {url}: {e} (retrying)")
58
- time.sleep(2 ** i)
59
-
60
- def fetch_next_event_for_org_stream(self, org: OrgSource) -> Iterator[EventResult]:
61
- checked_at = datetime.now(timezone.utc).isoformat()
62
- now_iso = self.now.isoformat()
63
- url = str(org.url)
64
- visited: list[str] = []
65
- info(f"Inspecting {org.name}…")
66
- result = EventResult(org_id=org.id, org_name=org.name, source_url=str(org.url), status="no_upcoming",
67
- events=[], checked_at=checked_at, hops=self.max_hops, visited_urls=visited, error="Max hops reached.",
68
- )
69
-
70
- for hop_i in range(1, self.max_hops + 1):
71
- visited.append(url)
72
- info(f"Inspecting {org.name}: fetching {url}")
73
-
74
- previous_events = self.previous_events_by_org[org.id] if org.id in self.previous_events_by_org else []
75
- page_html = self.fetch_html(url)
76
- hop = llm_extract(config=self.config, org=org, url=url, page_html=page_html, now_iso=now_iso, previous_events=previous_events)
77
-
78
- if hop.status != "ok":
79
- next_url = hop.next_url_to_check
80
- if next_url and next_url not in visited:
81
- info(f"Inspecting {org.name}: following {next_url}")
82
- url = next_url
83
- continue
84
- (error if hop.status == "error" else warn)(f"Inspecting {org.name}: {hop.status}: {hop.error}")
85
- result.status = hop.status
86
- result.hops = hop_i
87
- result.error = hop.error
88
- break
89
-
90
- events: list[LlmEvent] = []
91
- parsed = [(parse_dt_utc(e.start_time), e) for e in hop.events]
92
- parsed.sort(key=lambda x: x[0])
93
- for dt, e in parsed:
94
- if dt <= self.now:
95
- continue
96
- if e.event_url is None:
97
- e = e.model_copy(update={"event_url": url})
98
- events.append(e)
99
- if len(events) >= self.max_events:
100
- break
101
-
102
- result.status = "ok" if events else "no_upcoming"
103
- result.events = events
104
- result.hops = hop_i
105
- result.error = hop.error
106
- if events:
107
- info(f"Found {len(events)} upcoming event(s) for {org.name}")
108
- else:
109
- warn(f"No upcoming events found for {org.name}")
110
- break
111
-
112
- yield result
113
-
114
- def fetch_next_event_for_org(self, org: OrgSource) -> EventResult:
115
- return next(self.fetch_next_event_for_org_stream(org))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/hf_sync.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ ROOT_DIR = Path(__file__).resolve().parent.parent
11
+ ASSETS_EVENTS_PATH = ROOT_DIR / "assets" / "events.json"
12
+ HF_REPO_ID = "ar0s/robotic-seminars-cache"
13
+ HF_EVENTS_FILE = "events.json"
14
+
15
+ load_dotenv(ROOT_DIR / ".env")
16
+
17
+
18
+ def hf_repo_id() -> str:
19
+ return HF_REPO_ID
20
+
21
+
22
+ def hf_token() -> str | None:
23
+ return (os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or "").strip() or None
24
+
25
+ def hf_enabled() -> bool:
26
+ return bool(hf_token())
27
+
28
+
29
+ def pull_events(path: str | Path = ASSETS_EVENTS_PATH) -> None:
30
+ from huggingface_hub import hf_hub_download
31
+
32
+ downloaded = hf_hub_download(
33
+ repo_id=hf_repo_id(),
34
+ filename=HF_EVENTS_FILE,
35
+ repo_type="dataset",
36
+ token=hf_token(),
37
+ )
38
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
39
+ shutil.copy2(downloaded, path)
40
+
41
+
42
+ def push_events(path: str | Path = ASSETS_EVENTS_PATH) -> None:
43
+ from huggingface_hub import HfApi
44
+
45
+ HfApi(token=hf_token()).upload_file(
46
+ path_or_fileobj=str(path),
47
+ path_in_repo=HF_EVENTS_FILE,
48
+ repo_id=hf_repo_id(),
49
+ repo_type="dataset",
50
+ commit_message="Update robotic seminars events",
51
+ )
52
+
53
+
54
+ if __name__ == "__main__":
55
+ command = sys.argv[1] if len(sys.argv) > 1 else ""
56
+ if command == "pull":
57
+ pull_events()
58
+ if command == "push":
59
+ push_events()
src/models.py CHANGED
@@ -2,214 +2,39 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
5
- from dataclasses import dataclass
6
  from datetime import datetime, timezone
7
- from typing import Literal
8
 
9
- import html
10
- from urllib.parse import urljoin, urlparse
11
 
12
- from pydantic import BaseModel, Field, HttpUrl
13
 
14
- Status = Literal["ok", "no_upcoming", "error"]
 
15
 
16
- USER_AGENT = (
17
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
18
- "(KHTML, like Gecko) Chrome/120 Safari/537.36"
19
- )
20
 
21
- # SYSTEM_PROMPT = (
22
- # "You are given the page text content and a list of links from the page. "
23
- # "Find up to the NEXT 3 upcoming talks/events AFTER now, sorted by time ascending. "
24
- # "If the page lists only a date (no time), output start_time as an ISO-8601 DATE like '2026-01-15'. "
25
- # "If events are listed as lines with a date (e.g. 'Jan 15, 2026 – ...'), treat each such line as an event. "
26
- # "If there is no dedicated per-event URL, set event_url to the Source URL. "
27
- # "If the schedule is not on this page, set next_url_to_check to the single best link to follow (one of LINKS). "
28
- # "Do NOT invent placeholder titles like 'TBA' unless the page text explicitly contains 'TBA'. "
29
- # "IMPORTANT: Keep fields clean and separated: "
30
- # "- events[].title MUST be the talk/event title ONLY (no speaker name, no affiliation, no date/time). "
31
- # "- Put the speaker/person name in events[].speaker when present in the text (e.g. 'Speaker: …', 'by …', 'Presenter: …'). "
32
- # "- If the speaker affiliation/institution is present (e.g. 'UC Davis', 'MIT', 'Google DeepMind'), put it in events[].affiliation (do not mix it into title). "
33
- # "- If a line contains both speaker and title (e.g. 'Jane Doe — Learning Robots' or 'Learning Robots — Jane Doe'), split them correctly. "
34
- # "Choose the talk title that is best-supported by the page text. Give priority to explicit cues like 'Title:', 'Talk title:', 'Topic:', and text near 'Abstract:'/'Summary:'. "
35
- # "If a header includes a series label plus a person name/affiliation (e.g. 'Seminar: Jane Doe (MIT)'), treat that as speaker/affiliation (not title) and keep searching the body for the real title. "
36
- # "e.g. this Robotics Institute Seminar: Mahdi Tavakoli (University of Alberta) is not a title"
37
- # "Keep person name and affiliation seprate. Each should be put in its own field. "
38
- # "Never guess; quote evidence from the provided text."
39
- # "Never reply with any text other than the JSON object. If you don't find any events, still reply with a JSON object containing the no_upcoming status. "
40
- # "REMEMBER: START TIME IS ALWAYS REQUIRED FOR EACH EVENT. IF THERE IS NO START TIME, DO NOT INCLUDE THE EVENT. "
41
 
42
- # "Respond in JSON with the following schema: "
43
-
44
- # "status: ok | no_upcoming | error"
45
- # "events: array of up to 3 objects (required if ok)"
46
- # "events[].title: string"
47
- # "events[].start_time: ISO-8601 string; if only date is known, use YYYY-MM-DD (always required)"
48
- # "events[].event_url: string URL"
49
- # "events[].speaker: string "
50
- # "events[].affiliation: string (if prersent)"
51
- # "events[].evidence: short snippet from provided text (always required)"
52
- # "error: short error string (required if error)"
53
- # "next_url_to_check: string URL (optional - must be one of LINKS if provided)"
54
- # )
55
-
56
- SYSTEM_PROMPT = """
57
- You are a JSON extraction engine. You do NOT write code.
58
-
59
- CRITICAL OUTPUT CONSTRAINTS (HARD):
60
- - Your entire reply MUST be valid JSON (RFC 8259).
61
- - Reply with exactly ONE JSON object.
62
- - The first non-whitespace character MUST be "{" and the last MUST be "}".
63
- - Use double quotes for all JSON strings. Never use single quotes.
64
- - Do NOT include markdown fences (```), explanations, pseudocode, or Python.
65
-
66
- If you cannot follow these constraints, reply exactly:
67
- {"status":"error","error":"non_json_or_invalid_schema"}
68
-
69
- Event extraction rules (HARD):
70
- - Return up to 3 upcoming events after "now", sorted by start_time ascending.
71
- - Every event MUST include start_time and it MUST be ISO-8601 (e.g. "2026-01-15" or "2026-01-15T14:00+00:00").
72
- - Do NOT use month names or informal dates like "Jan 15, 2026".
73
- - If you cannot find a date/time in PAGE_TEXT for an event, DO NOT include that event.
74
- - If you find zero events with a date/time, return {"status":"no_upcoming"}.
75
-
76
- Title rule (HARD):
77
- - title MUST be copied verbatim from PAGE_TEXT (no paraphrasing).
78
- - title MUST come from the same local event block as the date/time:
79
- - it must appear within 300 characters of the date/time text you used for start_time.
80
- - Do NOT use site/series/page headings or navigation as title.
81
- Examples of INVALID titles: "Seminar Series", "Events", "Robotics", "University of Toronto", page header text.
82
- - If you cannot find a specific talk/topic title near the date/time, DO NOT include the event.
83
-
84
- If PREVIOUS_EVENTS is provided:
85
- - Use PREVIOUS_EVENTS as a strict copy source.
86
- - If PAGE_TEXT contains an event that matches one in PREVIOUS_EVENTS, you MUST include that event in your output and you MUST copy the entire event object exactly from PREVIOUS_EVENTS.
87
- - Do NOT omit a matched event. Do NOT say it is "already known". Do NOT reduce the number of returned events because PREVIOUS_EVENTS were provided.
88
- - If PREVIOUS_EVENTS contains events that are NOT present in PAGE_TEXT, ignore them.
89
-
90
- Final self-check (HARD, perform before replying):
91
- - Your reply must be valid JSON only.
92
- - For each event: verify start_time exists and is a non-empty string.
93
- - If an event has missing/empty start_time, REMOVE that event.
94
- - If no events remain, output {"status":"no_upcoming"}.
95
-
96
- Schema:
97
- { "status": "ok"|"no_upcoming"|"error",
98
- "events": [{"title": "...", "start_time": "...", "event_url": "...", "speaker": "", "affiliation": null, "attending_count": 0}],
99
- "error": "...",
100
- "next_url_to_check": "..." }
101
-
102
- """
103
-
104
-
105
- class OrgSource(BaseModel):
106
- id: str
107
- name: str
108
- url: HttpUrl
109
- tags: list[str] = Field(default_factory=list)
110
-
111
-
112
- class LlmEvent(BaseModel):
113
- title: str
114
- start_time: str
115
- event_url: str | None = None
116
- speaker: str
117
- affiliation: str | None = None
118
- attending_count: int = 0
119
-
120
-
121
- class LlmHopResult(BaseModel):
122
- status: Status
123
- events: list[LlmEvent] = Field(default_factory=list)
124
- error: str | None = None
125
- next_url_to_check: str | None = None
126
-
127
-
128
- class EventResult(BaseModel):
129
- org_id: str
130
- org_name: str
131
- source_url: str
132
- status: Status
133
- events: list[LlmEvent] = Field(default_factory=list)
134
- checked_at: str
135
- hops: int = 0
136
- visited_urls: list[str] = Field(default_factory=list)
137
- error: str | None = None
138
-
139
-
140
- @dataclass
141
- class LlmConfig:
142
- model: str
143
- api_key: str | None = None
144
- api_base: str | None = None
145
 
146
  def parse_dt_utc(value: str) -> datetime:
147
- from dateutil import parser as dtparser
148
-
149
- dt = dtparser.isoparse(value)
150
  if dt.tzinfo is None:
151
  dt = dt.replace(tzinfo=timezone.utc)
152
  return dt.astimezone(timezone.utc)
153
 
154
 
155
- def text_and_links(page_html: str, *, base_url: str, limit: int = 40) -> tuple[str, list[str]]:
156
- from bs4 import BeautifulSoup
157
-
158
- soup = BeautifulSoup(html.unescape(page_html), "html.parser")
159
- for tag in soup(["script", "style", "noscript"]):
160
- tag.decompose()
161
- text = "\n".join(ln.strip() for ln in soup.get_text("\n").splitlines() if ln.strip())[:24000]
162
-
163
- base_dom = urlparse(base_url).netloc.lower()
164
- links: list[str] = []
165
- for a in soup.find_all("a", href=True):
166
- u = urljoin(base_url, str(a["href"]).strip())
167
- p = urlparse(u)
168
- if p.scheme in {"http", "https"} and p.netloc.lower() == base_dom:
169
- links.append(u)
170
- if len(links) >= limit:
171
- break
172
-
173
- return text, links
174
-
175
-
176
- def llm_extract(*, config: LlmConfig, org: OrgSource, url: str, page_html: str, now_iso: str, previous_events: list[dict]) -> LlmHopResult:
177
- from litellm import completion # type: ignore
178
-
179
- page_text, links = text_and_links(page_html, base_url=url)
180
- messages = [
181
- {"role": "system", "content": SYSTEM_PROMPT},
182
- {"role": "user", "content": json.dumps({"org": org.name, "now": now_iso, "source_url": url, "PREVIOUS_EVENTS": previous_events})},
183
- {"role": "user", "content": "LINKS:\n" + "\n".join(links)},
184
- {"role": "user", "content": "PAGE_TEXT_BEGIN\n" + page_text + "\nPAGE_TEXT_END"},
185
- {"role": "user", "content": "Return ONLY one JSON object (no markdown, no code). "
186
- "Must start with '{' and end with '}'. Use double quotes only. "
187
- "start_time MUST be ISO-8601 only (e.g. 2026-01-15 or 2026-01-15T14:00+00:00), never 'Jan 15, 2026'. "
188
- "Before returning, delete any event missing/empty start_time. "
189
- "Title must be copied verbatim from PAGE_TEXT near the date/time. "
190
- "IMPORTANT: PREVIOUS_EVENTS are NOT a reason to omit events. If PAGE_TEXT contains an event that matches PREVIOUS_EVENTS, you MUST re-output it by copying the entire event object exactly from PREVIOUS_EVENTS. "
191
- "If none remain, return {\"status\":\"no_upcoming\"}."}
192
- ]
193
-
194
- timeout = float(os.environ.get("LLM_TIMEOUT_SECS", "60"))
195
- kwargs: dict[str, object] = {"model": config.model, "temperature": 0, "timeout": timeout}
196
- if config.api_key:
197
- kwargs["api_key"] = config.api_key
198
- if config.api_base:
199
- kwargs["api_base"] = config.api_base
200
-
201
- content = completion(messages=messages, **kwargs)["choices"][0]["message"]["content"]
202
- content = content.replace('`', '').replace('json', '')
203
- content = content[content.find("{") : content.rfind("}") + 1]
204
-
205
- try:
206
- content = json.loads(content)
207
- except json.JSONDecodeError:
208
- raise ValueError(f"LLM did not return valid JSON:\n{content}")
209
-
210
- try:
211
- LlmHopResult.model_validate(content)
212
- except Exception as e:
213
- raise ValueError(f"LLM returned JSON that does not match schema: {e}\n{content}")
214
-
215
- return LlmHopResult.model_validate(content)
 
2
 
3
  import json
4
  import os
 
5
  from datetime import datetime, timezone
6
+ from pathlib import Path
7
 
8
+ DEFAULT_EVENTS_PATH = Path(os.environ.get("EVENTS_PATH", Path(os.environ.get("ASSETS_DIR", "assets")) / "events.json"))
 
9
 
 
10
 
11
+ def load_payload(path: str | Path = DEFAULT_EVENTS_PATH) -> dict:
12
+ return json.loads(Path(path).read_text(encoding="utf-8"))
13
 
 
 
 
 
14
 
15
+ def write_payload(payload: dict, path: str | Path = DEFAULT_EVENTS_PATH) -> None:
16
+ Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def parse_dt_utc(value: str) -> datetime:
20
+ dt = datetime.fromisoformat(value)
 
 
21
  if dt.tzinfo is None:
22
  dt = dt.replace(tzinfo=timezone.utc)
23
  return dt.astimezone(timezone.utc)
24
 
25
 
26
+ def infer_category(*, title: str, org_name: str, event_url: str) -> str:
27
+ text = " ".join((title, org_name, event_url)).lower().replace("-", " ").replace("_", " ").replace("/", " ")
28
+ if "thesis defense" in text or "phd defense" in text:
29
+ return "PhD defense"
30
+ if "thesis proposal" in text or "phd proposal" in text:
31
+ return "PhD proposal"
32
+ if "lecture" in text:
33
+ return "Lecture"
34
+ if "talk" in text:
35
+ return "Talk"
36
+ if "podcast" in text or "episode" in text:
37
+ return "Podcast"
38
+ if "seminar" in text:
39
+ return "Seminar"
40
+ return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/org_colors.py CHANGED
@@ -2,24 +2,12 @@ from __future__ import annotations
2
 
3
  from html import escape
4
 
5
- PALETTE = [
6
- "#e6194b",
7
- "#3cb44b",
8
- "#4363d8",
9
- "#f58231",
10
- "#911eb4",
11
- "#46f0f0",
12
- "#f032e6",
13
- "#bcf60c",
14
- "#fabebe",
15
- "#008080",
16
- "#e6beff",
17
- "#9a6324",
18
- ]
19
 
20
 
21
- def org_colors(sources: list[dict]) -> dict[str, str]:
22
- return {s["name"]: PALETTE[i % len(PALETTE)] for i, s in enumerate(sources)}
 
23
 
24
 
25
  def org_tag(org: str, colors: dict[str, str]) -> str:
 
2
 
3
  from html import escape
4
 
5
+ PALETTE = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#008080"]
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
 
8
+ def org_colors(names) -> dict[str, str]:
9
+ unique_names = list(dict.fromkeys(names))
10
+ return {name: PALETTE[index % len(PALETTE)] for index, name in enumerate(unique_names)}
11
 
12
 
13
  def org_tag(org: str, colors: dict[str, str]) -> str:
src/ui_log.py DELETED
@@ -1,39 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from datetime import datetime, timezone
4
-
5
- LINES: list[str] = []
6
-
7
-
8
- def ts() -> str:
9
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
10
-
11
-
12
- def log(level: str, msg: str) -> None:
13
- html = {
14
- "INFO": "dodgerblue",
15
- "WARN": "goldenrod",
16
- "ERR": "crimson",
17
- }
18
- c = html.get(level)
19
- tag = f"<span style=\"color:{c}\">{level}</span>" if c else level
20
- LINES.append(f"[{ts()}] {tag}: {msg}")
21
-
22
-
23
- def info(msg: str) -> None:
24
- log("INFO", msg)
25
-
26
-
27
- def warn(msg: str) -> None:
28
- log("WARN", msg)
29
-
30
-
31
- def error(msg: str) -> None:
32
- log("ERR", msg)
33
-
34
- def clear() -> None:
35
- LINES.clear()
36
-
37
-
38
- def lines() -> list[str]:
39
- return LINES
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_golden.py CHANGED
@@ -1,54 +1,84 @@
1
- import json, os, shutil, tempfile
2
- from pathlib import Path
3
-
4
- ROOT = Path(__file__).resolve().parents[1]
5
- GOLDEN = ROOT / ".data" / "events.json"
6
-
7
-
8
- def _load(p: Path) -> dict: return json.loads(p.read_text(encoding="utf-8"))
9
-
10
-
11
- def _norm(d: dict) -> dict:
12
- d = json.loads(json.dumps(d)); (d.get("meta") or {}).pop("cached_at", None)
13
- for r in d.get("results") or []:
14
- if isinstance(r, dict): r.pop("checked_at", None)
15
- return d
16
-
17
-
18
- def test_cached_results_match_golden():
19
- golden = _load(GOLDEN); model = (golden.get("meta") or {}).get("model"); assert isinstance(model, str)
20
- with tempfile.TemporaryDirectory() as td:
21
- data_dir = Path(td); shutil.copy2(GOLDEN, data_dir / "events.json")
22
- from src.cache_store import CacheStore
23
- from src.models import LlmConfig
24
- cache = CacheStore(config=LlmConfig(model=model), ttl_hours=9999.0, repo_id="", path=str(data_dir / "events.json"))
25
- assert cache.is_usable()
26
- got = _load(cache.path)
27
- assert got["results"] == golden["results"]
28
-
29
-
30
- def test_live_crawl_matches_golden_snapshot():
31
- if os.environ.get("RUN_LIVE_TESTS") != "1": return
32
- golden = _load(GOLDEN); model = (golden.get("meta") or {}).get("model"); assert isinstance(model, str)
33
- with tempfile.TemporaryDirectory() as td:
34
- data_dir = Path(td)
35
- os.environ.update({"LLM_TEMPERATURE": "0", "LLM_SEED": "1", "LITELLM_FALLBACK_MODELS": ""})
36
- cached_at = (golden.get("meta") or {}).get("cached_at")
37
- if isinstance(cached_at, str) and cached_at.strip(): os.environ["NOW_ISO"] = cached_at.strip()
38
- from src.cache_store import CacheStore
39
- from src.fetcher import SeminarFetcher
40
- from src.models import LlmConfig, OrgSource
41
- cfg = LlmConfig(model=model)
42
- raw_sources = json.loads((ROOT / "sources.json").read_text(encoding="utf-8"))
43
- sources = [OrgSource.model_validate(s) for s in raw_sources]
44
- fetcher = SeminarFetcher(config=cfg, now=None, max_hops=3, max_events=3)
45
- results = [fetcher.fetch_next_event_for_org(o) for o in sources]
46
- cache = CacheStore(
47
- config=cfg,
48
- ttl_hours=float((golden.get("meta") or {}).get("ttl_hours") or 12.0),
49
- repo_id="",
50
- path=str(data_dir / "events.json"),
51
- )
52
- cache.write(results=results)
53
- produced = _load(cache.path)
54
- assert _norm(produced) == _norm(golden)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+
5
+ from src.attending import COLS, LOCAL_TIME_COL, inc_attending, results_table
6
+ from src.models import infer_category, load_payload, parse_dt_utc, write_payload
7
+
8
+
9
+ def sample_results() -> list[dict]:
10
+ return [
11
+ {
12
+ "org_id": "cmu",
13
+ "org_name": "CMU",
14
+ "source_url": "https://example.com",
15
+ "status": "ok",
16
+ "events": [
17
+ {
18
+ "title": "Event",
19
+ "start_time": "2026-04-13T09:00:00-04:00",
20
+ "event_url": "https://example.com/event",
21
+ "speaker": "Speaker",
22
+ "affiliation": None,
23
+ "category": "Seminar",
24
+ "tags": ["robotics", "university"],
25
+ "attending_count": 0,
26
+ }
27
+ ],
28
+ "checked_at": "2026-04-12T20:22:12+00:00",
29
+ "hops": 1,
30
+ "visited_urls": [],
31
+ "error": None,
32
+ }
33
+ ]
34
+
35
+
36
+ def test_results_table_shows_source_and_local_time():
37
+ table, _ = results_table(sample_results(), {"CMU": "#000000"})
38
+ assert list(table.columns) == COLS
39
+ assert table.loc[0, "Source Time"] == "Apr 13 at 9:00"
40
+ assert table.loc[0, LOCAL_TIME_COL] == "Apr 13 at 15:00"
41
+ assert table.loc[0, "Category"] == "Seminar"
42
+ assert table.loc[0, "Tags"] == "robotics, university"
43
+
44
+
45
+ def test_inc_attending_updates_results_and_events_file(tmp_path):
46
+ path = tmp_path / "events.json"
47
+ payload = {
48
+ "meta": {
49
+ "schema_version": 5,
50
+ "cached_at": "2026-04-12T20:22:12+00:00",
51
+ "ttl_hours": 12.0,
52
+ },
53
+ "results": sample_results(),
54
+ }
55
+ write_payload(payload, path)
56
+
57
+ results = load_payload(path)["results"]
58
+ table, row_map = results_table(results, {"CMU": "#000000"})
59
+
60
+ updated_table, updated_results, updated_row_map = inc_attending(
61
+ SimpleNamespace(index=(0, COLS.index("Attending"))),
62
+ results,
63
+ row_map,
64
+ colors={"CMU": "#000000"},
65
+ events_path=path,
66
+ )
67
+
68
+ assert updated_results[0]["events"][0]["attending_count"] == 1
69
+ assert updated_table.loc[0, "Attending"] == "1 (+)"
70
+ assert updated_row_map == row_map
71
+ assert load_payload(path)["results"][0]["events"][0]["attending_count"] == 1
72
+
73
+
74
+ def test_parse_dt_utc_normalizes_naive_and_offset_datetimes():
75
+ assert parse_dt_utc("2026-04-13T09:00:00").isoformat() == "2026-04-13T09:00:00+00:00"
76
+ assert parse_dt_utc("2026-04-13T09:00:00-04:00").isoformat() == "2026-04-13T13:00:00+00:00"
77
+
78
+
79
+ def test_infer_category_prefers_defense_over_seminar():
80
+ assert infer_category(
81
+ title="Efficient and Controllable Diffusion Models",
82
+ org_name="Robotics Institute Seminar Series",
83
+ event_url="https://example.com/ri-phd-thesis-defense/",
84
+ ) == "PhD defense"