Spaces:
Sleeping
Sleeping
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 +0 -264
- README.md +15 -41
- app.py +42 -155
- assets/events.json +9 -0
- assets/prompt.txt +87 -0
- sources.json → assets/sources.json +11 -1
- events.json +0 -270
- requirements.txt +1 -10
- src/attending.py +53 -49
- src/cache_store.py +0 -80
- src/fetcher.py +0 -115
- src/hf_sync.py +59 -0
- src/models.py +22 -197
- src/org_colors.py +4 -16
- src/ui_log.py +0 -39
- tests/test_golden.py +84 -54
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
|
| 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 |
-
-
|
| 19 |
-
-
|
| 20 |
-
-
|
| 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 |
-
##
|
| 40 |
|
| 41 |
-
The app
|
| 42 |
|
| 43 |
-
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 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.
|
|
|
|
| 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
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
)
|
| 44 |
-
|
| 45 |
-
|
| 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\
|
| 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(
|
| 154 |
-
|
| 155 |
-
|
| 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 |
-
|
| 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 .
|
| 9 |
-
from .models import
|
| 10 |
from .org_colors import org_tag
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
for r_i, r in enumerate(results):
|
| 23 |
-
if r["status"] != "ok":
|
| 24 |
continue
|
| 25 |
-
for
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
rows.append(
|
| 30 |
{
|
| 31 |
-
"
|
| 32 |
-
|
| 33 |
-
"Title": f"[{
|
| 34 |
-
"Speaker": speaker,
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"
|
| 39 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 48 |
-
|
| 49 |
-
df.
|
| 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
|
| 58 |
results: list[dict],
|
| 59 |
row_map: list[tuple[int, int]],
|
| 60 |
*,
|
| 61 |
colors: dict[str, str],
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
cache_repo_id: str,
|
| 65 |
-
cache_hf_token: str | None,
|
| 66 |
):
|
| 67 |
if evt is None or evt.index is None:
|
| 68 |
-
|
| 69 |
-
return
|
| 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 |
-
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
|
|
|
| 80 |
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
-
return
|
|
|
|
| 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
|
| 8 |
|
| 9 |
-
|
| 10 |
-
from urllib.parse import urljoin, urlparse
|
| 11 |
|
| 12 |
-
from pydantic import BaseModel, Field, HttpUrl
|
| 13 |
|
| 14 |
-
|
|
|
|
| 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 |
-
|
| 22 |
-
|
| 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 |
-
|
| 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
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 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(
|
| 22 |
-
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|