diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6c64bc24bee7d2e01eb0a33a196752e85016180 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts must use LF so bash on Linux (HF Space, Modal) doesn't choke on +# carriage returns (e.g. "set: pipefail: invalid option name") when the repo is +# committed/edited from Windows. +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..531ead3a23a174ae9aca3861dcdd5f05a54dcdac --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Secrets / config +.env +*.env +!*.env.example +collector/.env + +# Models & data (large; live on HF, not git) +*.gguf +*.bin +*.safetensors +models/ +training/data/* +!training/data/dataset.jsonl +!training/data/eval.jsonl +!training/data/eval_unstructured.jsonl +!training/data/ab_results.md +# screenshots/ stays ignored: regenerate with training/render_screenshots.py +training/outputs/ +checkpoints/ + +# Generated calendar files +*.ics +out/ + +# Google OAuth +token.json +credentials.json +client_secret*.json + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.ipynb_checkpoints/ +*.egg-info/ + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ +tok.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..56ce3ee612cb1a11df740899cd5bfbc11f984c24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Dedicated paid-GPU Space (Docker SDK) — real Gemma 4 on the OFFICIAL llama.cpp. +# Compiling llama.cpp in the HF build exceeds the build time limit, so we base on the +# llama.cpp project's own prebuilt CUDA image (trusted, current → supports Gemma 4). +# It runs `llama-server`; our app (UI + /agent) calls it via INFERENCE_BASE_URL. +# Pick a CUDA GPU in Space settings (e.g. 1x A100). Llama Champion = the llama.cpp server. +FROM ghcr.io/ggml-org/llama.cpp:server-cuda + +ENV PYTHONUNBUFFERED=1 \ + DEBIAN_FRONTEND=noninteractive \ + PORT=7860 \ + SERVE=uvicorn \ + HF_HOME=/tmp/hf \ + LLAMA_CACHE=/tmp/llama-cache \ + INFERENCE_BASE_URL="http://127.0.0.1:8080/v1" \ + INFERENCE_MODEL="gemma-4" \ + MODEL_HF_REPO="ParetoOptimal/gemma-4-cal-gguf" \ + MODEL_FILE="gemma-cal-e4b-Q4_K_M.gguf" \ + MMPROJ_REPO="unsloth/gemma-4-E4B-it-GGUF" \ + MMPROJ_FILE="mmproj-F16.gguf" +# Agent-tab planner (OFF by default — set as Space variables to enable): +# PLANNER_HF_REPO="openbmb/MiniCPM4.1-8B-GGUF" PLANNER_FILE="MiniCPM4.1-8B-Q4_K_M.gguf" +# (tiny <=4B variant: openbmb/MiniCPM5-1B-GGUF / MiniCPM5-1B-Q4_K_M.gguf) +# PLANNER_PORT=8081 PLANNER_NGL=999 PLANNER_BASE_URL=http://127.0.0.1:8081/v1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip curl ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Keep our app out of the image's /app (where the llama-server binary lives). +WORKDIR /srv + +COPY requirements-docker.txt . +# --break-system-packages: the base image's Python is PEP 668 externally-managed. +RUN pip3 install --no-cache-dir --break-system-packages -r requirements-docker.txt + +COPY . . + +# The base image's entrypoint is llama-server; we run our launcher instead. +ENTRYPOINT [] +EXPOSE 7860 +CMD ["bash", "scripts/start_space.sh"] diff --git a/FIELD_NOTES.md b/FIELD_NOTES.md new file mode 100644 index 0000000000000000000000000000000000000000..f15169947bbf3d85e8208d94b3ff5f23f984db1e --- /dev/null +++ b/FIELD_NOTES.md @@ -0,0 +1,145 @@ +# Field Notes — building the iMessage → Calendar agent + +What I set out to build, where reality bent the plan, and what I'd do next. This is +the "what I learned" companion to the product docs ([README](./README.md)) and the +design doc ([PLAN](./PLAN.md)). + +## The goal in one line + +Turn the calendar logistics buried in a chat thread — *"picture day moved to +Thursday 9am", "soccer is Tuesday now"* — into reviewed calendar events, from a +phone, with the data staying private. + +## 1. "Read my iMessages" is impossible as literally asked — and that shaped everything + +iOS exposes **no API** for iMessage/SMS *content*. There is no on-device path. The +only place the messages exist in a queryable form is a Mac, where they sync to +`~/Library/Messages/chat.db`. So the architecture forked early: + +- A Mac-side collector ([`collector/collector.py`](./collector/collector.py)) reads + `chat.db` (read-only, `mode=ro`) and POSTs new rows to the Space. +- "On my phone" was reinterpreted honestly as **used from** a phone browser — the + Space is hosted, the UI is mobile-friendly, but the model runs in the Space. + +The biggest adoption lesson came later: requiring a Mac collector + Full Disk +Access is a wall for a non-technical user. The fix was to make **paste-from-phone** +the hero path (the collector is now strictly optional) — no install, no DB, no +permissions. Most of that capability already existed in the Schedule tab; it was just +framed as secondary. + +## 2. `attributedBody` is the iMessage parsing trap + +Modern Messages often stores the body in `attributedBody` (an `NSAttributedString` +binary blob), **not** the `text` column. The collector reads `text` directly for +simplicity and **skips messages that only have `attributedBody`** +([`collector/collector.py:88-94`](./collector/collector.py)) — a deliberate, called-out +gap. The right move for production is to not hand-roll this: use `imessage-exporter` +(ReagentX) or `imessage_reader`. Noting the limitation in code beat pretending the +naive SQL was complete. + +## 3. Relative dates are the real accuracy battleground + +The hard part isn't "is there an event" — it's *when*. "Next Thursday", "the 14th", +"in two weeks" only resolve against a reference time. Two design responses: + +- The system prompt pins **"Current datetime"** into every request and instructs the + model to resolve relative dates from it ([`server/agent.py:21-34`](./server/agent.py)). +- **Conflict math is deterministic, not model-driven.** Overlap/adjacent/tight + detection and alternative-time proposals live in + [`calendar_out/freebusy.py`](./calendar_out/freebusy.py), because once you have ISO + datetimes, interval math should never be left to an LLM. The model decides *what*; + code decides *when-it-clashes*. + +The stub extractor's naive "match a time → 1h event tomorrow" +([`server/agent.py:152-175`](./server/agent.py)) is intentionally dumb — it exists to +prove the pipeline, and its dumbness is a good reminder of exactly how much the +fine-tune has to get right. + +## 4. Stub-first was the best architectural call + +`USE_STUB_EXTRACTOR=1` swaps the model for a regex heuristic +([`server/agent.py:85,124`](./server/agent.py)), forced on in tests +([`tests/conftest.py`](./tests/conftest.py)). Payoffs: + +- The whole app — paste → events → conflicts → `.ics` download → impact panel — + **works end-to-end with no GPU**, so a demo (and CI) never depends on a model load. +- `llama_cpp` and the Google libs are **lazy-imported**, so `requirements-ci.txt` can + exclude them and the test suite runs in seconds, offline. + +Lesson: make the expensive dependency optional from day one and the cheap path +becomes your test harness, your demo, and your free tier all at once. + +## 5. Reframing around one person changed the scope more than any feature + +The project started as a four-track hackathon checklist. Rewriting it around a single +named person — a **busy parent** whose kid's events are buried in a class group chat — +forced three concrete changes: phone-paste as the default, a one-tap **Try a sample** +class-chat ([`ui/blocks.py`](./ui/blocks.py)), and a **"This week"** impact panel. + +On measurement: `minutes_saved` ([`server/impact.py`](./server/impact.py)) is a +**configurable estimate, not a measurement** (default 8 min/event + 15 min/conflict). +Saying that plainly — in the UI, the README, and here — matters more than a +bigger-looking number. A capture is only counted when the parent *accepts* events by +exporting them, so the metric tracks value taken, not previews shown. + +## 6. Fine-tuning economics: Modal credits + honest scope + +QLoRA on a 31B needs an 80GB GPU. [`training/modal_train.py`](./training/modal_train.py) +wraps the existing `train_qlora.py` + `export_gguf.sh` to run on a serverless +A100/H100 and publish the GGUF to HF — roughly **$5–15 per run**, so ~$250 of credit +is 15–40 iterations. The "Well-Tuned" track went the distance: the eval-gated **E4B** fine-tune is +published and is what production serves — +[`build-small-hackathon/gemma-4-cal-gguf`](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) +— after clearing the gate over six runs at zero quality cost vs. stock E4B. (Re-running the pipeline +still spends your own Modal credits; the turnkey path is there whenever you want to retrain.) + +A small rule that paid off: training-data generation can use *any* offline tooling — +the "no cloud AI API" rule applies only to the **running app's inference**, not to +dataset prep. + +## 7. Two models, not one — a 1B planner over the same tools + +What shipped is two small local models, not one. The fine-tuned **gemma-cal E4B** does the +*reading* (thread → validated `ActionPlan`); a 1B **OpenBMB MiniCPM** does the *orchestrating*. +Clicking **Run the agents** hands the job to MiniCPM, which drives the Space's own MCP tools — +`extract_events → check_conflicts → make_ics` — as a visible multi-step agent +([`server/orchestrator.py`](./server/orchestrator.py)), consuming the *public* tool contract +instead of calling internals. Two things I'd underline: keep the planner **optional** (a +deterministic scripted plan is the fallback, so the agentic path never hard-depends on a second +model load), and don't let "agent" become a separate destination — the same **Run the agents** +action drives both the home workflow and the orchestrated trace, so it stays one engine, not a +second UI to keep in sync. + +## 8. The Off-the-Grid tension + +"No cloud AI APIs" and "serve a 31B" pull against each other: a Q4 31B GGUF is +~18–20GB and needs a GPU. Keeping inference **in the Space via `llama.cpp`** preserves +the privacy story but costs GPU. The honest compromise is the **E4B edge variant** for +the free tier, with the 31B as the headline. I deliberately did **not** offload +inference to a third-party endpoint, because "your own Modal GPU" and "a cloud AI API" +are easy to conflate and a purist judge would be right to dock it. + +The same principle drove the trace-sharing design (below): the hosted Space holds **no +HF token** — it only offers a **local download**, and a separate local CLI does the +upload with your own auth. + +## 9. What I'd do next + +- **Durable trace/metrics store.** The activity bus is an 800-entry in-memory ring + buffer ([`server/events.py`](./server/events.py)) — runs are lost on restart, so only + recent runs are exportable. A small append-only store (the impact log already shows + the pattern) would fix it. +- **Decode `attributedBody`** (or adopt `imessage-exporter`) so text-less messages stop + being dropped. +- **A real eval set** from the expanded dataset — measure JSON validity + field + accuracy, especially relative-date resolution and empty-list-on-chitchat. +- **Trace redaction as a tested invariant.** Today it's an allowlist over current emit + sites ([`server/trace.py`](./server/trace.py)); a lint/test that fails when a new + `emit(...)` puts free text on a non-`ingest` stage would keep it honest as the code + grows. + +## Publishing these notes + +This file is linked from the README. It can also be pasted into the Space's README +(the Space card renders Markdown) or posted to the model/dataset repo's **Community** +tab on the Hub so others can learn from the build. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..41ae551f1cd2062838d293e950f5a6866c633bcf --- /dev/null +++ b/PLAN.md @@ -0,0 +1,182 @@ +# Plan: Local-First iMessage → Calendar Agent (Gradio + llama.cpp + fine-tuned Gemma + an OpenBMB MiniCPM-planned agent) + +## Who this is for + +One named person: **a busy parent** whose kid's school/activity events are buried in a noisy class +group chat (picture day, the practice that moved, the RSVP). They read it once, mean to add it later, +and miss it. Success = *their* day measurably improves — events captured from the chat, conflicts +caught against their calendar, minutes saved — with **zero setup**: paste the thread or a screenshot +from a phone browser. The local-LLM / fine-tune work below is a **means** to better extraction, not +the point; the app must deliver value with no GPU (stub agent) first. + +## Context + +You want an agent that reads iMessage-style threads, understands the conversation, and turns them +into calendar events/reminders — exposed through a custom Gradio UI deployed as a Hugging Face Space. +Two local models share the work: our fine-tuned Gemma does the *reading* (thread → validated +ActionPlan), and an **OpenBMB MiniCPM** planner does the *orchestrating* — the brain behind **Run the agents**, +driving the Space's own MCP tools (`extract_events → check_conflicts → make_ics`) as a visible +multi-step agent. The build competes in the **Backyard AI** track (general and OpenBMB prizes are +awarded per track) and satisfies the quests secondary to the user story above: **Off the Grid** (no +cloud AI APIs, local-first), **Well-Tuned** (a fine-tuned model on HF), **Off-Brand** (custom UI), +and **Llama Champion** (both Gemma and MiniCPM are served through llama.cpp). + +### Feasibility verdict: YES, with one re-architecture + +The request as *literally* worded has two impossibilities, both solvable: + +1. **No app or cloud can read iMessage on iOS.** Apple exposes no API for iMessage/SMS content. + → **Solved:** you have a Mac. iMessages sync to `~/Library/Messages/chat.db`; a small local + collector reads it. This is the *only* supported path and it keeps data local ("off the grid"). +2. **A model cannot "run on your phone," and a HF Space runs in the cloud, not on-device.** + → **Solved:** "on my phone" = *used from* your phone's browser. The Space does its own llama.cpp + inference and calls no external AI service, so "hosted Space" and "off the grid" reconcile. + +Confirmed decisions: +- **Ingestion:** Mac collector reading `chat.db`. +- **Calendar output:** local `.ics` files first (strictly off-grid), with an *optional* Google + Calendar push toggle as a bonus. +- **Extraction model:** fine-tune Gemma, serve as GGUF via llama.cpp (production serves the + **E4B** edge fine-tune, `build-small-hackathon/gemma-4-cal-gguf`). +- **Agent planner:** **OpenBMB MiniCPM** (`openbmb/MiniCPM4.1-8B-GGUF`, Q4; the 1B variant is a + config switch) on a second llama-server — it plans, the MCP tools execute, every step visible. + +--- + +## Architecture + +``` +┌────────── Your Mac (local) ──────────┐ ┌──────── Hugging Face Space (Docker) ────────┐ +│ collector.py (Full Disk Access) │ HTTPS │ Gradio (custom theme/CSS) ── Off-Brand │ +│ • polls chat.db for new messages │ +token │ │ │ +│ • parses text / attributedBody ├────────▶│ FastAPI /ingest ──▶ extraction pipeline │ +│ • POSTs new msgs to Space /ingest │ │ │ │ +└───────────────────────────────────────┘ │ llama.cpp (llama-cpp-python) ── Llama Champ │ + │ running YOUR fine-tuned gemma-4-31B GGUF │ + View/approve from phone browser ───────────────▶│ │ ── Off the Grid (local) │ + │ JSON events → pydantic validate │ + │ ├──▶ .ics file (download) │ + │ └──▶ optional Google Calendar push │ + └──────────────────────────────────────────────┘ +``` + +Flow: messages → extraction prompt → model emits structured JSON of candidate events → +validated → shown in UI for review → user approves → `.ics` generated (and/or pushed to GCal). + +**Run the agents** runs the same flow agentically: an **OpenBMB MiniCPM** planner (second local +llama-server, OpenAI-compatible) consumes the Space's own **MCP tool surface** — +`extract_events → check_conflicts → make_ics` — through smolagents, so the pipeline above is +demonstrated as multi-step tool use over the public tool contract, with the planner's trace on +screen (`server/orchestrator.py`). Stub/CI falls back to a scripted planner so the tab always works. + +--- + +## Components + +### 1. Mac-side iMessage collector (`collector/collector.py`) +- **Reuse, don't reinvent the DB parsing.** Modern macOS stores message text in the + `attributedBody` (NSAttributedString) blob, not always the `text` column. Use the battle-tested + **`imessage-exporter`** (ReagentX, Rust) or the Python **`imessage_reader`** lib rather than + hand-rolling SQL. If hand-querying: join `message` ⨝ `handle` ⨝ `chat_message_join` ⨝ `chat`, + track last seen `ROWID`, poll on an interval. +- Requires **Full Disk Access** for the running process (System Settings → Privacy & Security). +- Sends only new messages to the Space `/ingest` endpoint over HTTPS with a shared bearer token. +- Config: which chats to watch, poll interval, Space URL, token (`.env`, never committed). + +### 2. HF Space backend (`app.py`, `server/`) +- **Docker SDK Space** (`README.md` frontmatter: `sdk: docker`, `app_port: 7860`). +- **llama.cpp** loads the fine-tuned GGUF and serves chat completions — satisfies + *Llama Champion*; no external AI call satisfies *Off the Grid*. +- **Agent orchestrator** (`server/orchestrator.py`): the **OpenBMB MiniCPM** planner behind + **Run the agents** (its own llama-server) drives the Space's MCP tools as a multi-step agent — the OpenBMB + per-track prize case, and the same extraction pipeline exercised through the public tool + contract rather than private imports. +- `/ingest` (FastAPI, mounted alongside Gradio) receives messages, runs the extraction prompt, + returns candidate events; results surface in the Gradio UI for review. +- **Compute:** Q4_K_M GGUF of a 31B ≈ 18–20 GB → does **not** fit the free CPU tier (16 GB / 2 cores). + Serve on a GPU: **ZeroGPU** (free, H200/70 GB — but cold GGUF load per acquisition; document the + caveat) or a **paid GPU Space** (e.g. L4/L40S) for a smooth always-warm demo. See Fallback. + +### 3. Fine-tuning pipeline (`training/`) +- **Task:** conversation snippet → strict JSON list of events + `{title, start, end, location, attendees, reminder_minutes, notes}`. +- **Data:** build a synthetic instruction dataset (~500–2000 examples) of realistic chat threads + paired with the target JSON. Generation/augmentation for *training data* can use any tooling + offline — the "no cloud API" rule applies to the *running app's inference*, not dataset prep. + Include hard cases: relative dates ("next Thurs"), ranges, no-event chitchat (empty list), + timezones, multiple events per thread. +- **Method:** QLoRA via **Unsloth** (Qwen3-0.6B GRPO experience applies), 4-bit, r=16, + 1–3 epochs. 31B QLoRA needs an A100/H100 80 GB (Colab Pro+/RunPod/Lambda, ~hours). +- **Export:** merge LoRA → `convert_hf_to_gguf.py` (llama.cpp) → `llama-quantize` to Q4_K_M → + **publish GGUF to your HF repo** (satisfies *Well-Tuned*). Space downloads it at startup via + `huggingface_hub`. + +### 4. Custom Gradio UI (`ui/`, `static/`) — *Off-Brand* +- `gr.Blocks` with a custom `gr.themes.Base(...)` palette + injected `css=` (custom fonts, layout, + cards) to push well past the default look. +- Screens: connection/status, incoming-message feed, **review queue** (edit candidate events + inline, approve/reject), download `.ics`, optional "Push to Google Calendar" toggle, settings. + +### 5. Calendar output (`calendar_out/`) +- **`.ics` (default, off-grid):** generate with the `icalendar` lib; offer as a download in the UI. +- **Google Calendar (optional bonus):** `google-api-python-client` OAuth; behind a toggle so the + off-grid demo path stays pure. Clearly labeled as the one optional cloud touchpoint. + +--- + +## Hackathon requirement mapping + +| Track | How it's satisfied | +|---|---| +| Off the Grid (local-first, no cloud AI APIs) | All inference is local llama.cpp in the Space; data originates on your Mac; `.ics` is the default output. | +| Well-Tuned (fine-tuned model on HF) | QLoRA fine-tune of `gemma-4-31B-it`, GGUF published to your HF repo. | +| Off-Brand (custom UI) | Custom Gradio theme + CSS, not the stock look. | +| Llama Champion (llama.cpp) | Inference via `llama-cpp-python`. | +| Gradio app on HF Space | Docker Space serving Gradio + FastAPI `/ingest`. | + +--- + +## Build phases + +1. **Hero path (no GPU):** Docker Space with custom-themed Gradio + the *stub* extractor → paste / + "Try a sample" / screenshot → review → `.ics` download, working end-to-end on a phone browser. + This is the parent's whole experience and must stand alone with no model. +2. **Measure impact:** persisted **This week** panel (events captured, conflicts caught, minutes + saved) via `server/impact.py`, recorded when the parent exports. Proves *their* day got better. +3. **Accuracy upgrade (optional):** wire `llama-cpp-python` with a community `gemma-4-31B-it` GGUF on + a GPU Space; swap the stub for the model + JSON-schema prompt + pydantic validation. +4. **Fine-tune (optional):** dataset → Unsloth QLoRA → GGUF → publish to HF → point the Space at it. +5. **Optional auto-feed:** Mac `collector.py` reading `chat.db` → POST `/ingest` (power users only). + +--- + +## Verification + +- **End-to-end (stub, phase 1):** open Space in phone browser → tap **Try a sample** (or paste a + chat) → event appears in review queue → download `.ics` → import to a calendar, confirm date/time. +- **Impact (phase 2):** after exporting, **Activity → This week** shows events captured and time + saved > 0; restart the app (same `IMPACT_PATH`) and confirm the weekly numbers persist while the + live tiles reset. `minutes_saved` is a stated estimate (`IMPACT_MIN_PER_EVENT`=8, + `IMPACT_MIN_PER_CONFLICT`=15, env-overridable), not a measurement. +- **Collector (phase 2):** send yourself a test iMessage ("lunch Tuesday 1pm") → confirm it reaches + `/ingest` and surfaces in the feed. +- **Model (phase 3+):** curated eval set of chats with known expected events; measure JSON validity + rate + field accuracy (esp. relative-date resolution); confirm empty-list on non-event chats. +- **llama.cpp:** confirm the Space logs show llama.cpp loading *your* GGUF, no external AI calls. + +--- + +## Risks & fallbacks + +- **31B serving cost/latency.** Q4 31B needs a GPU; ZeroGPU has cold-load + quota friction, paid GPU + has cost. **Fallback:** fine-tune **Gemma 4 E4B** (edge variant) — runs on free CPU tier / fast on + small GPU, far cheaper to fine-tune, and arguably *more* on-theme for "local-first." Keep 31B as + the headline, E4B as the safety net for a reliable live demo. +- **`chat.db` schema / `attributedBody`.** Mitigated by using `imessage-exporter`/`imessage_reader`. +- **Full Disk Access** must be granted to the collector's process or reads return empty. +- **Privacy:** the autonomous Mac-collector path sends messages to the Space (token-gated); the + hero phone-paste path keeps data client-side (calendar tokens live in the browser, nothing + persists server-side). The Space now lives in the public **`build-small-hackathon`** submission + org, so the *source* is public — but user data still never lands on the server. +- **Relative-date accuracy** is the main quality risk — pass the current datetime into the prompt + and weight the dataset toward relative-date examples. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..62a058c527c8a5168f4a7ca0697fffcf64b8f71a --- /dev/null +++ b/README.md @@ -0,0 +1,435 @@ +--- +title: OffGridSchedula +emoji: 🗓️ +colorFrom: indigo +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +license: apache-2.0 +short_description: Local-first chat-to-calendar agent (Gemma-4 E4B + MiniCPM) +tags: + - track:backyard + - sponsor:openbmb + - sponsor:modal + - achievement:offgrid + - achievement:welltuned + - achievement:offbrand + - achievement:llama + - achievement:sharing + - achievement:fieldnotes +models: + - build-small-hackathon/gemma-4-cal-gguf + - openbmb/MiniCPM5-1B-GGUF +demo_video: + - https://youtu.be/m-o0u9X3tI4 +social_posts: + - https://x.com/nate_mauer/status/2065973341651882386 + - https://x.com/nate_mauer/status/2064920352845709419 + - https://x.com/nate_mauer/status/2065661878441750916 + - https://www.linkedin.com/feed/update/urn:li:ugcPost:7471440639969132545 +blog_post: + - https://huggingface.co/blog/build-small-hackathon/offgridschedula +made_by: + - ParetoOptimal - a.k.a., Nate Mauer +--- + +# 🗓️ Message Scheduling Agent + + **OffGridSchedula turns a pasted chat (or a flyer screenshot) into calendar events, catches conflicts, and drafts the reply — right from your phone, no app, no account, +no setup. iOS allows neither background iMessage access nor a persistent on-device LLM server, so there's no autonomous on-device agent to install; instead, +a foreground Shortcut ([docs/automations.md](./docs/automations.md)) hands a thread or screenshot to the agent in two taps (optionally using a remote model via `INFERENCE_BASE_URL`).** + +The model runs on **your own server or even on the phone itself** and not on a cloud AI service. Your chats aren't shipped off to a third-party AI to be read; agent reads your snippet in memory and +discards it after replying. The run trace you can optionally share is a redacted, sent to the agent you control that turns it into ready-to-add calendar events. + +**Hardware-aware.** With under-powered hardware, the app warns users with an upgrade banner rather than hanging, the real model needs a tiny GPU. + +## Build Small submission — the idea & the tech + +**The idea.** A busy parent's calendar lives in other people's messages — picture day in the +class chat, the practice that moved, the party flyer. OffGridSchedula turns those into calendar +events: paste the chat (or snap the flyer) from a phone browser, review the extracted events, the +conflicts against your own `.ics`, and a drafted reply — then add to Apple/Google Calendar in a tap. + +**The tech.** Two small local models do the work. Extraction is [`gemma-cal` E4B](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) +(~4B effective params), our QLoRA fine-tune of Gemma-4 E4B that emits a single validated +**ActionPlan** (events · conflicts · reply · clarifying question), served with **vision** through +the official **llama.cpp** server inside this Docker Gradio Space — no cloud AI APIs. The +fine-tune + its 60-example task eval ran entirely on **Modal** serverless GPUs, behind an +eval gate that rejected eight regressed models before this one shipped. Conflict math is +deterministic Python, the UI is fully custom, the agent doubles as an **MCP tool server**, and +redacted run traces are public on the [Hub](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces). +Click **Run the agents** and a local **OpenBMB MiniCPM** planner (a second local llama-server) +drives this same Space's MCP tools as a multi-step agent — extract → check conflicts → render +`.ics` — with every step visible. Still zero cloud AI; every model under 32B. + +**What's new.** Extraction now reads the *logistics*, not just the date (see below): arrival-aware +start times, duration→end conversion, type-based reminders, and calendar-ready titles — each +guaranteed by deterministic post-processing even when the model wobbles, and each shipped through +a measured A/B eval ([full result tables](./training/data/ab_results.md): regex vs text-LLM vs +**vision-LLM reading rendered screenshots only**). Calendar out got one-click too: a unified +**Connect your calendar** block (Google OAuth — the token lives in *your* browser, never on the +server; Outlook/Apple need no sign-in) and per-event **Google · Outlook · iCal** links, with the +Google push verified end-to-end (push → readback → delete, 11/11). +**The UX.** One decision — **Offline or Online** — re-themes the whole workflow card and sets the +path: off-grid `.ics` only, or a **one-click "Connect your calendar"** whose Google OAuth token +lives *only in the browser* (server-verified each visit; the client secret never leaves the +server). Results land in a single card: events, conflicts, the drafted reply, and per-event +**Google · Outlook · iCal · .ics** quick-add links. **Activity → This week** tallies events +captured, conflicts caught, and time saved; a per-device **Memory** (localStorage, one-click +samples) feeds names and preferences back into extraction. + +**Submission links:** [requirement-by-requirement mapping](./docs/build-small-submission.md) · +[demo video](https://youtu.be/m-o0u9X3tI4) · +social posts [1](https://x.com/nate_mauer/status/2064920352845709419) · +[2](https://x.com/nate_mauer/status/2065661878441750916) + +## Who this is for + +A busy parent whose kid's school and activity events are buried in a noisy class group chat — +picture day Thursday, the practice that moved to Tuesday, the birthday-party RSVP. They read it once, +mean to add it later, and miss it. With this, they **paste the chat** (or a **screenshot** of a flyer +or invite) from their phone's browser and get back: the events, a **conflict check** against their +calendar, and a **ready-to-send reply** — all surfaced for review before anything is saved. Output is +a local `.ics` they can add to any calendar, with optional Google Calendar push. + +No app to install and no account. It reads nothing automatically — the parent pastes only what they +choose. Inference runs **in the Space** via `llama.cpp` (no cloud AI APIs), and works out of the box +with no GPU (see *Accuracy upgrade* below). + +## The model: `gemma-cal` E4B — one calendar-native LLM, built for exactly this + +What makes this platform different isn't a prompt wrapped around a generic chatbot — it's +**[`gemma-cal` E4B](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf), our own fine-tune of +Gemma-4 E4B purpose-built for one job: turning messy human conversation into calendar-ready +structure.** The model doesn't chat. It reads a thread (or a flyer photo) and emits a single +validated **ActionPlan** — events with exact ISO datetimes, conflicts, proposed alternatives, a +drafted reply, and a clarifying question when the plan is too vague to schedule. **It is the one +and only model the platform runs**, everywhere from the production Space to a laptop. + +- **Edge-sized by design.** ~5 GB at Q4 — serves on a **~$0.40/hr 16 GB T4** (vs $4+/hr A100-class + for big models), a gaming GPU, or an Apple-silicon laptop, with full **vision** + (screenshots/flyers) via its mmproj. Local-first isn't a tagline; it's the parameter count. +- **Schema-bulletproof.** The fine-tune holds **100% schema validity even with no system prompt**, + with stronger no-event discipline (doesn't invent events from "thanks!") and a higher rate of + *asking* when a date is TBD — the failure modes that actually burn users of generic models. +- **Convention-trained.** It learns *this product's* date semantics ("next Tuesday" means next + week's Tuesday; weekday-anchored relative dates) instead of whatever a base model absorbed + from the internet. +- **Eval-gated, never vibes-shipped.** Every retrain runs a 60-example task eval (start-exact + datetime matching, F1, validity, clarification) and **cannot reach production unless it clears + the gate** — the pipeline has rejected eight regressed models to date. The full, honest scorecard + lives in [`docs/eval-roadmap.md`](./docs/eval-roadmap.md) and the + [post-mortem write-up](./docs/blog-eval-gated-finetuning.md). + +**Hackathon size constraint (≤ 32B):** easily — E4B is ~4B effective parameters. See the in-app +**🏆 Submission** tab for the full compliance scorecard. + +### Reads the logistics, not just the date + +A confirmation like *"Time: 10:30 AM · Duration: approx. 30–45 min · (Please arrive 15 minutes +early to complete intake forms) · 📍 112A West 72nd Street…"* becomes one correct event: + +- **Arrival-aware start** — the event starts at **10:15** (when you must show up), the official + 10:30 is preserved in the notes, and the **end is anchored to the stated time + duration** + (11:00), so the calendar block covers the forms *and* the visit. +- **Type-based notifications** — an explicitly stated lead time always wins ("remind me 2 hours + before" → 120); otherwise doctor/medical visits get 60 minutes, parties 30, carpools and school + events 45. +- **Real-world addresses** — multi-line and 📍-emoji locations join into one string; + "(Upper West Side — 72nd & Columbus)" glosses and SMS footers ("Reply C to confirm… call us + at 212-223-0349") don't confuse it. +- **Calendar-ready titles** — an action+subject summary ("Pick up Priya — Terminal 4"), not a + quote of the message. + +The model is *taught* these conventions (prompt + fine-tune data), but the load-bearing ones are +also **guaranteed by deterministic post-processing** (`apply_text_rules` in +[`server/agent.py`](./server/agent.py)) — same philosophy as the conflict engine: must-hold +logistics are never left to model temperament. Every behavior above shipped through a measured +A/B eval — regex baseline vs text-LLM vs **vision-LLM reading rendered chat screenshots only** — +with the full tables in [`training/data/ab_results.md`](./training/data/ab_results.md) +(headline: text-LLM event F1 0.96 structured / 0.89 unstructured vs regex 0.60/0.67; the +screenshot-only vision arm lands within a point of text). + +## Try it in 30 seconds + +Open the Space in your phone's browser → **Schedule** tab → tap **Try a sample** (or paste your own +group chat, and optionally a screenshot or your `.ics`) → review the detected events → **Download +.ics**. The **Activity → This week** panel then shows what you've captured and the time it saved. + +## How it works + +``` +Paste a thread / screenshot ──▶ HF Space ──▶ llama.cpp ──▶ events + conflicts + reply + (phone browser) │ │ + custom Gradio UI ◀── review ──┐ ┌────┘ + ▼ ▼ + .ics download / optional Google Calendar +``` + +The **primary path needs nothing but a browser**: paste text and/or attach a screenshot in the +Schedule tab. (Power users can also auto-feed messages from a Mac — see *Optional: Mac collector*.) + +For the full solution-architecture view — every workflow and which LLM (if any) it calls, +plus the eval-gated fine-tuning loop — see **[docs/architecture.md](./docs/architecture.md)**. + +## Can it process multiple invites at once? + +**Yes — multiple invites in one paste is the designed path** (on the live Space, where the real +model runs). `ActionPlan.events` is a *list*, and the extraction prompt explicitly tells the model +that one thread often holds several events — a drop-off AND a pickup, or two appointments, are +separate events (`server/agent.py`). Everything downstream is built for N events: the results card +shows "*N events found*" with one card per invite, the editable table gets one row each, the `.ics` +contains one `VEVENT` per event, each event carries its own Google/Outlook/Apple quick-add links, +and the conflict check runs across all of them. Screenshot input is multi-file too — attach several +flyers and they're all read in one run. + +Two caveats: + +- **Stub mode extracts only the first invite.** The local-dev heuristic (`_stub_plan` in + `server/agent.py`, enabled by `USE_STUB_EXTRACTOR=1`) works with no model and no GPU — and it's + now a decent parser in its own right (labeled times, explicit dates, multi-line/📍 locations, + durations, arrival-early shifts, type-based reminders) — but it still returns at most **one** + event. If you paste a multi-invite thread locally and get one event back, that's the stub, not + the product; the deployed Space uses the multi-event model path. +- **Simultaneous *runs* are serialized, not parallel.** If two users (or two tabs) hit *Run the + agents* at once, both complete, but inference executes one request at a time — `server/model.py` + holds the llama.cpp instance behind a `threading.Lock`, and Gradio queues the events. On a + single-GPU Space that's intentional (one model copy in memory); the second run simply waits its + turn, then streams its own pipeline progress. + +## Repo layout + +``` +app.py # Gradio + FastAPI entrypoint (the Space) +server/ + agent.py # thread (+images) -> validated ActionPlan + orchestrator.py # Run the agents: MiniCPM planner driving our own MCP tools + schema.py # Event / Conflict / ActionPlan pydantic models + model.py # llama.cpp load: GGUF + vision mmproj, constrained JSON + imageutil.py # image -> base64 data URI +ui/blocks.py # custom Gradio Blocks (reasoning, events, conflicts, reply) +static/app.css # custom CSS (Off-Brand) +calendar_out/ + ics.py # .ics generation (off-grid default) + freebusy.py # parse existing .ics + deterministic conflict detection + gcal.py # optional Google Calendar push +collector/collector.py # Mac-side iMessage collector (text + image attachments) +training/ # dataset build + QLoRA fine-tune + GGUF/mmproj export +Dockerfile # dedicated-GPU Space: builds llama.cpp (0.3.28) WITH CUDA +requirements-docker.txt # runtime deps for the Docker image (llama.cpp built separately) +PLAN.md # full design + build plan +``` + +## Quick start (local dev) — no GPU needed + +```bash +pip install -r requirements.txt + +# Runs the whole app with the built-in heuristic agent — no model, no GPU: +export USE_STUB_EXTRACTOR=1 INGEST_TOKEN="dev-secret" +python app.py # http://localhost:7860 +``` + +Open it, go to the **Schedule** tab, and tap **Try a sample** — or paste a thread, attach chat +**screenshots**, and optionally upload your current calendar **`.ics`** for conflict checks. +(Heads-up: the stub agent extracts only the **first** invite in a thread — multi-invite extraction +needs the real model; see *Can it process multiple invites at once?* above.) Tip for +self-hosted installs: set `CAL_ICS_PATH=/path/to/calendar.ics` and conflict checks use that file +automatically whenever no `.ics` is uploaded — step 4 completes itself, fully offline. Review +the detected events, conflicts, proposed times, and the suggested reply, then add any event with +its **Add to: Google · Outlook · iCal · .ics** links (iCal and .ics both download the event's +`.ics` file; with 2+ events an **iCal — all N events** link grabs everything at once). +The **Activity → This week** panel shows what you've captured. + +## This week (impact) + +The Activity tab has a **This week** panel that persists across restarts: **events captured**, +**conflicts caught**, and **estimated time saved**. A "capture" is counted when a run surfaces +events for review (adding to a calendar happens through the per-event links, which the server +can't observe). + +`minutes_saved` is a deliberately conservative, **configurable estimate — not a measurement**: +`IMPACT_MIN_PER_EVENT` (default **8** min per captured event) + `IMPACT_MIN_PER_CONFLICT` (default +**15** min per conflict caught). Override either via env. State persists to `IMPACT_PATH` +(default `/tmp/impact_weeks.json`; point it at a persistent disk on a Space to survive rebuilds). + +## Accuracy upgrade (optional) — serve the real `gemma-cal` LLM + +The stub agent above makes the demo work with **no GPU**. The production Space serves our +fine-tuned **`gemma-cal` E4B** through `llama-server` — no cloud AI APIs either way. The same +config works anywhere llama.cpp runs: + +```bash +export USE_STUB_EXTRACTOR=0 +export MODEL_HF_REPO="build-small-hackathon/gemma-4-cal-gguf" +export MODEL_FILE="gemma-cal-e4b-Q4_K_M.gguf" # ~5 GB edge fine-tune (what the Space serves) +export MMPROJ_REPO="unsloth/gemma-4-E4B-it-GGUF" # the E4B's own vision projector +export MMPROJ_FILE="mmproj-F16.gguf" # enables screenshot/vision input +bash scripts/start_space.sh +``` + +This is the platform's **only** model — the same ~5 GB GGUF serves the production Space (16 GB +T4), a gaming GPU, or a laptop. (`MODEL_FILE` is explicit on purpose: the model repo also stores +legacy training artifacts, so the `-hf repo:Q4_K_M` shorthand is ambiguous.) + +## Optional: Mac collector (power users) + +The phone-paste path above needs nothing installed. If you'd rather have new iMessages fed in +automatically, run the collector on a Mac where iMessages sync (iOS exposes no API for message +content, so a Mac is the only auto-feed source): + +```bash +cd collector && cp .env.example .env # edit SPACE_URL + INGEST_TOKEN +python collector.py +``` + +> ⚠️ The collector needs **Full Disk Access** (System Settings → Privacy & Security) to read `chat.db`. + +## Autonomous & on a phone + +There's a single backend endpoint — **`POST /agent`** (bearer `INGEST_TOKEN`) — that takes a thread +(or messages, + optional screenshot/`.ics`) and returns the extracted events, conflicts, and reply as +JSON (optionally an `.ics` or a Google Calendar push). Every front-end calls it: + +- **Fully autonomous (Mac) — set-and-forget:** `INGEST_TOKEN=… MODEL_GGUF=~/models/hermes.gguf + scripts/setup_mac.sh` installs three launchd jobs (Hermes `llama-server` + autonomous backend + + collector). New iMessages **you send or accept** become calendar events automatically, deduped per + chat. Triggers on outgoing messages by default (`TRIGGER_ON=outgoing`; `any` to widen). +- **Hermes "grows-with-you" brain:** point `INFERENCE_BASE_URL` at a Hermes `llama-server`; its + personal **memory** (people→roles, "you decline Mondays") improves extraction over time and is shown + in the dashboard **Memory** tab. See **[docs/hermes.md](./docs/hermes.md)**. +- **iPhone, one tap:** an iOS **Shortcut** shares a thread/screenshot to `/agent` and adds the events + to Apple Calendar natively — no `.ics` import. +- **Android, hands-off:** a Tasker/MacroDroid rule on a notification/SMS calls `/agent` and inserts + events. See **[docs/android-tasker.md](./docs/android-tasker.md)**. +- **On-device model:** set `INFERENCE_BASE_URL` to a local `llama-server` (e.g. Gemma **E4B** or a + small Hermes in Termux) so inference runs *on the phone* — same agent, env-selected. + +> **iOS can't read iMessage in the background** (no message API), so fully-autonomous iMessage needs +> the Mac collector; the iPhone path is one-gesture. See **[docs/automations.md](./docs/automations.md)** +> and **[docs/on-device.md](./docs/on-device.md)**. + +## Build Small — prizes & quests + +**Track: 🏡 Backyard AI** (`track:backyard`) — a practical app for a specific real person: a busy +parent whose family calendar is buried in a noisy class group chat. + +### Sponsor awards we compete for + +| Award | Why this submission qualifies | +|---|---| +| 🟢 **Modal Awards** (best Modal-powered apps) | **Modal powered the development of the platform's model end-to-end** — required note, gladly given: [`training/modal_train.py`](./training/modal_train.py) (QLoRA fine-tune on serverless A100/H100s, Volumes caching weights), [`training/modal_eval.py`](./training/modal_eval.py) + [`modal_quant_eval.py`](./training/modal_quant_eval.py) (the task eval served on llama.cpp inside Modal, incl. an f16/Q8_0/Q4_K_M quantization study and the regex/text/vision A/B harness), and [`training/gated_retrain.py`](./training/gated_retrain.py) (train → staging → eval → promote *only past the gate* — eight regressed models rejected, every run a Modal job). | +| 🌱 **OpenBMB Awards** (standout MiniCPM builds, per track) | The **agent is planned by OpenBMB MiniCPM** (`openbmb/MiniCPM4.1-8B-GGUF`, Q4; the 1B variant is a config switch) on a second local llama-server, driving this Space's own MCP tools (`extract_events → check_conflicts → make_ics`) as a visible multi-step agent ([`server/orchestrator.py`](./server/orchestrator.py)). MiniCPM is the agent's brain, not a garnish. | + +*(Not claimed: the OpenAI Track — no Codex-attributed commits — and the NVIDIA Nemotron Quest — +different model family. We'd rather be honest than eligible.)* + +### Special awards — our case + +| Award | Our case | +|---|---| +| 🎖️ **Bonus Quest Champion** | All **six** collectable quests claimed with evidence — the full sash (table below). | +| 🎨 **Off-Brand Award** | Custom landing page, hero + carousel, grouped nav, bespoke results cards and Activity dashboard — [`ui/blocks.py`](./ui/blocks.py) + [`static/app.css`](./static/app.css), far past the stock Gradio look. | +| 🐜 **Tiny Titan** | The platform's one and only model is **Gemma E4B — ~4B *effective* parameters** (~5 GB at Q4, serves on a 16 GB T4 or a laptop), and a 1B MiniCPM planner variant is a config switch. Honest framing: E4B is a MatFormer "effective-4B" — judges' call whether that's tiny enough. | +| 🎬 **Best Demo** | App + demo video + social post as one package — storyboard with every quest named on-camera in [`docs/demo-script.md`](./docs/demo-script.md). | +| 🤖 **Best Agent** | The MiniCPM-planned, MCP-tool-driven agent above — real multi-step tool use, every model under the 32B cap. | +| 🃏 **Judges' Wildcard** | No entry needed — but if "eval-gated fine-tuning with a public failure post-mortem" fits no category, we know where to find you. | + +### Collectable quests — all six claimed + +| Quest | Evidence | +|---|---| +| 🔌 **Off the Grid** (local-first, no cloud APIs) | All inference is llama.cpp inside the Space; the only optional outbound call is the user's own Google Calendar push. | +| 🎯 **Well-Tuned** (published fine-tune) | [`gemma-cal` E4B](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) — our QLoRA fine-tune **is the model production serves**, shipped through the eval gate with the [honest scorecard public](./docs/eval-roadmap.md). | +| 🎨 **Off-Brand** (custom UI) | See the Off-Brand Award case above. | +| 🦙 **Llama Champion** (llama.cpp runtime) | The official `ghcr.io/ggml-org/llama.cpp` server image runs the GGUF + vision mmproj ([`Dockerfile`](./Dockerfile), [`scripts/start_space.sh`](./scripts/start_space.sh)). | +| 📡 **Sharing is Caring** (open trace on the Hub) | Redacted agent traces published to [`ParetoOptimal/offgridschedula-traces`](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces) — one click from the Activity tab. | +| 📓 **Field Notes** (write-up) | [`FIELD_NOTES.md`](./FIELD_NOTES.md) + the [eval-gated fine-tuning post-mortem](./docs/blog-eval-gated-finetuning.md) + [project blog](https://huggingface.co/blog/build-small-hackathon/offgridschedula). | + +## Fine-tune on Modal (GPU) + +`training/modal_train.py` runs the whole fine-tune on a serverless GPU and publishes the GGUF to +HF — no local GPU needed. It's a thin wrapper that ships this repo to Modal and runs the existing +pipeline (`make_dataset.py` → `train_qlora.py` → `export_gguf.sh`) on an A100/H100, then uploads the +quantized GGUF + `mmproj` to your HF repo. This is all *offline* prep, so **Off the Grid** is +untouched (the rule applies to the running app's inference, not dataset/training prep). + +```bash +pip install modal +modal token new +modal secret create huggingface HF_TOKEN=hf_xxxxxxxx # your HF *write* token + +# Validate the full pipeline cheaply first (cheap edge model, ~a couple $): +modal run training/modal_train.py --base-model google/gemma-4-E4B-it + +# Then the real run (default A100-80GB; --gpu H100 for speed): +modal run training/modal_train.py +modal run training/modal_train.py --gpu H100 --num-epochs 3 +``` + +On finish it prints the `MODEL_REPO` / `MODEL_FILE` / `MMPROJ_FILE` to set on the Space. Two +persistent Modal Volumes cache the base-model download and the outputs across runs, so iterating on +`training/data/dataset.jsonl` only re-pays for the training itself. + +> Cost (A100-80GB ≈ $2.5/hr, per-second billing): a few-hundred-to-2000-example QLoRA run is +> ~1–3 hr ≈ $5–15, so ~$250 of credit ≈ 15–40 full iterations. Expand the dataset before the +> first real 31B run — the seeds in `make_dataset.py` are a smoke test, not a training set. + +### Publish your fine-tune & point the Space at it + +The training run is the one step that spends **your** GPU/Modal credits — it's not done for you. +Once you've run it, the path is turnkey: + +1. **Recommended:** `python training/gated_retrain.py` — train → staging upload → 60-example eval → + **promote only if it beats the gate**. A regressed model cannot reach production. (Raw + `modal run training/modal_train.py` is the ungated equivalent for experiments.) +2. Point the Space at *your* model via **Space variables** (`scripts/start_space.sh` reads them at + launch; set in *Settings → Variables* or with `HfApi().add_space_variable`): + ``` + MODEL_HF_REPO = /gemma-cal-gguf + MODEL_FILE = gemma-cal-e4b-Q4_K_M.gguf # explicit file — repo may hold several quants/tiers + MMPROJ_REPO = unsloth/gemma-4-E4B-it-GGUF # projector repo, if different from the LLM's + MMPROJ_FILE = mmproj-F16.gguf # enables screenshot/vision input + ``` + The deploy workflow stays a plain git mirror — the model is pulled at runtime, never committed. +3. Push to `main` → CI deploys → the Space now serves your fine-tune (**Well-Tuned**). + +## Share a trace (Sharing is Caring) + +Want others to learn from a run? In the **Activity** tab, click **⬇ Download trace (JSON)** — the +trace stays on your device, and the hosted Space holds **no Hub token**. Personal data is redacted by +default (the activity log only carries counts + status; the one chat-name field is stripped). Then +publish it from your own machine, with your own login: + +```bash +huggingface-cli login # or export HF_TOKEN=... +python training/share_trace.py trace.json --public # -> a HF dataset repo of traces +``` + +## Field notes + +[**FIELD_NOTES.md**](./FIELD_NOTES.md) is the build retrospective — the iOS→`chat.db` pivot, the +`attributedBody` trap, why conflict math is deterministic, stub-first architecture, the +reframe-around-one-person lesson, and the Off-the-Grid trade-offs. + +## Remote automation (runs without an interactive session) + +| Workflow | Trigger | What it does | Needs | +|---|---|---|---| +| `.github/workflows/ci.yml` → **test** | push / PR | compile + `pytest` (stub mode, no GPU) | nothing | +| `.github/workflows/ci.yml` → **deploy** | push to `main`, after tests pass | `huggingface-cli upload` the repo to the HF Space (Gradio SDK; model excluded, pulled at runtime) | secret `HF_TOKEN`, var `SPACE_ID` | +| `.github/workflows/maintenance.yml` | daily + manual | ping the Space `/health`, audit outdated deps → open/update a GitHub issue | var `SPACE_HEALTH_URL` | + +One-time setup for deploy + monitoring: + +```bash +gh secret set HF_TOKEN # HF write token +gh variable set SPACE_ID -b "/" +gh variable set SPACE_HEALTH_URL -b "https://-.hf.space/health" +``` + +CI installs `requirements-ci.txt` (excludes `llama-cpp-python` and the Google libs — both are +imported lazily and not needed for the stub-mode tests). A weekly Claude `/schedule` routine handles +the judgment work (grow `training/data/dataset.jsonl` → PR, triage CI failures). diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..f8d63a8a1f5f05da025752742da51b5a2b317f42 --- /dev/null +++ b/app.py @@ -0,0 +1,298 @@ +"""Space entrypoint: Gradio UI + FastAPI /ingest, served together on one port.""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import gradio as gr +import uvicorn +from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from pydantic import BaseModel + +from server import dedup, events, health, threads +from server.pipeline import AgentRequest, AgentResponse, run_pipeline +from ui.blocks import CAROUSEL_JS, CSS, THEME, build_demo + +INGEST_TOKEN = os.environ.get("INGEST_TOKEN", "") +FEED_PATH = Path(os.environ.get("FEED_PATH", "/tmp/ingest_feed.json")) +MAX_FEED = 200 +# Opt-in: run the agent automatically on each new message (front-end A). Off by +# default, so /ingest keeps its store-only behavior unless explicitly enabled. +AUTONOMOUS = os.environ.get("AUTONOMOUS") == "1" +# Which message direction triggers autonomous action: "outgoing" = only when YOU +# send/accept an invite (is_from_me), "any" = any new message in the chat. +TRIGGER_ON = os.environ.get("TRIGGER_ON", "outgoing").lower() + +app = FastAPI(title="iMessage Calendar Agent") + + +class IngestMessage(BaseModel): + chat: str + sender: str + text: str + timestamp: str + images: list[str] = [] # base64 data URIs of image attachments + is_from_me: bool = False # True when YOU sent it (the send/accept trigger) + + +class IngestBatch(BaseModel): + messages: list[IngestMessage] + + +def _load_feed() -> list[dict]: + try: + return json.loads(FEED_PATH.read_text()) + except Exception: # noqa: BLE001 missing/corrupt -> empty + return [] + + +def _append_feed(items: list[dict]) -> None: + feed = (_load_feed() + items)[-MAX_FEED:] + FEED_PATH.write_text(json.dumps(feed, indent=2)) + + +def _require_token(authorization: str) -> None: + if not INGEST_TOKEN or authorization != f"Bearer {INGEST_TOKEN}": + raise HTTPException(status_code=401, detail="bad token") + + +def _run_autonomous(chats: set[str]) -> None: + """For each affected chat, run the agent over its rolling thread and deliver + only the genuinely-new events (deduped). Used when AUTONOMOUS=1. + + Order matters: extract WITHOUT pushing, dedup, then push only the fresh + events. (Pushing inside the pipeline re-pushed already-captured events on + every rolling-window re-run — the exact duplicate-creation dedup exists to + prevent.)""" + feed = _load_feed() + for chat in chats: + thread = threads.rolling_thread(feed, chat) + if not thread: + continue + resp = run_pipeline(AgentRequest(thread=thread, push_gcal=False)) + # Filter WITHOUT recording: events are only marked seen once the push + # actually succeeds — recording first turns any transient push failure + # into silent, permanent event loss (filtered out on every retry). + new_events = dedup.filter_new(resp.plan.events, record=False) + if not new_events: + continue + try: + from calendar_out.gcal import push_events # lazy: google libs optional + + push_events(new_events) + except Exception as e: # noqa: BLE001 push failure must not kill the loop + events.emit("calendar", + f"autonomous push failed (will retry next run): " + f"{type(e).__name__}: {e}", + level="error") + continue # NOT marked seen -> retried on the next trigger + dedup.mark_seen(new_events) + events.emit( + "decision", + f"autonomous: {len(new_events)} new event(s) in {chat}", + events=len(new_events), + ) + + +@app.post("/agent", response_model=AgentResponse) +def agent(req: AgentRequest, authorization: str = Header(default="")): + """Run the agent on a thread (or messages) and return an ActionPlan. + + The shared contract every front-end calls (iOS Shortcut, Android Tasker, the + Mac collector). Stateless — see server/pipeline.run_pipeline. + """ + _require_token(authorization) + return run_pipeline(req) + + +@app.post("/ingest") +def ingest(batch: IngestBatch, background_tasks: BackgroundTasks, + authorization: str = Header(default="")): + """Receive new messages from the Mac collector (bearer-token protected). + + Returns immediately — autonomous runs (full LLM inference, potentially + minutes per chat) happen in a background task. Running them inline blew + the collector's 30s POST timeout, which skipped _save_rowid and re-sent + the same batch every poll (duplicate feed entries + duplicate runs).""" + _require_token(authorization) + items = [m.model_dump() for m in batch.messages] + _append_feed(items) + n_imgs = sum(len(m.images) for m in batch.messages) + chats = sorted({m.chat for m in batch.messages}) + events.emit("ingest", f"{len(items)} msg(s) from {', '.join(chats) or '—'}", images=n_imgs) + if AUTONOMOUS: + # Trigger on YOUR sent/accepted messages by default; "any" widens it. + if TRIGGER_ON == "any": + trigger_chats = set(chats) + else: + trigger_chats = {m.chat for m in batch.messages if m.is_from_me} + if trigger_chats: + background_tasks.add_task(_run_autonomous, trigger_chats) + return {"received": len(items)} + + +@app.get("/health") +def health_route(): + # Liveness + hardware-adequacy (device/model/degraded/reason). The on-page + # status banner and the maintenance monitor both read this. + return health.health_status() + + +# --- Per-user Google Calendar OAuth (web flow) ----------------------------- # +def _oauth_redirect_uri(request: Request) -> str: + """Public redirect URI. On a Space, SPACE_HOST is the public host; locally, + fall back to the request's base URL. Must match the Google client config.""" + host = os.environ.get("SPACE_HOST", "").strip() + base = f"https://{host}" if host else str(request.base_url).rstrip("/") + return base.rstrip("/") + "/oauth2callback" + + +@app.get("/oauth2/start") +def oauth2_start(request: Request): + """Kick off the Google consent flow (opened as a popup from the UI).""" + from calendar_out import gcal + + try: + url, _state = gcal.auth_url(_oauth_redirect_uri(request)) + except Exception as e: # noqa: BLE001 not configured -> friendly page + return HTMLResponse( + f"

Google Calendar isn't " + f"configured on this Space.
{e}

", + status_code=503, + ) + return RedirectResponse(url) + + +@app.get("/oauth2callback") +def oauth2_callback(request: Request): + """Google redirects here after consent. Exchange the code for a per-user token, + hand it to the opener window (and localStorage), then close. The token is NOT + stored server-side.""" + code = request.query_params.get("code") + if request.query_params.get("error") or not code: + return HTMLResponse( + "

Google connection cancelled. " + "You can close this window.

" + ) + from calendar_out import gcal + + try: + token_json = gcal.exchange_code( + _oauth_redirect_uri(request), code, request.query_params.get("state", "") + ) + except Exception as e: # noqa: BLE001 + return HTMLResponse( + f"

Couldn't complete Google " + f"sign-in.
{e}

" + ) + tok_js = json.dumps(token_json) # JS string literal of the token JSON + return HTMLResponse( + "" + "" + "

✅ Google Calendar connected. You can close this window.

" + "" + ) + + +class TokenCheckBody(BaseModel): + token: str + + +@app.post("/oauth2/check") +def oauth2_check(body: TokenCheckBody): + """Liveness-check a browser-held Google token with one real API call + (same-origin fetch from wireGcal on page load). POST so the token never + lands in access logs; it is checked and discarded, never stored. + 200 = definitive verdict; non-200 = indeterminate (client keeps its + local shape-check state).""" + from calendar_out import gcal + + try: + gcal._client_config() # mirror /oauth2/start: friendly 503 when env unset + except Exception as e: # noqa: BLE001 + return JSONResponse( + {"ok": False, "transient": True, "reason": str(e)}, status_code=503 + ) + res = gcal.check_token(body.token) + out: dict = {"ok": res["ok"]} + if res["ok"]: + if res.get("refreshed_token"): + out["token"] = res["refreshed_token"] + else: + out["reason"] = res.get("reason", "") + out["transient"] = bool(res.get("transient")) + return out + + +# Register the @spaces.GPU functions at startup so ZeroGPU can schedule them. +import server.model # noqa: E402,F401 + +demo = build_demo() + +# Serving mode, env-selected: +# - "gradio": the HF *Gradio-SDK* / ZeroGPU platform manages the launch (a self-run +# uvicorn gets SIGTERM'd there), so we call demo.launch(). /agent etc. aren't served. +# - "uvicorn": mount gradio under FastAPI and serve UI + /agent + /ingest on one port. +# Used locally and on the *Docker-SDK* GPU Space (Dockerfile sets SERVE=uvicorn). +# Default: gradio on a Space unless told otherwise, uvicorn locally. +_default_serve = "gradio" if (os.environ.get("SPACE_ID") or os.environ.get("SYSTEM") == "spaces") else "uvicorn" +SERVE = os.environ.get("SERVE", _default_serve) + +# Gradio 6 applies theme/css at mount/launch time — the css set on gr.Blocks is +# IGNORED when mounted, so pass it here or the custom UI renders as default Gradio. +# +# The `js=` load-function does NOT reliably execute on a *mounted* (uvicorn) app in +# Gradio 6 — the carousel then sits on its first slide with dead arrows/dots. So we +# inject the carousel script as a real inline ' + # Status banner: fetch /health on load and reveal #status-banner if degraded + # (e.g. real model on CPU-only hardware). Same inline-script pattern as the + # carousel, since js= is unreliable on a mounted app; it polls for the element + # because Gradio renders it client-side after . + _BANNER_JS = ( + "(function(){fetch('/health').then(function(r){return r.json();})" + ".then(function(h){if(!h||!h.degraded){return;}(function s(){" + "var b=document.getElementById('status-banner');" + "if(!b){return setTimeout(s,400);}" + "b.textContent='\\u26a0\\ufe0f '+(h.reason||'This Space needs a GPU.')+' \\u26a0\\ufe0f';" + "b.style.display='block';})();}).catch(function(){});})();" + ) + _BANNER_INLINE = f'' + + @app.middleware("http") + async def _inject_carousel_js(request, call_next): # noqa: ANN001 + resp = await call_next(request) + if request.url.path != "/" or "text/html" not in resp.headers.get("content-type", ""): + return resp + body = b"".join([chunk async for chunk in resp.body_iterator]) + html = body.decode("utf-8", "ignore") + if "cz-inline-js" not in html and "" in html: + html = html.replace("", _CAROUSEL_INLINE + _BANNER_INLINE + "", 1) + headers = dict(resp.headers) + headers.pop("content-length", None) # body length changed; let Starlette recompute + return _Response(content=html, status_code=resp.status_code, + headers=headers, media_type="text/html") + + app = gr.mount_gradio_app( + app, demo, path="/", ssr_mode=False, theme=THEME, css=CSS, js=CAROUSEL_JS, + mcp_server=True, # expose extract_events/make_ics/check_conflicts as MCP tools + ) + +if __name__ == "__main__": + if SERVE == "gradio": + demo.launch( + server_name="0.0.0.0", server_port=7860, ssr_mode=False, + theme=THEME, css=CSS, js=CAROUSEL_JS, + mcp_server=True, # expose extract_events/make_ics/check_conflicts as MCP tools + ) + else: + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860"))) diff --git a/calendar_out/__init__.py b/calendar_out/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/calendar_out/freebusy.py b/calendar_out/freebusy.py new file mode 100644 index 0000000000000000000000000000000000000000..f1e32e1644082f357a168959972f98dd3878492c --- /dev/null +++ b/calendar_out/freebusy.py @@ -0,0 +1,141 @@ +"""Conflict detection against the user's existing calendar. + +Off-grid by default: the user uploads a current-calendar .ics; we parse it into +busy intervals and detect clashes deterministically (time math is more reliable +in code than from the model). The model still writes the reasoning + reply. +""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +from dateutil import parser as dtparser +from icalendar import Calendar +from pydantic import BaseModel + +from server import events as events_bus # aliased: 'events' is a common param name here +from server.schema import ActionPlan, Conflict, Event + +TIGHT_GAP = timedelta(minutes=30) +DEFAULT_DURATION = timedelta(hours=1) + + +class Busy(BaseModel): + start: datetime + end: datetime + title: str = "" + + +def _naive_local(dt: datetime) -> datetime: + """Aware datetimes are CONVERTED before dropping tzinfo — blindly stripping + shifted UTC-exported .ics (Google's default) by the whole UTC offset against + the model's local-time events. Conversion target = the SAME configured zone + gcal labels pushed events with (calendar_out/tzconfig), else process-local, + so conflict math and calendar pushes share one time basis.""" + if dt.tzinfo is not None: + from calendar_out.tzconfig import zone + + dt = dt.astimezone(zone()) # None -> process-local + return dt.replace(tzinfo=None) + + +def _as_dt(value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + return _naive_local(value) + try: + return _naive_local(dtparser.isoparse(str(value))) + except (ValueError, TypeError): + return None + + +def load_ics_busy(data: bytes) -> list[Busy]: + """Parse VEVENTs from an .ics into busy intervals (naive local datetimes).""" + busy: list[Busy] = [] + cal = Calendar.from_ical(data) + for comp in cal.walk("VEVENT"): + start = _as_dt(getattr(comp.get("dtstart"), "dt", None)) + if start is None: + continue + end = _as_dt(getattr(comp.get("dtend"), "dt", None)) or (start + DEFAULT_DURATION) + busy.append(Busy(start=start, end=end, title=str(comp.get("summary", "")))) + return busy + + +def _event_interval(ev: Event) -> Optional[tuple[datetime, datetime]]: + start = _as_dt(ev.start) + if start is None: + return None + end = _as_dt(ev.end) or (start + DEFAULT_DURATION) + return start, end + + +def _overlaps(a0, a1, b0, b1) -> bool: + return a0 < b1 and b0 < a1 + + +def _severity(a0, a1, b0, b1) -> Optional[str]: + if _overlaps(a0, a1, b0, b1): + return "overlap" + gap = b0 - a1 if b0 >= a1 else a0 - b1 + if gap <= timedelta(0): + return "adjacent" + if gap < TIGHT_GAP: + return "tight" + return None + + +def check_conflicts(events: list[Event], busy: list[Busy]) -> list[Conflict]: + conflicts: list[Conflict] = [] + for idx, ev in enumerate(events): + iv = _event_interval(ev) + if iv is None: + continue + a0, a1 = iv + for b in busy: + sev = _severity(a0, a1, b.start, b.end) + if sev: + conflicts.append( + Conflict(event_index=idx, clashes_with=b.title or "existing event", severity=sev) + ) + return conflicts + + +def propose_times(ev: Event, busy: list[Busy], n: int = 3) -> list[str]: + """Suggest up to n nearby start times that don't overlap busy intervals.""" + iv = _event_interval(ev) + if iv is None: + return [] + start, end = iv + duration = end - start + out: list[str] = [] + # try later today (+1h..+4h), then same time the next two days + candidates = [start + timedelta(hours=h) for h in (1, 2, 3, 4)] + candidates += [start + timedelta(days=d) for d in (1, 2)] + for c in candidates: + if not any(_overlaps(c, c + duration, b.start, b.end) for b in busy): + out.append(c.isoformat()) + if len(out) >= n: + break + return out + + +def annotate_conflicts(plan: ActionPlan, busy: list[Busy]) -> ActionPlan: + """Replace model-guessed conflicts with deterministic ones + propose times.""" + if not busy: + return plan + plan.conflicts = check_conflicts(plan.events, busy) + events_bus.emit( + "conflict", + f"{len(plan.conflicts)} conflict(s) vs {len(busy)} existing event(s)", + conflicts=len(plan.conflicts), + ) + clashing_idx = {c.event_index for c in plan.conflicts} + proposals: list[str] = [] + for idx in sorted(clashing_idx): + proposals.extend(propose_times(plan.events[idx], busy)) + # de-dupe preserving order + seen = set() + plan.proposed_times = [t for t in proposals if not (t in seen or seen.add(t))] + return plan diff --git a/calendar_out/gcal.py b/calendar_out/gcal.py new file mode 100644 index 0000000000000000000000000000000000000000..f850a6d7066939d2b4914ce929bd991adc3ff5b3 --- /dev/null +++ b/calendar_out/gcal.py @@ -0,0 +1,313 @@ +"""OPTIONAL Google Calendar push (the one optional cloud touchpoint). + +Disabled unless the user opts in via the UI toggle and provides OAuth creds. +Keeps the default .ics path strictly off-grid. +""" +from __future__ import annotations + +import json +import os +import threading +import time +from pathlib import Path + +from dateutil import parser as dtparser + +from server import events as events_bus +from server.schema import Event + +SCOPES = ["https://www.googleapis.com/auth/calendar.events"] +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" + + +# --------------------------------------------------------------------------- # +# Per-user OAuth (web flow): each visitor connects their OWN Google account. +# The OAuth *app* creds (client id/secret) are the owner's, set as Space secrets; +# the resulting per-user token is held client-side (never stored server-side) and +# passed back only to perform a push. See app.py /oauth2/start + /oauth2callback. +# --------------------------------------------------------------------------- # +def _client_config() -> dict: + """OAuth client config from env (Space secrets). Raises if unconfigured.""" + cid = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "").strip() + csecret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "").strip() + if not (cid and csecret): + raise RuntimeError( + "Google Calendar isn't configured: set GOOGLE_OAUTH_CLIENT_ID and " + "GOOGLE_OAUTH_CLIENT_SECRET (a Google Cloud OAuth 'Web application' client)." + ) + return {"web": { + "client_id": cid, + "client_secret": csecret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + }} + + +# PKCE: authorization_url() auto-generates a code_verifier (google-auth-oauthlib +# >= 1.0) and sends its challenge to Google; the token exchange must then send +# the SAME verifier or Google rejects it with "(invalid_grant) Missing code +# verifier". The start and the callback are different HTTP requests, so the +# verifier is held server-side for a few minutes, keyed by the flow's `state` +# — which doubles as the CSRF check. Single-use; nothing user-identifying. +_PENDING_TTL_S = 600 +_PENDING_MAX = 500 # bound memory if /oauth2/start is hammered +_pending: dict[str, tuple[str, float]] = {} +_pending_lock = threading.Lock() + + +def _remember_verifier(state: str, verifier: str) -> None: + now = time.time() + with _pending_lock: + for k in [k for k, (_, t) in _pending.items() if now - t > _PENDING_TTL_S]: + _pending.pop(k, None) + while len(_pending) >= _PENDING_MAX: + _pending.pop(next(iter(_pending))) + _pending[state] = (verifier, now) + + +def _pop_verifier(state: str) -> str | None: + with _pending_lock: + item = _pending.pop(state or "", None) + if item is None: + return None + verifier, t = item + return verifier if time.time() - t <= _PENDING_TTL_S else None + + +def auth_url(redirect_uri: str) -> tuple[str, str]: + """Build the Google consent URL for the calendar-events scope. Returns (url, state).""" + from google_auth_oauthlib.flow import Flow + + flow = Flow.from_client_config(_client_config(), scopes=SCOPES, redirect_uri=redirect_uri) + url, state = flow.authorization_url( + access_type="offline", include_granted_scopes="true", prompt="consent" + ) + _remember_verifier(state, flow.code_verifier) + return url, state + + +def exchange_code(redirect_uri: str, code: str, state: str = "") -> str: + """Exchange an auth code for a per-user token; returns the token as a JSON string. + + ``state`` must match a pending auth_url() call — it keys the PKCE verifier + and doubles as the CSRF check.""" + verifier = _pop_verifier(state) + if verifier is None: + raise RuntimeError( + "sign-in session expired or unknown — close this window and click " + "Connect Google Calendar again" + ) + from google_auth_oauthlib.flow import Flow + + flow = Flow.from_client_config( + _client_config(), scopes=SCOPES, redirect_uri=redirect_uri, code_verifier=verifier + ) + flow.fetch_token(code=code) + return _sanitize_token_json(flow.credentials.to_json()) + + +def _sanitize_token_json(token_json: str) -> str: + """Token JSON as handed to the BROWSER (localStorage): the OAuth app's + client_secret has no business there — the server re-injects it from env + when it needs to refresh.""" + info = json.loads(token_json) + info.pop("client_secret", None) + return json.dumps(info) + + +def _with_client_secret(info: dict) -> dict: + """Restore the env client_secret into browser-held token info so + creds.refresh() works. Older stored tokens that still carry a secret are + left untouched. The refresh endpoint is PINNED: the token JSON comes from + the browser, and a crafted token_uri would otherwise receive the injected + secret on refresh.""" + info = {**info, "token_uri": GOOGLE_TOKEN_URI} + if not info.get("client_secret"): + secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "").strip() + if secret: + info["client_secret"] = secret + return info + + +def _creds_from_token_json(token_json: str): + from google.oauth2.credentials import Credentials + + return Credentials.from_authorized_user_info( + _with_client_secret(json.loads(token_json)), SCOPES + ) + + +def _refresh_if_needed(creds) -> str | None: + """Refresh expired creds; returns sanitized token JSON to re-store client-side, + or None when no refresh happened.""" + from google.auth.transport.requests import Request + + if not creds.valid and creds.expired and creds.refresh_token: + creds.refresh(Request()) + return _sanitize_token_json(creds.to_json()) + return None + + +def _probe_events(creds) -> None: + """Cheapest real API call permitted by the calendar.events scope (the only + scope we request — calendarList/freeBusy would 403).""" + from googleapiclient.discovery import build + + build("calendar", "v3", credentials=creds).events().list( + calendarId="primary", maxResults=1, fields="items(id)" + ).execute() + + +def _is_definitive_auth_failure(e: Exception) -> bool: + """True when the token itself is dead (revoked/invalid), False for anything + that might heal on its own. Duck-typed by exception name / resp.status so + this module never has to import the google libs (absent in CI).""" + if type(e).__name__ == "RefreshError": # revoked / invalid_grant + return True + status = getattr(getattr(e, "resp", None), "status", None) # HttpError + return status in (401, 403) + + +def check_token(token_json: str) -> dict: + """Liveness check for a browser-held token: refresh if needed, then one + real (scope-compatible) API call. Three-state result so the client only + discards a token on a DEFINITIVE failure: + + {"ok": True, "refreshed_token": | None} + {"ok": False, "reason": str, "transient": bool} + """ + try: + info = json.loads(token_json or "") + if not isinstance(info, dict) or not (info.get("refresh_token") or info.get("token")): + raise ValueError("token JSON missing token/refresh_token") + except Exception as e: # noqa: BLE001 garbage in localStorage -> definitive + return {"ok": False, "reason": f"unreadable token: {e}", "transient": False} + try: + creds = _creds_from_token_json(token_json) + refreshed = _refresh_if_needed(creds) + _probe_events(creds) + return {"ok": True, "refreshed_token": refreshed} + except ImportError as e: + return {"ok": False, "reason": f"google libs unavailable: {e}", "transient": True} + except Exception as e: # noqa: BLE001 + return { + "ok": False, + "reason": f"{type(e).__name__}: {e}", + "transient": not _is_definitive_auth_failure(e), + } + + +def _dt_field(value: str) -> dict: + """Calendar API datetime field. The model emits offset-less ISO datetimes + (schema: 2026-06-10T13:00:00) and the API 400s on a naive dateTime without + a timeZone. Uses the shared zone basis (calendar_out/tzconfig — same one + freebusy compares conflicts in); with no configured zone, naive datetimes + get the process-local offset attached instead.""" + from calendar_out.tzconfig import configured_timezone + + dt = dtparser.isoparse(value) + if dt.tzinfo is not None: # already has an offset — API accepts as-is + return {"dateTime": dt.isoformat()} + tz = configured_timezone() + if tz: + return {"dateTime": dt.isoformat(), "timeZone": tz} + # interpret as process-local: attach the local offset + return {"dateTime": dt.astimezone().isoformat()} + + +def _event_body(ev: Event) -> dict: + body = { + "summary": ev.title, + "start": _dt_field(ev.start), + "end": _dt_field(ev.end or ev.start), + } + if ev.location: + body["location"] = ev.location + if ev.notes: + body["description"] = ev.notes + if ev.reminder_minutes is not None: + body["reminders"] = { + "useDefault": False, + "overrides": [{"method": "popup", "minutes": ev.reminder_minutes}], + } + return body + + +def push_events_with_token(token_json: str, events: list[Event], calendar_id: str = "primary") -> list[str]: + """Push events to the *visitor's* calendar using their per-session OAuth token.""" + from google.auth.transport.requests import Request + from googleapiclient.discovery import build + + creds = _creds_from_token_json(token_json) + if not creds.valid and creds.expired and creds.refresh_token: + creds.refresh(Request()) + svc = build("calendar", "v3", credentials=creds) + links = [] + for ev in events: + created = svc.events().insert(calendarId=calendar_id, body=_event_body(ev)).execute() + links.append(created.get("htmlLink", "")) + events_bus.emit("calendar", f"pushed {len(links)} event(s) to Google Calendar") + return links + + +def _service(): + """Build an authorized Calendar service. Requires credentials.json + token.json. + + Run an OAuth flow once locally to mint token.json; do NOT commit either file. + """ + from google.auth.transport.requests import Request + from google.oauth2.credentials import Credentials + from google_auth_oauthlib.flow import InstalledAppFlow + from googleapiclient.discovery import build + + creds = None + if os.path.exists("token.json"): + creds = Credentials.from_authorized_user_file("token.json", SCOPES) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES) + creds = flow.run_local_server(port=0) + with open("token.json", "w") as f: + f.write(creds.to_json()) + return build("calendar", "v3", credentials=creds) + + +def push_events(events: list[Event], calendar_id: str = "primary") -> list[str]: + """Create events in Google Calendar; returns created event links.""" + svc = _service() + links = [] + for ev in events: + created = svc.events().insert(calendarId=calendar_id, body=_event_body(ev)).execute() + links.append(created.get("htmlLink", "")) + events_bus.emit("calendar", f"pushed {len(links)} event(s) to Google Calendar") + return links + + +def read_recent_facts(calendar_id: str = "primary", max_results: int = 50) -> tuple[list[str], list[str]]: + """OPT-IN read: scan recent/upcoming events for recurring attendees and + locations to seed memory. Returns (contact_names, locations). Raises if + Google libs/creds aren't configured (the caller degrades gracefully).""" + from collections import Counter + + svc = _service() + items = ( + svc.events() + .list(calendarId=calendar_id, maxResults=max_results, singleEvents=True, orderBy="startTime") + .execute() + .get("items", []) + ) + people, places = Counter(), Counter() + for ev in items: + for a in ev.get("attendees", []) or []: + nm = (a.get("displayName") or "").strip() + if nm and len(nm) <= 60: + people[nm] += 1 + loc = (ev.get("location") or "").strip() + if loc and len(loc) <= 80: + places[loc] += 1 + # keep recurring ones (seen >= 2) so memory stays meaningful + names = [n for n, c in people.most_common(20) if c >= 2] or [n for n, _ in people.most_common(10)] + locs = [p for p, c in places.most_common(10) if c >= 2] + return names, locs diff --git a/calendar_out/ics.py b/calendar_out/ics.py new file mode 100644 index 0000000000000000000000000000000000000000..08f2507305d48993206d5c21b03d97086bc24929 --- /dev/null +++ b/calendar_out/ics.py @@ -0,0 +1,60 @@ +"""Generate .ics files locally (default, off-grid output).""" +from __future__ import annotations + +import os +import tempfile +from datetime import datetime + +from dateutil import parser as dtparser +from icalendar import Alarm, Calendar +from icalendar import Event as IcsEvent + +from server import events as events_bus +from server.schema import Event + + +def events_to_ics(events: list[Event]) -> bytes: + cal = Calendar() + cal.add("prodid", "-//iMessage Calendar Agent//EN") + cal.add("version", "2.0") + + for ev in events: + ie = IcsEvent() + ie.add("summary", ev.title) + ie.add("dtstart", dtparser.isoparse(ev.start)) + if ev.end: + ie.add("dtend", dtparser.isoparse(ev.end)) + if ev.location: + ie.add("location", ev.location) + if ev.notes: + ie.add("description", ev.notes) + if ev.attendees: + for a in ev.attendees: + ie.add("attendee", a) + if ev.reminder_minutes is not None: + alarm = Alarm() + alarm.add("action", "DISPLAY") + alarm.add("description", f"Reminder: {ev.title}") + alarm.add("trigger", _minutes_before(ev.reminder_minutes)) + ie.add_component(alarm) + cal.add_component(ie) + + return cal.to_ical() + + +def _minutes_before(minutes: int): + from datetime import timedelta + + return timedelta(minutes=-minutes) + + +def write_ics(events: list[Event], path: str | None = None) -> str: + """Write events to an .ics file and return the path (for Gradio download).""" + data = events_to_ics(events) + if path is None: + fd, path = tempfile.mkstemp(suffix=".ics", prefix="events_") + os.close(fd) + with open(path, "wb") as f: + f.write(data) + events_bus.emit("calendar", f"wrote .ics with {len(events)} event(s)") + return path diff --git a/calendar_out/tzconfig.py b/calendar_out/tzconfig.py new file mode 100644 index 0000000000000000000000000000000000000000..81a1fdc02b7650704b33910a1b018bbca7a71e36 --- /dev/null +++ b/calendar_out/tzconfig.py @@ -0,0 +1,46 @@ +"""One timezone basis for the whole calendar path. + +The model emits offset-less local datetimes; gcal labels them with a zone and +freebusy compares them against .ics busy intervals. Both MUST resolve the zone +the same way, or conflicts are checked in one zone while events are pushed in +another (off by the whole UTC offset). Resolution order: + +1. CAL_TIMEZONE / TZ env — any value that validates as an IANA zone, slash or + not (UTC, GMT, Japan, America/New_York all count; a leading ':' is stripped). +2. /etc/timezone (Debian-style containers — i.e. the Space image). +3. None -> the process-local zone. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional +from zoneinfo import ZoneInfo + + +def _valid(name: str) -> Optional[str]: + name = (name or "").strip().lstrip(":") + if not name: + return None + try: + ZoneInfo(name) + return name + except Exception: # noqa: BLE001 not an IANA name (e.g. TZ=EST5EDT rules) + return None + + +def configured_timezone() -> Optional[str]: + """The configured IANA zone name, or None meaning 'process-local'.""" + for env in ("CAL_TIMEZONE", "TZ"): + v = _valid(os.environ.get(env, "")) + if v: + return v + try: + return _valid(Path("/etc/timezone").read_text()) + except Exception: # noqa: BLE001 not Debian-style (macOS/Windows) + return None + + +def zone() -> Optional[ZoneInfo]: + name = configured_timezone() + return ZoneInfo(name) if name else None diff --git a/collector/.env.example b/collector/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..3fe08555a88b999d080c8ed997bc85ca2bae5244 --- /dev/null +++ b/collector/.env.example @@ -0,0 +1,13 @@ +# Copy to .env and fill in. NEVER commit the real .env. +SPACE_URL=https://your-space.hf.space +INGEST_TOKEN=change-me-to-match-the-space +POLL_SECONDS=20 +# Optional: comma-separated chat names/handles to watch (blank = all) +WATCH_CHATS= +# Path to the iMessage DB (default is correct on macOS) +CHAT_DB=~/Library/Messages/chat.db +# AGENT_MODE=1 posts to /agent (client-side autonomous push) instead of /ingest. +# Prefer the server-side switch (run the backend with AUTONOMOUS=1) so logic lives +# in one place; the collector now also reports is_from_me so the backend can fire +# only on YOUR sent/accepted messages (backend env TRIGGER_ON=outgoing, the default). +AGENT_MODE=0 diff --git a/collector/collector.py b/collector/collector.py new file mode 100644 index 0000000000000000000000000000000000000000..0de86ce8084aefb4654cd4429072d6983a876f87 --- /dev/null +++ b/collector/collector.py @@ -0,0 +1,175 @@ +"""Mac-side iMessage collector. + +Polls ~/Library/Messages/chat.db for new messages and POSTs them to the Space +/ingest endpoint. Requires Full Disk Access for the running process. + +This reads the `text` column directly for simplicity. Many modern messages store +their body in `attributedBody` (an NSAttributedString blob) instead — for robust +extraction, prefer the `imessage-exporter` CLI (ReagentX) or the `imessage_reader` +package rather than expanding the SQL here. +""" +from __future__ import annotations + +import os +import sqlite3 +import sys +import time +from pathlib import Path + +import requests +from dotenv import load_dotenv + +# Allow importing the shared image helper from the repo root. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from server.imageutil import to_data_uri # noqa: E402 + +load_dotenv() + +SPACE_URL = os.environ["SPACE_URL"].rstrip("/") +INGEST_TOKEN = os.environ["INGEST_TOKEN"] +POLL_SECONDS = int(os.environ.get("POLL_SECONDS", "20")) +# AGENT_MODE=1: call /agent (run the agent + push to calendar) instead of /ingest +# (which only stores for review). The autonomous, hands-off path — see docs/automations.md. +AGENT_MODE = os.environ.get("AGENT_MODE") == "1" +CHAT_DB = Path(os.path.expanduser(os.environ.get("CHAT_DB", "~/Library/Messages/chat.db"))) +WATCH = [c.strip() for c in os.environ.get("WATCH_CHATS", "").split(",") if c.strip()] + +STATE = Path(__file__).with_name(".last_rowid") + +# Apple epoch = 2001-01-01; timestamps are nanoseconds since then. +# Chat key: display_name is EMPTY for 1:1 chats, and your own outgoing rows +# have handle_id NULL — falling back to sender filed incoming messages under +# the phone number and your replies under "unknown", so rolling_thread never +# assembled the conversation. COALESCE to chat_identifier gives both +# directions of a direct chat one stable key. +QUERY = """ +SELECT m.ROWID, m.text, m.attributedBody, h.id AS sender, + COALESCE(NULLIF(c.display_name, ''), c.chat_identifier) AS chat, + m.is_from_me, + datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS ts +FROM message m +LEFT JOIN handle h ON m.handle_id = h.ROWID +LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID +LEFT JOIN chat c ON c.ROWID = cmj.chat_id +WHERE m.ROWID > ? +ORDER BY m.ROWID ASC +""" + + +# Image attachments for a given message ROWID (filenames live under Attachments/). +ATTACH_QUERY = """ +SELECT a.filename +FROM attachment a +JOIN message_attachment_join maj ON maj.attachment_id = a.ROWID +WHERE maj.message_id = ? +""" + + +def _attachments_for(conn: sqlite3.Connection, message_rowid: int) -> list[str]: + """Return base64 data URIs for image attachments of a message.""" + uris: list[str] = [] + for (filename,) in conn.execute(ATTACH_QUERY, (message_rowid,)).fetchall(): + if not filename: + continue + path = os.path.expanduser(filename) + uri = to_data_uri(path) # None for non-images / too large + if uri: + uris.append(uri) + return uris + + +def _last_rowid() -> int: + try: + return int(STATE.read_text().strip()) + except Exception: # noqa: BLE001 + return 0 + + +def _save_rowid(rowid: int) -> None: + STATE.write_text(str(rowid)) + + +def poll_once(conn: sqlite3.Connection) -> int: + last = _last_rowid() + rows = conn.execute(QUERY, (last,)).fetchall() + batch = [] + max_rowid = last + for rowid, text, _attr, sender, chat, is_from_me, ts in rows: + max_rowid = max(max_rowid, rowid) + if WATCH and (chat or "") not in WATCH: + continue + images = _attachments_for(conn, rowid) + if not text and not images: + continue # nothing usable (see docstring re: attributedBody-only msgs) + batch.append( + { + "chat": chat or (sender or "unknown"), + "sender": "me" if is_from_me else (sender or "unknown"), + "text": text or "", + "timestamp": ts, + "images": images, + "is_from_me": bool(is_from_me), # you sending/accepting = the trigger + } + ) + if batch: + headers = {"Authorization": f"Bearer {INGEST_TOKEN}"} + if AGENT_MODE: + # One /agent call PER CHAT — a raw batch can span conversations, + # and format_thread would interleave them into one bogus thread. + # (/ingest doesn't need this: the server groups by chat itself.) + # Per-chat failures are caught, not raised: the /agent path pushes + # to the calendar with no dedup, so aborting mid-loop and replaying + # the whole batch next poll would re-push the chats that already + # succeeded. At-most-once: a failed chat's batch is logged and + # dropped; its next message re-triggers the rolling window anyway. + by_chat: dict[str, list[dict]] = {} + for m in batch: + by_chat.setdefault(m["chat"], []).append(m) + for chat, msgs in by_chat.items(): + try: + resp = requests.post( + f"{SPACE_URL}/agent", + json={"messages": msgs, "push_gcal": True}, + headers=headers, + timeout=120, + ) + resp.raise_for_status() + plan = resp.json().get("plan", {}) + print(f"[{chat}] sent {len(msgs)} msg(s) -> " + f"{len(plan.get('events', []))} event(s)") + except Exception as e: # noqa: BLE001 + print(f"[{chat}] agent call failed ({e}) — skipping this " + "batch for the chat; next message re-triggers it") + else: + resp = requests.post( + f"{SPACE_URL}/ingest", + json={"messages": batch}, + headers=headers, + timeout=30, + ) + resp.raise_for_status() + print(f"sent {len(batch)} message(s) -> {resp.json()}") + if max_rowid > last: + _save_rowid(max_rowid) + return len(batch) + + +def main(): + if not CHAT_DB.exists(): + raise SystemExit(f"chat.db not found at {CHAT_DB} (grant Full Disk Access?)") + # Read-only connection so we never mutate the Messages DB. + conn = sqlite3.connect(f"file:{CHAT_DB}?mode=ro", uri=True) + print(f"polling {CHAT_DB} every {POLL_SECONDS}s -> {SPACE_URL}/ingest") + try: + while True: + try: + poll_once(conn) + except Exception as e: # noqa: BLE001 - keep the loop alive + print(f"poll error: {e}") + time.sleep(POLL_SECONDS) + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/deploy/launchd/com.offgrid.backend.plist b/deploy/launchd/com.offgrid.backend.plist new file mode 100644 index 0000000000000000000000000000000000000000..b6b13fef0f26e1ddd28a927e2bcc5efc6e16358b --- /dev/null +++ b/deploy/launchd/com.offgrid.backend.plist @@ -0,0 +1,33 @@ + + + + + + Labelcom.offgrid.backend + ProgramArguments + + __PYTHON__ + __REPO__/app.py + + WorkingDirectory__REPO__ + EnvironmentVariables + + AUTONOMOUS1 + TRIGGER_ONoutgoing + USE_STUB_EXTRACTOR0 + INFERENCE_BASE_URLhttp://127.0.0.1:8080/v1 + INFERENCE_MODELhermes + INGEST_TOKEN__INGEST_TOKEN__ + MEMORY_PATH__HOME__/.offgrid/agent_memory.json + FEED_PATH__HOME__/.offgrid/ingest_feed.json + DEDUP_PATH__HOME__/.offgrid/agent_seen.json + IMPACT_PATH__HOME__/.offgrid/impact_weeks.json + PORT7860 + + RunAtLoad + KeepAlive + StandardOutPath__HOME__/Library/Logs/offgrid-backend.log + StandardErrorPath__HOME__/Library/Logs/offgrid-backend.log + + diff --git a/deploy/launchd/com.offgrid.collector.plist b/deploy/launchd/com.offgrid.collector.plist new file mode 100644 index 0000000000000000000000000000000000000000..f15ab02c12267ea58789a230602185e2639be234 --- /dev/null +++ b/deploy/launchd/com.offgrid.collector.plist @@ -0,0 +1,28 @@ + + + + + + Labelcom.offgrid.collector + ProgramArguments + + __PYTHON__ + __REPO__/collector/collector.py + + WorkingDirectory__REPO__/collector + EnvironmentVariables + + SPACE_URLhttp://127.0.0.1:7860 + INGEST_TOKEN__INGEST_TOKEN__ + POLL_SECONDS20 + CHAT_DB__HOME__/Library/Messages/chat.db + + RunAtLoad + KeepAlive + StandardOutPath__HOME__/Library/Logs/offgrid-collector.log + StandardErrorPath__HOME__/Library/Logs/offgrid-collector.log + + diff --git a/deploy/launchd/com.offgrid.hermes.plist b/deploy/launchd/com.offgrid.hermes.plist new file mode 100644 index 0000000000000000000000000000000000000000..0a303955678c8266867e70e8239755ce35b25c9f --- /dev/null +++ b/deploy/launchd/com.offgrid.hermes.plist @@ -0,0 +1,23 @@ + + + + + + Labelcom.offgrid.hermes + ProgramArguments + + __LLAMA_SERVER__ + -m__MODEL_GGUF__ + --host127.0.0.1 + --port8080 + --ctx-size8192 + --jinja + + RunAtLoad + KeepAlive + StandardOutPath__HOME__/Library/Logs/offgrid-hermes.log + StandardErrorPath__HOME__/Library/Logs/offgrid-hermes.log + + diff --git a/docs/android-tasker.md b/docs/android-tasker.md new file mode 100644 index 0000000000000000000000000000000000000000..9b26b7b3708923e20250343d022cb8984109cb7c --- /dev/null +++ b/docs/android-tasker.md @@ -0,0 +1,37 @@ +# Android background capture (Scenario 2) + +Unlike iOS, Android **allows background message capture**, so you get real on-phone autonomy — just +not for iMessage. A no-build recipe (Tasker or MacroDroid) calls the same shared **`POST /agent`** +backend the Mac collector and iOS Shortcut use. + +## What you need +- The backend reachable from the phone: the HF Space's dedicated-GPU path, a Mac/cloud box, or even + the phone itself (Termux). The free **ZeroGPU Space does not serve `/agent`** (Gradio-SDK only) — use + one of the others. +- The same `INGEST_TOKEN` the backend uses. + +## Tasker recipe (Notification Access — works for RCS/WhatsApp/SMS notifications) +1. **Profile → Event → UI → Notification** (or **Phone → Received Text** for SMS). Restrict it to + your messaging app(s). +2. **Task → Net → HTTP Request:** + - Method: `POST` + - URL: `https:///agent` + - Headers: `Authorization: Bearer ` and `Content-Type: application/json` + - Body: + ```json + { "thread": "%evtprm()", "now": "%TIMES", "push_gcal": true } + ``` + (Use the notification text variable your trigger provides for `thread`; `%TIMES` → current time.) +3. **Parse the response** (`Variable → JSON Read` on `plan.events`) if you want a confirmation + toast/notification; otherwise `push_gcal:true` already created the events in Google Calendar. + +MacroDroid is equivalent: **Trigger:** Notification Received / SMS Received → **Action:** HTTP POST +with the same URL/headers/body. + +## Notes +- This is genuinely hands-off: the OS delivers the trigger in the background. +- For a fully on-device variant, run the backend + a small model in **Termux** and point Tasker at + `http://127.0.0.1:7860/agent`, with `INFERENCE_BASE_URL` → a local `llama-server` (Gemma E4B / a + small Hermes). See [on-device.md](./on-device.md) and [hermes.md](./hermes.md). +- A native Kotlin `NotificationListenerService` app could replace Tasker for a polished install — a + separate effort; the Tasker recipe is the MVP. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000000000000000000000000000000000000..8d0b05887445e2f465611c88b184c358382ef659 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,121 @@ +# Architecture — workflows and the LLMs behind them + +An AI-solution-architect view of the agentic system: every workflow through the +platform, and exactly which model (if any) each one calls. The architectural +signature: the extraction core is **one grammar-constrained LLM call**, the +**MiniCPM planner** adds a visible multi-step loop over the platform's own +public MCP tool contract, everything verifiable — conflict math, dedup, time +proposals, eval gates — stays deterministic, and there are **zero cloud-AI API +calls anywhere**, training included. + +## System workflow + +```mermaid +flowchart TB + subgraph ENTRY["1 · Entry points — four front-ends, one contract"] + direction LR + UIIN["🖥️ Gradio UI
Schedule flow + Agent tab
(paste thread, screenshots, .ics)"] + SHORT["📱 iOS Shortcut /
Android Tasker"] + MAC["🍎 Mac collector
polls iMessage chat.db
(collector/collector.py)"] + MCPC["🤖 MCP clients
Claude Desktop, Cursor"] + end + + subgraph API["2 · API & orchestration — app.py (FastAPI + Gradio, one port)"] + AGENTEP["POST /agent
bearer-token, stateless"] + INGEST["POST /ingest → feed store
AUTONOMOUS=1 triggers on
your outgoing message (is_from_me)"] + ROLL["threads.rolling_thread
per-chat window (20 msgs / 12 h)"] + MCPT["MCP tools — server/mcp_tools.py
extract_events · make_ics · check_conflicts"] + end + + subgraph ORCH["2a · Agentic orchestration — server/orchestrator.py"] + SMOL["smolagents ToolCallingAgent
planned by MiniCPM, ≤6 steps
playbook: extract → check → render
final ActionPlan re-derived deterministically"] + SCRIPT["ScriptedPlanner — no LLM
identical tool sequence + step events
(stub mode, CI, planner failure)"] + end + + subgraph CORE["3 · Agent core — server/pipeline.py → server/agent.py"] + PROMPT["Prompt assembly:
SYSTEM + memory recall block
+ existing calendar + thread + images"] + GEN["Grammar-constrained generation
→ ActionPlan JSON (always parses)"] + PROMPT --> GEN + end + + subgraph LLMT["4 · LLM tier — ALL inference is local llama.cpp, zero cloud AI APIs"] + GEMMA["⭐ gemma-cal E4B — fine-tuned Gemma 4
ParetoOptimal/gemma-4-cal-gguf
gemma-cal-e4b-Q4_K_M.gguf (~5 GB)
+ mmproj-F16.gguf vision projector"] + MODES["served either:
· in-process llama-cpp-python (ZeroGPU lease)
· remote llama-server via INFERENCE_BASE_URL
(Space sidecar / Mac launchd / phone)"] + MINICPM["🧭 MiniCPM planner — OpenBMB (sponsor)
openbmb/MiniCPM4.1-8B-GGUF Q4 (~5 GB)
≤4B option: openbmb/MiniCPM5-1B-GGUF (config switch)
2nd llama-server :8081 — enabled via
PLANNER_HF_REPO / PLANNER_FILE"] + HERMES["(optional) Hermes-3-Llama-3.1-8B Q4_K_M
HERMES_TOOLS=1 — tool-calling loop:
calls remember() to write memory mid-run"] + STUB["(no LLM) regex stub extractor
USE_STUB_EXTRACTOR=1 — CI & free tier"] + GEMMA --- MODES + end + + subgraph DET["5 · Deterministic post-processing — no LLM"] + CONF["freebusy.annotate_conflicts
overlap / adjacent / tight
+ propose_times free slots"] + DEDUP["dedup.filter_new
idempotency for autonomous runs"] + MEMW["memory.observe_plan
learns recurring contacts"] + end + + subgraph OUT["6 · Outputs"] + CARDS["Event cards + reply draft
+ clarification question"] + ICS["📥 .ics download
(off-grid default)"] + GCAL["📆 Google Calendar push
(per-user OAuth web flow, opt-in)"] + TRACE["Redacted trace export
→ public HF dataset"] + end + + UIIN -->|"run_orchestrator (step trace streams into the UI)"| SMOL + SHORT --> AGENTEP + MAC -->|"store-only"| INGEST + MAC -->|"AGENT_MODE=1"| AGENTEP + MCPC --> MCPT + AGENTEP --> CORE + INGEST --> ROLL --> CORE + SMOL ==>|"planning loop, ≤6 steps"| MINICPM + SMOL -->|"tool calls — the Space's OWN MCP
endpoint (localhost SSE)"| MCPT + SMOL -.->|"planner down / stub mode"| SCRIPT + SCRIPT -->|"same tool sequence,
deterministic"| MCPT + MCPT -->|"extract_events → 1 LLM call"| CORE + MCPT -.->|"make_ics / check_conflicts → 0 LLM calls"| DET + + GEN ==>|"default"| GEMMA + GEN -.->|"opt-in autonomous brain"| HERMES + GEN -.->|"tests / free demo"| STUB + HERMES -->|"remember()"| MEMW + + LLMT --> DET --> OUT +``` + +## Offline loop — eval-gated fine-tuning (produces the serving LLM) + +```mermaid +flowchart LR + SEEDS["Seed data — NO LLM
139 hand-authored template examples
(gen_new_seeds.py / make_dataset.py)"] + SMC["SMCalFlow import — NO LLM
deterministic LISP-program parse, ~2000 rows"] + TRAIN["QLoRA fine-tune — Unsloth on Modal A100-80GB
base: google/gemma-4-31B-it or gemma-4-E4B-it
r=16, lr 5e-5, 2 epochs, responses-only loss"] + GGUF["convert_hf_to_gguf + llama-quantize
→ staging Q4_K_M GGUF"] + EVAL["Eval — NO LLM judge, deterministic metrics
60-example held-out set:
schema validity · event F1 · start-exact recall"] + GATE{"Gate
validity ≥ 0.95
F1 ≥ 0.81
recall ≥ 0.773"} + PROD["Promote → ParetoOptimal/gemma-4-cal-gguf
(the model the Space serves)"] + TRASH["Discard staging —
production untouched"] + + SEEDS --> TRAIN + SMC --> TRAIN + TRAIN --> GGUF --> EVAL --> GATE + GATE -->|pass| PROD + GATE -->|fail| TRASH +``` + +See [eval-roadmap.md](./eval-roadmap.md) and the +[eval-gated fine-tuning post-mortem](./blog-eval-gated-finetuning.md) for the +gate's history and rationale; [hermes.md](./hermes.md) for the optional +tool-calling backend; [build-small-submission.md](./build-small-submission.md) +for how the MiniCPM planner maps to the `sponsor:openbmb` track. + +## Which LLM each workflow calls + +| # | Workflow | Trigger | LLM call(s) | Where it runs | +|---|----------|---------|-------------|----------------| +| 1 | Agentic orchestration (Schedule flow + Agent tab) | User pastes thread / uploads screenshots, clicks Find the events / Run the agents | **1× MiniCPM planning loop** (`MiniCPM4.1-8B`, or `MiniCPM5-1B` ≤4B variant; ≤6 steps) driving the Space's own MCP tools, **+ 1× gemma-cal E4B** per `extract_events` tool call (vision via mmproj); `check_conflicts`/`make_ics` are zero-LLM. Planner unconfigured or down → ScriptedPlanner runs the identical sequence, **gemma-cal only** | Two local llama-servers — gemma-cal on :8080, MiniCPM on :8081 | +| 2 | API extraction (`POST /agent`) | iOS Shortcut, Android Tasker, or Mac collector in `AGENT_MODE=1` | **1× gemma-cal E4B** (same pipeline, same prompt) | Same | +| 3 | Autonomous ingest | Mac collector → `/ingest`; your outgoing message triggers a run over the chat's rolling thread | **1× gemma-cal E4B per affected chat**, then deterministic dedup + calendar delivery | Same | +| 4 | Memory-writing agent (optional) | `HERMES_TOOLS=1` on the remote path | **Hermes-3-Llama-3.1-8B** in a tool loop (≤3 rounds): may call `remember()` then returns the ActionPlan | Remote llama-server (e.g. Mac launchd) | +| 5 | MCP tools for external agents | MCP client calls the Space | `extract_events` → **1× gemma-cal E4B**; `make_ics` and `check_conflicts` → **zero LLM calls** | Same as #1 | +| 6 | CI / free-tier demo | `USE_STUB_EXTRACTOR=1` | **No LLM** — regex heuristic | CPU anywhere | +| 7 | Training & eval (offline) | `training/gated_retrain.py` | **No LLM at the inference-API level**: data gen is template-based, eval is metric-based (no judge). The LLM here is the *training target*: QLoRA on `google/gemma-4-31B-it` / `gemma-4-E4B-it` | Modal A100/H100 | diff --git a/docs/automations.md b/docs/automations.md new file mode 100644 index 0000000000000000000000000000000000000000..9479ae86176b0ff6947d4281d0fd277a5d03316f --- /dev/null +++ b/docs/automations.md @@ -0,0 +1,83 @@ +# Automations — make it autonomous without a custom app + +Everything below drives one endpoint. **iOS cannot read iMessage in the background** (no API), so the +autonomy ceiling differs by platform: + +| Front-end | Autonomy | Source | Notes | +|---|---|---|---| +| Mac collector (`AGENT_MODE`/`AUTONOMOUS`) | Fully hands-off | iMessage | Needs an always-on Mac | +| iOS Shortcut | One gesture (you trigger it) | anything you share | No background reading possible | +| Android Tasker/MacroDroid | Hands-off | SMS/RCS/notifications | Not iMessage | + +## The `/agent` contract (what they all call) + +`POST {SPACE_URL}/agent` with `Authorization: Bearer `: + +```jsonc +// request — `thread` OR `messages` required; rest optional +{ + "thread": "Room parent: picture day Thursday 9am\nMe: thanks", + "messages": [{"sender": "Room parent", "text": "picture day Thursday 9am"}], + "images": ["data:image/png;base64,..."], // a screenshot + "existing_ics": "", // optional, enables conflict checks + "now": "2026-06-05T10:00:00", + "push_gcal": false, + "return_ics": true +} +``` +```jsonc +// response +{ + "plan": { "events": [{"title":"Picture day","start":"2026-06-11T09:00:00", ...}], + "conflicts": [], "proposed_times": [], "reply_draft": "...", "needs_clarification": null }, + "ics_base64": "<...>", + "gcal_links": [] +} +``` + +## (A) Mac collector — fully autonomous (iMessage) + +Two equivalent ways; prefer the server-side switch so logic lives in one place: + +- **Server-side:** run the Space with `AUTONOMOUS=1`. `/ingest` then assembles a per-chat rolling + thread, runs the agent, dedupes, and (if Google is configured) pushes events automatically. +- **Collector-side:** run the collector with `AGENT_MODE=1` — it POSTs `/agent` (with `push_gcal`) + instead of `/ingest`. See `collector/collector.py`. + +```bash +# collector-side +cd collector && AGENT_MODE=1 python collector.py +``` + +## (B) iOS Shortcut — one tap, no `.ics` import + +1. New Shortcut → accept **Share Sheet** input (Text and Images). +2. **Text** → set variable `Thread`. +3. **Get Contents of URL** → `https:///agent`, Method **POST**, Header + `Authorization: Bearer `, Request Body **JSON**: + `{ "thread": Thread, "now": }` + (To send a screenshot instead: Base64-encode the shared image into `images`.) +4. **Get Dictionary Value** `plan.events` from the response. +5. **Repeat with Each** → **Add New Event** (Calendar): Title = `title`, Start = `start`, + End = `end`, Location = `location`, Notes = `notes`. + +Now sharing a thread/screenshot to the Shortcut adds the events to Apple Calendar in one tap — no +file download, no import. (Optional: read back `plan.conflicts` and show an alert.) + +## (C) Android — Tasker / MacroDroid (SMS/RCS) + +1. **Trigger:** Event → *Received Text* (SMS), or a Notification trigger for your messaging app. +2. **Action:** HTTP Request → POST `https:///agent`, header + `Authorization: Bearer `, body `{ "thread": "%astext", "now": "%DATE..." }`. +3. Parse `plan.events` (JSON Read) → for each, **Insert Calendar Event** (Tasker writes via + `CalendarContract`). + +Because Android can read SMS/RCS and run in the background, this path is genuinely autonomous. + +## Roadmap — a native app + +- **Android:** a real app using a Notification Listener / `READ_SMS`, on-device **Gemma E4B** via + llama.cpp/MLC (see [on-device.md](./on-device.md)), writing events through the Calendar provider — + the same `/agent` contract or fully local. Feasible and fully autonomous. +- **iOS:** no background message or LLM-server access — the Shortcut above is the ceiling. An + autonomous iOS iMessage app is **not possible**; we won't promise one. diff --git a/docs/blog-eval-gated-finetuning.md b/docs/blog-eval-gated-finetuning.md new file mode 100644 index 0000000000000000000000000000000000000000..0b8eaaa3d31cd99f26c2a5057f883d560e57237f --- /dev/null +++ b/docs/blog-eval-gated-finetuning.md @@ -0,0 +1,187 @@ +# What Six Failed Fine-Tunes Taught Us About Evals, Templates, and Knowing When to Stop + +*A post-mortem on fine-tuning Gemma-4 for structured calendar extraction — fifteen GPU runs, +one destroyed model, one exonerated quantizer, a chat-template landmine, and the eval harness +that caught every bad model before it shipped.* + +--- + +## The setup + +[OffGridSchedula](https://huggingface.co/spaces/ParetoOptimal/OffGridSchedula) is a local-first +scheduling agent: paste a group chat (or a flyer screenshot) and get back a constrained +**ActionPlan** JSON — events with exact ISO datetimes, a conflict check, a drafted reply, and a +`needs_clarification` question when the thread is too vague to schedule. Inference is +llama.cpp serving Gemma-4 GGUFs; no cloud AI APIs. + +The project carried a hard requirement: ship a **fine-tuned model** that outperforms its base. +This is the story of trying to satisfy that requirement honestly — and what "honestly" ended up +costing and teaching. Everything below ran on Modal serverless A100s; total GPU spend for the +entire investigation was well under $100. + +## Act I: The fine-tune that lost to its own base + +The first QLoRA fine-tune of `google/gemma-4-31B-it` (Unsloth, r=16, 69 synthetic examples, +2 epochs) looked fine in a smoke test. So we built a real eval before trusting it: 28 held-out +examples scored on **start-exact recall** (did you produce the exact ISO start datetime), +event F1 with greedy datetime matching, schema validity, no-event accuracy (does chitchat +hallucinate events), and clarification recall (do you *ask* instead of inventing when a plan is +"TBD"). Temperature 0, the same `response_format: json_schema` call the production server uses. + +First scores: fine-tune **F1 0.81**, base **0.977**. The fine-tune *lost to its own base*. The +mismatch dump showed why — three of its five misses were the same corruption: `"206-10-06"` +instead of `"2026-10-06"`. A dropped year digit. + +Two suspects: quantization (classic low-bit digit corruption) or the training itself. + +## Act II: Scaling data made it worse. Much worse. + +The intuitive fix — more data — backfired in the most instructive way possible: + +| training examples | schema validity | event F1 | +|---|---|---| +| 69 | 1.00 | 0.81 | +| 87 | 0.75 | 0.465 | +| 122 | 0.46 | 0.214 | +| 2,122 (incl. real SMCalFlow data) | 0.107 | 0.000 | + +**Monotonic decay with training steps.** By the 2,122-example run the model emitted unparseable +output on ~90% of inputs. A raw-output probe (serve the staging GGUF, generate *without* the +JSON grammar) settled what "broken" meant: the model free-generated `Huddle — — — — — —…` to the +token limit. Not a formatting problem. Destroyed weights. + +Two cheap experiments isolated the cause: + +**Quantization was exonerated** by sweeping the *same* merged weights through f16 / Q8_0 / +Q4_K_M (one A100 lease, the fp16 was already on a Modal volume). At full fp16 the fine-tune +still scored validity 0.64 / F1 0.57 — nowhere near base. Precision bought ~+0.1 F1. The damage +preceded the quantizer. + +**The chat template was half the story.** Gemma-4 ships a brand-new template — +`<|turn>user\n…`, with a dedicated `<|turn>system` block. There is no +`` anywhere in it. Our training code used Unsloth's legacy `"gemma"` template, +which is built entirely on ``. Every gradient step optimized a turn syntax that +`llama-server --jinja` (which reads the template *embedded in the GGUF*) never renders. We +verified the fix end-to-end by reading `tokenizer.chat_template` out of our exported GGUF's +metadata with `gguf.GGUFReader` — trust the artifact, not the code — and added a hard +`assert "<|turn>" in rendered` to the training script so the mismatch can never silently +recur. + +And yet: with templates verifiably aligned, response-only loss masking, and LR dropped to 5e-5, +the 31B *still* collapsed to validity 0.0. With dataset, template, LR, and masking all varied, +the one remaining common factor was the training stack itself: Unsloth's QLoRA path for the +brand-new Gemma-4-31B architecture (its own logs warn it can't handle `Gemma4AudioModel` +internals). The same recipe on Gemma-4 **E4B** trained cleanly every single time. New +architectures make the training framework a first-class suspect. + +## Act III: The benchmark nobody wants — prompt engineering hits 1.0 + +While the fine-tune investigation ran, error analysis kept improving the *system*: + +- **State the weekday in the prompt.** `Current datetime: Monday, 2026-09-14T09:00:00` turns + day-of-week resolution from memorized calendar knowledge into deterministic arithmetic — for + every model. +- **Two surgical system-prompt lines** targeting the base's only two eval misses (multi-event + splitting; asking on "TBD") took **stock Gemma-4-31B to 1.0 on every metric**. + +That's the uncomfortable benchmark for any SFT project: against a near-ceiling base, prompt +engineering had ~100× better ROI than fine-tuning. The requirement, however, was a fine-tune +that beats *its* base — so we re-aimed at the tier where headroom actually existed. + +## Act IV: The E4B campaign — six gated runs to a tie + +A ~5 GB Gemma-4 E4B that runs on modest hardware is the model this local-first project actually +wants at the edge, and stock E4B had real room: F1 0.93. Every retrain ran through an +**eval-gate**: train → upload to a *staging* filename → eval → promote to production **only if +it beats the bar**, else delete staging. The gate rejected eight models across this project +without production ever serving one of them. + +Each iteration fixed a diagnosed failure, not a hunch: + +| run | change | F1 (eval) | +|---|---|---| +| 1 | fixed recipe, 2,122 examples | 0.884 (n=28) | +| 2 | weekday-in-prompt, data regenerated to match | 0.955 | +| 3 | dropped 74 SMCalFlow rows teaching a conflicting "next DOW" convention; 4× hand-data upsample | **1.000** | +| 4 | + TBD-clarify seeds, 8× upsample | 0.93 (clarify → 1.0) | +| 5 | clarify seeds at 4× | 0.93 | +| — | **eval expanded 28 → 60 examples** | — | +| 6 | + targeted seeds for the two shapes stock fails | 0.97 | + +Three findings here deserve their own bullets: + +- **Label conventions are silent killers.** SMCalFlow annotates "next Tuesday" (said on a + Monday) as *tomorrow*; our app's convention is *Tuesday of next week*. 74 imported rows + trained the bug in. Filtering them fixed it — until other data shifts brought it back. + When you convert someone else's dataset, you inherit someone else's semantics. +- **Small evals lie.** At n=28 (22 gold events), one event = 4.5 recall points, and we watched + **4 added training rows flip 3 eval cases**. Run-to-run SFT jitter swamped the signal — runs + 3–5 were a seesaw, not progress. Expanding to 60 examples / 50 events made the gate mean + something again. +- **Some priors resist data.** The "next Tuesday = tomorrow" prior survived *seven* explicit + counter-examples. Stock makes the same error. Genuinely ambiguous English stays ambiguous. + +Run 6 vs stock E4B (with the same engineered prompt): **identical confusion counts** — +48/50 events, tp/fp/fn 48/1/2, F1 0.97 = 0.97. A dead statistical tie. + +## Act V: The bare-prompt tiebreaker + +The classic argument for SFT at parity is internalization: the fine-tune shouldn't *need* the +prompt. So we measured it — same 60 examples, system prompt deleted for both models, identical +minimal user content, same JSON-schema constraint: + +| bare, n=60 | stock E4B | fine-tuned E4B | +|---|---|---| +| schema validity | 0.967 | **1.0** | +| no-event accuracy | 0.70 | **0.80** | +| clarification recall | 0.50 | **0.625** | +| event F1 | **0.682** | 0.644 | + +The fine-tune is more *disciplined* bare (never breaks schema, hallucinates less, asks more); +stock edges bare extraction. No decisive gap. **Final verdict: at this data scale (139 +hand-authored + 2,000 converted examples, QLoRA, 1 epoch), the fine-tune reaches parity with +its base — not superiority.** It shipped as the project's edge model with exactly that claim on +the model card, by explicit owner decision; the strict-dominance auto-gate, correctly, never +promoted it. + +## What we'd tell you to do differently + +1. **Build the eval before the fine-tune, and gate every publish on it.** Ours rejected eight + bad models, caught a regression that had already overwritten a good artifact (server-side + `CommitOperationCopy` restored it for free), and converted every failure into a diagnosis. + The eval harness was the single highest-value artifact of the project. +2. **Train format must equal serve format — and verify it in the artifact.** Read the chat + template out of the exported GGUF's metadata. Assert it in the training script. A template + mismatch doesn't error; it just quietly ruins everything at a rate proportional to your + training steps. +3. **Suspect the training stack on new architectures.** The same recipe destroyed Gemma-4-31B + and trained Gemma-4-E4B flawlessly, six times in a row. Framework warnings about unhandled + submodules (`Gemma4AudioModel`) are not noise. +4. **Exonerate quantization cheaply before blaming it.** Sweep the same weights across + f16/Q8/Q4 in one GPU lease. Ours cost a few dollars and killed the most plausible-sounding + hypothesis of the whole project. +5. **Put deterministic facts in the prompt instead of hoping the model memorized them.** + Weekday-in-the-prompt improved every model, including the ones we didn't train. +6. **Match your eval's resolution to your iteration size.** If one flipped case moves a gated + metric by 4+ points, your gate is a coin flip. +7. **Diff label conventions when importing datasets.** Resolution semantics ("next DOW"), + reference-time handling, and reply style all transfer — whether you want them to or not. +8. **Respect the parity outcome.** Against a strong instruction-tuned base on a narrow, + well-prompted task, SFT parity is a common honest result. The defensible claims left are + discipline-under-no-prompt, token savings, and convention control — claim those, not wins + you didn't measure. + +### What might still beat the base +10–100× more *real* (non-template) data; full fine-tuning rather than QLoRA once the stack +supports the architecture; preference optimization (DPO) specifically on the +clarify-vs-extract boundary; and a harder eval where the ceiling isn't 0.97. The gate is +already in place to referee all of it. + +--- + +*Artifacts: model repo +[`ParetoOptimal/gemma-4-cal-gguf`](https://huggingface.co/ParetoOptimal/gemma-4-cal-gguf) +(31B v1 + E4B edge + mmproj), eval harness `training/eval.py` + `training/data/eval.jsonl`, +gate `training/gated_retrain.py`, importer `training/import_smcalflow.py` (SMCalFlow, +CC BY-SA 4.0 — Semantic Machines et al., TACL 2020), full run-by-run log in +`docs/eval-roadmap.md`.* diff --git a/docs/build-small-submission.md b/docs/build-small-submission.md new file mode 100644 index 0000000000000000000000000000000000000000..5450a5e8b38a28f175fe21814b2c0f663e998fe8 --- /dev/null +++ b/docs/build-small-submission.md @@ -0,0 +1,68 @@ +# Build Small — submission mapping + +How OffGridSchedula lines up with every requirement, track, sponsor prize, and badge of the +[Build Small hackathon](https://huggingface.co/build-small-hackathon) +([field guide](https://huggingface.co/spaces/build-small-hackathon/field-guide)). +Tags claimed in the README frontmatter use the field guide's namespaced taxonomy +(`track:*`, `sponsor:*`, `achievement:*`). + +## Hard rules + +| # | Rule | Status | Evidence | +|---|------|--------|----------| +| 1 | Every model under 32B parameters | ✅ | Two local models, both far under the cap: extraction is [`gemma-cal` E4B](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) (~4B effective params, ~5 GB GGUF at Q4) and planning is [`openbmb/MiniCPM5-1B`](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) (1B). | +| 2 | Gradio app, hosted as an HF Space (Docker OK) | ✅ | [`app.py`](../app.py) is a Gradio Blocks app served from a Docker SDK Space running llama.cpp, now hosted in the hackathon org: [`build-small-hackathon/OffGridSchedula`](https://huggingface.co/spaces/build-small-hackathon/OffGridSchedula). | +| 3 | Demo video | ✅ | Recorded and linked from the README: [youtu.be/m-o0u9X3tI4](https://youtu.be/m-o0u9X3tI4) (storyboard in [`docs/demo-script.md`](./demo-script.md)). | +| 4 | Social media post, linked from the README | ✅ | Published and linked from the README: [X (1)](https://x.com/nate_mauer/status/2064920352845709419), [X (2)](https://x.com/nate_mauer/status/2065661878441750916), and [LinkedIn](https://www.linkedin.com/feed/update/urn:li:ugcPost:7471440639969132545) (drafts in [`docs/social-post.md`](./social-post.md)). | +| 5 | ≤ 10 ZeroGPU apps per user | ✅ n/a | Runs on cpu-basic (stub preview) or a dedicated T4 — no ZeroGPU dependency. | +| 6 | README frontmatter tags + short write-up of idea & tech | ✅ | Namespaced tags + the idea-and-tech write-up are in [`README.md`](../README.md). | + +## Track — `track:backyard` (Backyard AI) + +A specific real person: a busy parent whose kid's school and activity events are buried in a +noisy class group chat. They paste the chat (or a flyer screenshot) from their phone's browser +and get back events, a conflict check against their own calendar, and a ready-to-send reply — +reviewed before anything is saved, exported as a local `.ics` (Apple/Google Calendar one tap +away). Short pasted chats and screenshots are exactly the workload a small local model handles +well — an honest fit, not a stretch. + +## Sponsor prize — `sponsor:modal` (Best Use of Modal) + +Modal powered the **development** of the platform's model end-to-end: + +- [`training/modal_train.py`](../training/modal_train.py) — full QLoRA fine-tune on serverless A100/H100s (dataset → train → GGUF export → HF publish), with persistent Volumes caching base weights and outputs across runs. +- [`training/modal_eval.py`](../training/modal_eval.py) / [`modal_quant_eval.py`](../training/modal_quant_eval.py) — the 60-example task eval served on llama.cpp inside Modal, including an on-volume quantization study (f16 / Q8_0 / Q4_K_M). +- [`training/gated_retrain.py`](../training/gated_retrain.py) — the eval-gated pipeline: train → staging upload → eval → promote **only if it beats the gate**. It rejected eight regressed models before the published one; every one of those runs was a Modal job. + +## Sponsor prize — `sponsor:openbmb` (Best MiniCPM Build) + +Clicking **Run the agents** invokes **OpenBMB MiniCPM** as the planner (`openbmb/MiniCPM5-1B-GGUF`; +the larger `MiniCPM4.1-8B` variant is a config switch) on a second local llama.cpp instance. It +drives this Space's own MCP tools (`extract_events` → `check_conflicts` → `make_ics`) as a visible +multi-step agent ([`server/orchestrator.py`](../server/orchestrator.py)) — MiniCPM is core to the +agent experience, not a garnish (a deterministic scripted plan is the fallback when the planner +isn't configured). Also the natural evidence for the judged **Best Agent** award. + +## Achievement badges (self-declared, all claimed) + +| Tag | Badge | Evidence | +|-----|-------|----------| +| `achievement:offgrid` | Off the Grid | All inference runs inside the Space via llama.cpp — no cloud AI APIs. The only optional outbound call is the user's own Google Calendar push. | +| `achievement:welltuned` | Well-Tuned | [`build-small-hackathon/gemma-4-cal-gguf`](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) — our published QLoRA fine-tune of Gemma-4 E4B **is the model production serves**, shipped through the eval gate with the [honest scorecard public](./eval-roadmap.md). | +| `achievement:offbrand` | Off-Brand | Custom landing page, grouped nav, dark hero + carousel, elevated tool card, bespoke CSS/JS ([`ui/blocks.py`](../ui/blocks.py), [`static/app.css`](../static/app.css)) — far past the stock Gradio look. | +| `achievement:llama` | Llama Champion | The official `ghcr.io/ggml-org/llama.cpp` server image runs the GGUF + vision mmproj ([`Dockerfile`](../Dockerfile), [`scripts/start_space.sh`](../scripts/start_space.sh)). | +| `achievement:sharing` | Sharing is Caring | Redacted agent traces published to the public dataset [`ParetoOptimal/offgridschedula-traces`](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces) — one-click from the Activity tab, or [`training/share_trace.py`](../training/share_trace.py). | +| `achievement:fieldnotes` | Field Notes | [`FIELD_NOTES.md`](../FIELD_NOTES.md) (build retrospective) + [`docs/blog-eval-gated-finetuning.md`](./blog-eval-gated-finetuning.md) (fine-tuning post-mortem) + the [published project blog](https://huggingface.co/blog/build-small-hackathon/offgridschedula) ([source](./blog-offgridschedula.md)). | + +Sponsor prizes **not** claimed: OpenAI Codex (no Codex-attributed commits) and NVIDIA Nemotron +(different model family). The cash bonus badges (Off Brand, Tiny Titan, Best Demo, Best Agent, +Bonus Quest Champion, Judges' Wildcard) are judged across all submissions and take no tags. + +## Status + +All six hard rules are met — nothing outstanding: + +- The Space is live in the hackathon org: [`build-small-hackathon/OffGridSchedula`](https://huggingface.co/spaces/build-small-hackathon/OffGridSchedula). +- The model is published at [`build-small-hackathon/gemma-4-cal-gguf`](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) (the planner is [`openbmb/MiniCPM5-1B-GGUF`](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF)). +- The [demo video](https://youtu.be/m-o0u9X3tI4) and the social posts (X + LinkedIn) are published and linked from the README. +- The write-up is live as a Hugging Face blog post: [build-small-hackathon/offgridschedula](https://huggingface.co/blog/build-small-hackathon/offgridschedula). diff --git a/docs/eval-roadmap.md b/docs/eval-roadmap.md new file mode 100644 index 0000000000000000000000000000000000000000..e5e995e6cbaf424111509ba2fa58ddec8dd5cf4f --- /dev/null +++ b/docs/eval-roadmap.md @@ -0,0 +1,337 @@ +# Eval roadmap — improving the scheduling fine-tune + +How we measure and improve `ParetoOptimal/gemma-4-cal-gguf` (the fine-tuned +Gemma-4-31B that turns chat/images into a calendar `ActionPlan`). The eval is +**task-specific** — generic LLM benchmarks (MMLU etc.) don't apply. + +Harness: `training/eval.py` (scores), `training/gen_eval.py` + `training/data/eval.jsonl` +(28 held-out examples, disjoint from `dataset.jsonl`), `training/modal_eval.py` +(serves the GGUF on the same `llama-server` the Space uses, then scores). + +## Baseline scores (Q4_K_M, n=28, 2026-06-09) + +| Metric | Score | +| --- | --- | +| schema validity | 1.00 | +| no-event accuracy | 1.00 | +| clarification recall | 1.00 | +| end-time exact | 1.00 | +| event precision | 0.85 | +| **event recall (start-exact)** | **0.77** | +| event F1 | 0.81 | +| title similarity | 0.87 | + +Discipline (never invents events, always asks when ambiguous) is perfect; all 9 +relative-date cases passed. The gap is **exact start datetime** on a few +explicit far-future dates (misses: `e02`, `e05`, `e06`, `e15`, one leg of `m02`). + +## The 3 steps + +### 1. Diagnose the 5 misses (cheap) +Enhance `eval.py` to dump the model's actual `start`/`title` for mismatched events, +then one re-run shows whether they're date-shift, time/AM-PM, or wrong-year errors — +which tells us exactly what training data to add. (~one A100 eval run; the GGUF is +cached in the Modal Volume, so it's fast.) + +### 2. Baseline comparison (the "Well-Tuned" proof) +Run `modal run training/modal_eval.py --model-hf-repo unsloth/gemma-4-31B-it-GGUF` +to score **stock** Gemma-4-31B on the same set. If the fine-tune's discipline +(no-event 1.0, clarification 1.0) and datetime recall beat stock, that's concrete +evidence the fine-tune helps. (Separate ~18 GB model download + A100 time.) + +### 3. Close the gap +Add ~15–20 explicit-date examples (especially next-month dates and times) to +`training/data/dataset.jsonl`, re-train on Modal (`training/modal_train.py`), +re-eval — and watch start-exact recall move. + +## Results log + +### Step 1 — diagnosis (2026-06-09) +The mismatch dump showed the misses are **not** a reasoning failure. 3 of 5 are the +same bug — a dropped year digit, **"206" instead of "2026"** — on next-month dates +(month/day/time all correct): + +``` +[e02] gold 2026-10-06T15:30 pred 206-10-06T15:30 +[e05] gold 2026-10-01T08:15 pred 206-10-01T08:15 +[e15] gold 2026-10-08T19:00 pred 206-10-08T19:00 +[e06] gold 2026-09-28T09:00 pred [] (abstained) +[m02] Standup + Sprint demo pred Standup only (dropped 2nd leg) +``` + +Fix indicated: more far-future explicit-date examples reinforcing 4-digit years +(+ multi-event 2nd legs). → Step 3. + +### Step 2 — baseline vs fine-tune (2026-06-09, n=28, Q4_K_M) + +| Metric | Stock `gemma-4-31B-it-GGUF` | Fine-tune `gemma-4-cal-gguf` | +| --- | --- | --- | +| schema validity | 1.00 | 1.00 | +| event precision | **1.00** | 0.85 | +| start-exact recall | **0.955** | 0.773 | +| event F1 | **0.977** | 0.81 | +| end-exact | 1.00 | 1.00 | +| no-event accuracy | 1.00 | 1.00 | +| clarification recall | 0.75 | **1.00** | + +**Honest read:** stock Gemma-4-31B is already strong at this extraction and *beats* +the current fine-tune on datetime recall — the "206" bug is a fine-tune regression. +The fine-tune's only clear win is **clarification discipline** (asks when a thread is +"date TBD"; stock missed `q04`). As-is, the fine-tune is **not** justified on +extraction. Step 3 must fix the year regression and clear baseline's 0.955 recall +while keeping clarification at 1.00 — otherwise the better play is stock + the +fine-tune's clarification behavior via prompting. + +### Step 3 — after gap-closing retrain (2026-06-09) — REGRESSED +Dataset grown 69 → 87 (+18 Oct–Dec 2026 explicit-date examples, disjoint from eval), +same 2-epoch recipe, re-quantized to Q4_K_M and republished. Re-eval (n=28): + +| Metric | Stock 31B | Fine-tune v1 (69) | **Fine-tune v2 (87, retrained)** | +| --- | --- | --- | --- | +| schema validity | 1.00 | 1.00 | **0.75** | +| event precision | 1.00 | 0.85 | **0.476** | +| start-exact recall | 0.955 | 0.773 | **0.455** | +| event F1 | 0.977 | 0.81 | **0.465** | +| end-exact | 1.00 | 1.00 | 1.00 | +| no-event accuracy | 1.00 | 1.00 | 1.00 | +| clarification recall | 0.75 | 1.00 | **0.75** | + +**The naive retrain made it worse, not better.** New failure modes: unparseable/empty +JSON (validity 1.0→0.75), duplicate events, hallucinated "Drive to …" events, +transposed/garbage years (`2062`, `2062-15:00:00`), and previously-passing relative +dates now empty. Cause: overfitting — 18 of 87 examples were near-identical far-future +templates, biasing a tiny dataset and degrading general formatting/extraction. + +## Conclusions & recommendation + +1. **Stock Gemma-4-31B is already strong** at this extraction (F1 0.98). The only + thing fine-tuning reliably *added* was clarification discipline (v1: 1.00 vs stock + 0.75) — and even that was lost in v2. +2. **Tiny-dataset SFT is fragile here.** v1 (69 ex) underperformed stock on dates; + v2 (87 ex) regressed hard. More data of the *same shape* hurt. +3. **Recommended path** (pick one): + - **Ship stock + prompt for clarification** — simplest; recover the one real win + without the regressions. (Lowest risk.) + - **If keeping a fine-tune:** rebuild the dataset much larger and *diverse* (not + template-heavy), drop to ~1 epoch with regularization, and **gate every retrain + on this eval** (only publish if it beats the current best). Consider a higher + quant (Q5/Q6) to rule out the `"206"`/`2062` digit corruption being quant-driven. +4. **Action — revert the live model.** v2 (worse) overwrote v1 in + `ParetoOptimal/gemma-4-cal-gguf`. Restore v1 (the better fine-tune) or point the + Space back at stock `unsloth/gemma-4-31B-it-GGUF` until a fine-tune *beats* the + eval baseline. + +**Bottom line: the eval did its job — it caught a regression before it reached users, +and it says the current fine-tune is not yet worth shipping over stock.** + +## Follow-up (2026-06-09) + +### Live model restored to v1 +v2 (regressed) was rolled back: `gemma-cal-Q4_K_M.gguf` in the repo was restored to the +v1 LFS object via a server-side `CommitOperationCopy` (no transfer, no GPU). Production +serves the better v1 again. + +### Dataset rebuilt larger + more diverse (69 → 122) +Added a diversity batch (`gen_new_seeds.MORE_SEEDS3`): varied date/time formats +(`10/15`, "the 3rd", "half past 7", "0900", "noon", "midnight"), reschedules, +cancellations, recurring, all-day, deadlines (EOD/midnight), past & hypothetical +(must NOT schedule), richer no-event & clarify, and varied image sources (ticket, +invite screenshot, notice). Goal: counter the template-heavy skew that overfit v2. +Verified valid + disjoint from `eval.jsonl`. + +### Eval-gating is now the publishing process +**No retrain publishes unless it beats the eval.** `training/gated_retrain.py`: +1. retrain on Modal → upload to a **staging** filename (`gemma-cal-staging-Q4_K_M.gguf`) + in the repo (production file untouched; mmproj skipped — `--skip-mmproj`); +2. eval the staging file (`modal_eval.py --model-file …`); +3. gate: `schema_validity ≥ 0.95`, `event_f1 ≥ 0.81`, `start-exact recall ≥ 0.773` + (defaults = the current best, v1) — tune via `--gate-f1/--gate-recall`; +4. **PASS** → promote staging → production via server-side `CommitOperationCopy` (free); + **FAIL** → delete staging, production unchanged. + +Run: `python training/gated_retrain.py [--epochs 1 --gate-f1 … --gate-recall …]`. + +### Step 4 — first eval-gated retrain (122 ex, 1 epoch) — GATE FAILED ✅ (protected prod) +The retrain scored **worse** than every prior version and the gate refused to publish: + +| Metric | Stock | v1 (live) | v3 staging (122, 1ep) | +| --- | --- | --- | --- | +| schema validity | 1.00 | 1.00 | **0.46** | +| event F1 | 0.977 | 0.81 | **0.214** | +| start-exact recall | 0.955 | 0.773 | **0.136** | +| no-event accuracy | 1.00 | 1.00 | 1.00 | +| clarification recall | 0.75 | 1.00 | 1.00 | + +>½ of outputs were unparseable; extraction collapsed. **Gate: FAIL → staging deleted, +production unchanged (still v1).** The gate worked exactly as intended. + +## Verdict (after 3 fine-tune attempts) +All three fine-tunes — v1 (69 ex / 2 ep), v2 (87 / 2 ep), v3 (122 / 1 ep) — **underperform +stock Gemma-4-31B**, and the larger runs broke JSON validity. Only the safety behaviors +(no-event, clarification) survive fine-tuning; extraction degrades. **QLoRA-on-31B-Q4 here +is fragile and not worth shipping over stock.** Recommended: serve **stock +`unsloth/gemma-4-31B-it-GGUF`** and recover the one fine-tune win (clarification) via the +prompt. Keep v1 as the published fine-tune for the "Well-Tuned" artifact, but don't route +production extraction through it. Revisit fine-tuning only with a substantially larger, more +varied dataset and a recipe that holds schema validity at 1.0 — gated, as now, on this eval. + +## Step 5 — quantization-penalty test (2026-06-09): quant EXONERATED +Hypothesis: maybe Q4 quantization (the `"206"`/`2062` digit bug) was tanking the fine-tune. +Tested the SAME fine-tuned weights (`gemma-cal-f16.gguf`, v2/87-ex — best fp16 still on the +volume) at three precisions on the 28-example eval (`training/modal_quant_eval.py`): + +| precision | schema validity | event F1 | start-exact recall | +| --- | --- | --- | --- | +| f16 (full) | 0.643 | 0.571 | 0.545 | +| Q8_0 | 0.679 | 0.565 | 0.591 | +| Q4_K_M | 0.75 | 0.465 | 0.455 | +| base (stock) | 1.00 | 0.977 | 0.955 | + +**Quantization is not the cause.** At full fp16 the fine-tune still scores validity 0.64 / F1 +0.57 — nowhere near base; validity is actually *lower* at f16 than Q4, so quant isn't breaking +the JSON. Precision buys only ~+0.1 F1/recall (Q4→Q8/f16), a fraction of the gap to base. The +degradation is the **SFT itself**, not the GGUF conversion. Step 2 (retrain at Q8 to beat base) +is **not pursued** — the gate would fail. (Caveat: v1's fp16 was overwritten, so this used v2; +a definitive v1 test needs a retrain, but the small quant lift makes a base-beating result +improbable.) + +### Final recommendation +A higher quant won't make the fine-tune beat base, and an automation agent (e.g. `ml-intern`) +doesn't change the binding constraints (near-ceiling base; small data; SFT degrades +instruction-following). **Serve stock `unsloth/gemma-4-31B-it-GGUF`** and recover the +clarification behavior via the system prompt; keep v1 as the "Well-Tuned" artifact. Only +revisit fine-tuning with a substantially larger, real, diverse dataset + a validity-preserving +recipe (low LR, few steps), always gated on this eval. + +## Real training data: SMCalFlow importer +`training/import_smcalflow.py` converts **SMCalFlow** (Microsoft Semantic Machines, **CC BY-SA +4.0**) calendar dialogues into our `ActionPlan` format. SMCalFlow encodes events as LISP +"dataflow" programs; the importer parses `CreatePreflightEventWrapper` turns, extracts +subject/start/location/attendees, and **resolves** date/time constructs (`Tomorrow`, `NextDOW`, +`MD`, `NumberPM`, `HourMinuteMilitary`, …) against a per-example reference `now` spread across +2026 — so relative dates become concrete, self-consistent targets (directly trains the failing +date/time skill, with varied 4-digit years). Conservative: only emits a row when a title AND an +explicit start time resolve (~7.5k usable turns from train+valid). + +- Run: `python training/import_smcalflow.py --limit 2000 --heldout 200` → writes + `training/data/smcalflow_train.jsonl` (+ `…_heldout.jsonl`). **Both are git-ignored** (CC BY-SA + share-alike vs this repo's Apache-2.0 → we don't commit/redistribute the derived data; the + importer code is ours) and **disjoint from `eval.jsonl`**. +- `train_qlora.py` now trains on `dataset.jsonl` **+** `smcalflow_train.jsonl` (when present). + `gated_retrain.py` therefore trains on real data, and still **only publishes if it beats the + gate** — so a bigger-but-worse model can't reach production. +- Attribution (required by CC BY-SA): *Semantic Machines et al., "Task-Oriented Dialogue as + Dataflow Synthesis," TACL 2020.* + +## Step 6 — eval-gated retrain on REAL data (2026-06-09): FAILED gate (worst yet) +Trained the 31B on **2,122 examples** (122 hand-authored + 2,000 real SMCalFlow), 1 epoch, +through `gated_retrain.py` with a beat-base gate (F1≥0.95, recall≥0.90). Result on the 28-ex eval: + +| Metric | base | v1 (live) | real-data (2,122 ex) | +| --- | --- | --- | --- | +| schema validity | 1.00 | 1.00 | **0.107** | +| event F1 | 0.977 | 0.81 | **0.000** | +| start-exact recall | 0.955 | 0.773 | **0.000** | + +~90% unparseable output, zero events extracted. **Gate FAIL → not promoted; production stays v1.** + +### Verdict across 4 fine-tunes (now incl. real data) +Scores **monotonically worsen with more training/data**: v1 (69 synth, F1 0.81) → v2 (87, 0.465) +→ v3 (122, 0.214) → real (2,122, 0.0). This is no longer a *data* problem — **the SFT recipe +itself degrades the model**, and more data makes it worse. Most likely root cause to investigate +*if* fine-tuning is ever revisited: a **train/inference chat-template mismatch** — `train_qlora.py` +formats with Unsloth's `get_chat_template("gemma")` while `llama-server` serves with the GGUF's +own `--jinja` template; if these differ for Gemma-4, training optimizes a format the server never +uses, and the divergence compounds with more steps (exactly the monotonic decay seen). Other +suspects: LR too high (2e-4) / catastrophic forgetting on a near-ceiling base. + +**Final, evidence-backed recommendation: serve stock `unsloth/gemma-4-31B-it-GGUF`** (best by far) +and recover clarification via the system prompt. Do NOT route production through any current +fine-tune. The eval-gate has now correctly rejected 2 bad retrains — keep it as the publish gate. + +## Step 7 — recipe fix + raw-output probe (2026-06-09): training stack implicated, fine-tuning HALTED +Fixed the suspected train/serve chat-template mismatch (PR #54): Gemma-4's native +`chat_template.jinja` uses a NEW `<|turn>role … ` format (no `` at all), +while training forced unsloth's legacy "gemma" template. `train_qlora.py` now formats with the +tokenizer's native template (hard `<|turn>` assert), masks loss to the assistant turn, LR 5e-5. +Retrained on the 2,122-example set through the gate: **validity 0.0 — gate FAIL** (production +stays v1, third bad retrain rejected). + +Diagnostics that pinpointed the cause: +- **GGUF template check (CPU, ~free):** our exported staging GGUF embeds the correct native + `<|turn>` template (16,934 chars, no ``) → train and serve formats are now + verifiably aligned. Template is exonerated as the remaining cause. +- **Raw-output probe (`/outputs/gemma-cal-staging-Q4_K_M.gguf`):** free generation emits pure + degenerate looping — `'Huddle — — — — — …'` to the token limit; constrained generation emits + 512 tokens of nothing. **The weights are destroyed, not misformatted.** + +With dataset (69→2,122), template (legacy/native), LR (2e-4/5e-5), and masking (on/off) all +varied, degradation always tracks training steps and ends in token-loop collapse. The remaining +common factor is **Unsloth's QLoRA path for Gemma-4-31B** (new architecture; training logs warn +`get_input_embeddings not auto-handled for Gemma4AudioModel`). **Fine-tuning is halted** until +that stack demonstrably works for this arch (or is replaced with plain transformers+PEFT). + +## Step 8 — improve served evals via prompt (stock + targeted SYSTEM additions) +Base's only eval misses are prompt-fixable: m03 dropped the 2nd event of a multi-event thread; +q04 didn't ask clarification on a "TBD" plan. Added two surgical SYSTEM lines (list every +distinct event separately; ask via needs_clarification when day/time is TBD). + +**Result: PERFECT SCORE — 1.0 on every metric (n=28, tp/fp/fn = 22/0/0).** + +| Metric | base (old prompt) | **base + new prompt** | +| --- | --- | --- | +| schema validity | 1.00 | **1.00** | +| event precision | 1.00 | **1.00** | +| start-exact recall | 0.955 | **1.00** | +| event F1 | 0.977 | **1.00** | +| no-event accuracy | 1.00 | **1.00** | +| clarification recall | 0.75 | **1.00** | + +Both misses fixed, nothing regressed. **This is the production configuration: stock +`unsloth/gemma-4-31B-it-GGUF` + the updated SYSTEM prompt.** (Set Space var +`MODEL_HF_REPO=unsloth/gemma-4-31B-it-GGUF`; the prompt ships with the app.) The "Well-Tuned" +artifact remains `ParetoOptimal/gemma-4-cal-gguf` (v1); any future fine-tune must beat THIS +1.0 baseline through the gate — i.e., match it and win on a harder, expanded eval set. + +## Step 9 — the E4B edge-model campaign (2026-06-10) +Re-aimed fine-tuning where it has headroom: a **Gemma-4 E4B (~8B)** edge model that runs without a +paid A100, gated against **stock E4B**. Six gated runs, each fixing a diagnosed failure (the fixed +recipe trained cleanly every time — validity 1.0 throughout, confirming the Step-7 breakage was +specific to the 31B path): + +| run | change | F1 | recall | clarify | eval | +| --- | --- | --- | --- | --- | --- | +| #1 | fixed recipe, 2,122 ex | 0.884 | 0.864 | 1.0 | n=28 | +| #2 | + weekday-in-prompt (+data regen) | 0.955 | 0.955 | 0.75 | n=28 | +| #3 | + next-DOW conflict filter (74 rows), 4× hand | **1.0** | **1.0** | 0.75 | n=28 | +| #4 | + TBD-clarify seeds, 8× hand | 0.93 | 0.909 | 1.0 | n=28 | +| #5 | clarify seeds, 4× hand | 0.93 | 0.909 | 1.0 | n=28 | +| — | **eval expanded 28→60** (50 events; jitter-resistant) | | | | | +| #6 | + Batch-7 seeds (next-DOW, "opens") | 0.97 | 0.96 | 1.0 | n=60 | +| stock E4B (weekday prompt) | | 0.97 | 0.96 | 1.0 | n=60 | + +Run #6 vs stock is an **exact statistical tie** (identical tp/fp/fn 48/1/2; both miss `e09` +"next Tuesday" — which resisted 7 explicit training seeds — and one "opens" case each). +Campaign side effects that improved the PRODUCT for every model: weekday-in-prompt, the +next-DOW convention cleanup, and the 60-example eval. + +## Step 10 — bare-prompt (internalization) test: no decisive gap +Dropped the system prompt for both models (identical minimal user content, same JSON-schema +constraint; `modal_eval.py --minimal-prompt`), measuring internalized task knowledge: + +| bare, n=60 | stock E4B | fine-tuned E4B | +| --- | --- | --- | +| schema validity | 0.967 | **1.0** | +| event F1 | **0.682** | 0.644 | +| start-exact recall | **0.60** | 0.56 | +| no-event accuracy | 0.70 | **0.80** | +| clarification recall | 0.50 | **0.625** | + +Small trade-offs both ways, within noise. **Verdict: at this data scale (139 hand + 2,000 +SMCalFlow) with QLoRA/1-epoch, the E4B fine-tune reaches PARITY with stock, not superiority** — +non-degraded, perfect validity everywhere, better bare-prompt discipline, slightly weaker bare +extraction. The strict-dominance gate therefore never auto-promoted it; the candidate GGUF +remains on the Modal volume (`/outputs/gemma-cal-e4b-staging-Q4_K_M.gguf`). Publishing it as +the project's edge model at parity is a **product decision** (zero quality cost; production +would serve our own fine-tune, fulfilling "Well-Tuned") — deliberately left to the owner, not +the gate. diff --git a/docs/gcal-verify.md b/docs/gcal-verify.md new file mode 100644 index 0000000000000000000000000000000000000000..53d36f0f7b4796266284f6a7bc0a59b45713c92f --- /dev/null +++ b/docs/gcal-verify.md @@ -0,0 +1,73 @@ +# Verifying the Google Calendar connection end-to-end + +> **Private-Space gotcha (the OAuth popup 404):** while the Space is private, +> `*.hf.space` URLs answer Hugging Face's 404 page for any request that lacks +> the signed access cookie. The app viewed EMBEDDED on huggingface.co +> authenticates its iframe with a short-lived signed URL, but the OAuth POPUP +> is a separate top-level window — when the subdomain cookie is missing or +> expired, **Connect opens a 404**. Fix: always open +> `https://paretooptimal-offgridschedula.hf.space` directly in its own tab +> (the redirect re-mints the cookie for the whole subdomain), then connect +> from there. Making the Space public would remove this entirely, at the cost +> of the deployed source tree becoming publicly browsable — deliberately NOT +> done (2026-06-12). + +Two layers of verification exist: + +1. **In-app, automatic** — on every page load, `wireGcal()` round-trips the stored + token to `POST /oauth2/check`, which makes one real (scope-compatible) Google + API call. The Step 2a row and the export-bar badge upgrade from + "✓ connected" to **"✓ connected · verified"** when Google answers; a + *definitive* rejection (revoked/invalid token) clears the stored token and + flips everything to "not connected". Transient problems (OAuth env unset, + network down, `SERVE=gradio` mode where FastAPI routes aren't served) never + destroy the token — the UI just stays at the local shape-check state. +2. **Scripted E2E** — `scripts/verify_gcal_e2e.py` proves the whole chain: + agent-extracted event → real push → API readback (title/location/start/ + reminder) → cleanup. + +## Browser loop (manual) + +1. Run locally with OAuth configured: + ``` + set GOOGLE_OAUTH_CLIENT_ID=... # a Google Cloud OAuth "Web application" client + set GOOGLE_OAUTH_CLIENT_SECRET=... + set SERVE=uvicorn + python app.py + ``` +2. Open http://localhost:7860, switch to **☁️ Online**, open Step 2a + **"🔗 Connect your calendar"** → click **Connect** on the Google row → + consent in the popup. The row flips to "✓ connected", then upgrades to + **"✓ connected · verified"** within ~1s (the `/oauth2/check` round-trip). +3. Paste the appointment text (the CANON sample in `tests/test_agent.py`) → + **Run the agents** → the export toolbar appears with the badge + **"Google: ✓ connected · verified"** next to the three buttons. +4. Click **Add to Google Calendar** → the status line shows the created event + link; open it: *Mon Jun 22 2026, 10:15–11:00, 112A West 72nd Street, + New York, NY 10023*, 60-minute reminder. +5. **Reload the page** → still verified, no re-prompt (the acceptance test for + "never asks again"). + +### Negative paths + +- Revoke access at https://myaccount.google.com/permissions → reload → the + check is definitive: token is cleared, every surface shows "not connected". +- Unset the OAuth env vars (or kill the network) → reload → stays at plain + "✓ connected" — transient failures never log the user out. +- Click **disconnect** in Step 2a → flips everywhere instantly. + +## Scripted loop + +One-time: after connecting in the browser, copy the `gcal_token` value from +DevTools → Application → Local Storage into a file (e.g. `tok.json` — it's +gitignored territory; don't commit it). + +``` +python scripts/verify_gcal_e2e.py --token-file tok.json --check-only # liveness only +python scripts/verify_gcal_e2e.py --token-file tok.json # full E2E +``` + +The full run pushes the CANON event with a nonce in the title (`[e2e-xxxxxx]`), +reads it back through the API, asserts summary/location/start-instant/reminder, +and deletes it (use `--keep` to inspect it in the calendar first). Exit code 0 += all checks passed. diff --git a/docs/hermes.md b/docs/hermes.md new file mode 100644 index 0000000000000000000000000000000000000000..61c06290e550e22ecf43ca388e6bc02fecbd93d0 --- /dev/null +++ b/docs/hermes.md @@ -0,0 +1,48 @@ +# The Hermes "grows-with-you" brain + +The agent's reasoning is pluggable through `INFERENCE_BASE_URL` (see `server/model.py`). Point it at a +**NousResearch Hermes** model served OpenAI-compatible and the whole pipeline uses it — **no code +change**. Hermes is a tool-calling Llama/Qwen fine-tune, a good fit for the autonomous daemon. + +## Serve Hermes locally (llama.cpp → "Llama Champion") + +```bash +# Hermes 3 Llama 3.1 8B (Q4_K_M) runs comfortably on a Mac with Metal. +llama-server -m ~/models/Hermes-3-Llama-3.1-8B.Q4_K_M.gguf \ + --host 127.0.0.1 --port 8080 --ctx-size 8192 --jinja # --jinja = tool-calling template +``` + +Point the backend at it: + +```bash +export INFERENCE_BASE_URL="http://127.0.0.1:8080/v1" +export INFERENCE_MODEL="hermes" +export USE_STUB_EXTRACTOR=0 +python app.py +``` + +`server/model.py` routes `complete_json` / `stream_complete_json` to the remote server when +`INFERENCE_BASE_URL` is set (`_remote_*`), still grammar-constraining the output to the ActionPlan +schema. (Ollama or vLLM also work — any OpenAI-compatible endpoint.) + +## "Grows with you" — the memory (`server/memory.py`) + +Durable facts/preferences personalize every extraction; they're injected into the prompt via +`recall()` (`server/agent.py::build_messages`) and shown/edited in the dashboard **Memory** tab. + +- **Learns automatically:** recurring event attendees become `contact` facts (`observe_plan`). +- **You can teach it:** add facts in the Memory tab — `"Dana is the soccer coach"`, + `"you decline Mondays"`, `"default location is Lincoln Elementary"`. +- **Hermes can update it itself:** set `HERMES_TOOLS=1` and the remote path advertises a `remember` + tool (`server/tools.py`); the model calls it mid-run to save durable facts, then returns the + ActionPlan. The tool-call loop is in `server/model.py::_remote_complete_json` (round-trip logic + + tests in `server/tools.py` / `tests/test_tools.py`). Requires a tool-calling server (`--jinja`). +- Stored at `MEMORY_PATH` (set it to a real path like `~/.offgrid/agent_memory.json`, not `/tmp`). + +Over time the model resolves nicknames, applies your preferences to conflicts, and needs fewer +clarifications — the "grows with you" behavior. + +## Where it runs +The Hermes brain + memory live wherever the autonomous backend runs — the **Mac daemon** +(`scripts/setup_mac.sh`), an Android phone via Termux (`INFERENCE_BASE_URL` → a local `llama-server`), +or a cloud box (if you capture from email/Slack/Telegram instead of iMessage). diff --git a/docs/on-device.md b/docs/on-device.md new file mode 100644 index 0000000000000000000000000000000000000000..7be2bf81cdb2a833319ab02cedc6fabc3e0737c7 --- /dev/null +++ b/docs/on-device.md @@ -0,0 +1,54 @@ +# Running on a cell phone (on-device or thin-client) + +"Runs on a cell phone" can mean two things; the app supports both via one env switch. + +## The inference switch + +`server/model.py` reads `INFERENCE_BASE_URL`: + +- **Unset (default):** the GGUF is loaded in-process via `llama-cpp-python` (the Space / a laptop). +- **Set:** generation is delegated to a remote **OpenAI-compatible / llama.cpp server** at that URL. + Same agent code, different inference location. + +```bash +export INFERENCE_BASE_URL="http://127.0.0.1:8080/v1" # a llama-server on the phone +export INFERENCE_API_KEY="..." # optional +export INFERENCE_MODEL="gemma-e4b" # optional label +``` + +So "on the phone" = run a `llama-server` **on the device** and point the agent at `127.0.0.1`. + +## On-device model profile (Gemma E4B edge) + +A 31B Q4 GGUF (~18–20 GB) needs a GPU and will not run on a phone. Use the lightweight **Gemma E4B** +edge variant (see [PLAN.md](../PLAN.md) and the README *Accuracy upgrade* section), with a small +context window: + +```bash +export MODEL_REPO="" +export MODEL_FILE="" +export N_CTX=4096 # keep the KV cache small on a phone +export N_GPU_LAYERS=0 # CPU; on a Mac use Metal layers instead +``` + +## Android (Termux) — genuinely on-device + +```bash +pkg install python git cmake clang +git clone && cd imessage-calendar-agent +pip install -r requirements-ci.txt llama-cpp-python # CPU build +# Option 1: run the whole app (UI + /agent) on the phone +USE_STUB_EXTRACTOR=0 python app.py # http://127.0.0.1:7860 +# Option 2: run only a llama-server and point a client/app at it +# llama-server -m --port 8080 +# then set INFERENCE_BASE_URL=http://127.0.0.1:8080/v1 +``` + +Expect multi-second latency per request on phone CPU — keep `N_CTX` small and threads short. + +## iOS — the honest limit + +iOS does **not** allow background message access or a persistent background LLM server. You cannot +run an autonomous on-device agent for iMessage on an iPhone. The supported iOS path is the +foreground **Shortcut** in [automations.md](./automations.md), optionally pointing at a remote +`INFERENCE_BASE_URL` for the model. diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 0000000000000000000000000000000000000000..216d647928022c93cf4d9854c33eeaa7f447ebb7 --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,16 @@ +# Minimal deps for CI / local testing. The app never imports llama_cpp or the +# Google libs at module load (both are lazy), and tests run in stub mode +# (USE_STUB_EXTRACTOR=1), so we deliberately exclude: +# - llama-cpp-python (slow source build on CI; real inference is tested on the Space) +# - google-api-python-client / google-auth-* (only used by the optional GCal push) +gradio>=6.0 +pandas>=2.0 +fastapi>=0.115 +uvicorn[standard]>=0.30 +pydantic>=2.7 +python-dotenv>=1.0 +requests>=2.32 +huggingface_hub>=0.24 +icalendar>=5.0 +python-dateutil>=2.9 +pytest>=8.0 diff --git a/requirements-docker.txt b/requirements-docker.txt new file mode 100644 index 0000000000000000000000000000000000000000..391afd49d6f40f5d719e0712ae72872f8cfe5131 --- /dev/null +++ b/requirements-docker.txt @@ -0,0 +1,24 @@ +# Runtime deps for the dedicated-GPU Docker Space. +# Excludes: llama-cpp-python (compiled WITH CUDA in the Dockerfile), the cu124 +# prebuilt index + nvidia-*-cu12 libs (the CUDA devel base provides the toolkit), +# and `spaces` (ZeroGPU only — its absence makes server/model.py's gpu decorator a +# no-op so llama.cpp runs directly on the always-attached dedicated GPU). +# [mcp] extra exposes named Gradio API endpoints as Model Context Protocol tools +# — same MCP surface as the Gradio-SDK Space (server/mcp_tools.py, app.py). +gradio[mcp]>=6.0 +pandas>=2.0 +fastapi>=0.115 +uvicorn[standard]>=0.30 +pydantic>=2.7 +python-dotenv>=1.0 +huggingface_hub>=0.24 +requests>=2.32 +icalendar>=5.0 +python-dateutil>=2.9 +pillow-heif>=0.16 +google-api-python-client>=2.130 +google-auth-oauthlib>=1.2 +google-auth-httplib2>=0.2 +# Agent tab: smolagents drives this Space's own MCP tools with a MiniCPM +# planner (lazy-imported; the stub/scripted path never touches it). +smolagents[mcp,openai]==1.26.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..4fa9e6814962b9f1a274122f8ca343c7ab279397 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,47 @@ +# --- Space runtime (Gradio SDK + ZeroGPU) --- +# [mcp] extra exposes named Gradio API endpoints as Model Context Protocol tools +# so any MCP-aware agent (Claude Desktop, Cursor, etc.) can call this Space's +# extract_events / make_ics / check_conflicts — see server/mcp_tools.py + app.py. +gradio[mcp]>=6.0 +pandas>=2.0 # used directly by the Activity dashboard chart +fastapi>=0.115 +uvicorn[standard]>=0.30 +pydantic>=2.7 +python-dotenv>=1.0 +huggingface_hub>=0.24 +requests>=2.32 +spaces>=0.30 # ZeroGPU: @spaces.GPU dynamic GPU allocation + +# --- llama.cpp inference (Llama Champion), GPU build --- +# CUDA prebuilt wheel so layers offload to the ZeroGPU GPU (n_gpu_layers=-1). +# NOTE: the RTX Pro 6000 Blackwell is sm_120 — if the cu124 wheel lacks Blackwell +# kernels, build from source against CUDA 12.8: +# CMAKE_ARGS="-DGGML_CUDA=on -DCMAKE_CUDA_ARCHITECTURES=120" pip install llama-cpp-python +--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 +# Pin to the newest version with a prebuilt cu124 cp310 wheel. With >= pip grabs a +# newer PyPI *sdist* and compiles from source (slow, and mismatches the base's CUDA). +llama-cpp-python==0.3.19 +# CUDA userspace libs the prebuilt wheel dlopens (ZeroGPU env lacks libcudart.so.12). +# server/model.py::_preload_cuda_libs loads these RTLD_GLOBAL before importing llama_cpp. +nvidia-cuda-runtime-cu12; platform_system == "Linux" +nvidia-cublas-cu12; platform_system == "Linux" +nvidia-cuda-nvrtc-cu12; platform_system == "Linux" + +# --- calendar output --- +icalendar>=5.0 +python-dateutil>=2.9 + +# --- vision input: transcode iPhone HEIC attachments to JPEG --- +pillow-heif>=0.16 + +# --- optional Google Calendar bonus --- +google-api-python-client>=2.130 +google-auth-oauthlib>=1.2 +google-auth-httplib2>=0.2 + +# --- Agent tab: smolagents drives the Space's own MCP tools with a MiniCPM +# planner (lazy-imported; stub/scripted path and CI never touch it). --- +smolagents[mcp,openai]==1.26.0 + +# NOTE: training deps (unsloth, trl, transformers, bitsandbytes) live in +# training/requirements-train.txt — they are NOT installed in the Space. diff --git a/scripts/setup_mac.sh b/scripts/setup_mac.sh new file mode 100644 index 0000000000000000000000000000000000000000..77af58ec646e8e3cafbb91aa03204da9278958fb --- /dev/null +++ b/scripts/setup_mac.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# One-time setup for the Mac always-on daemon (Scenario 1): +# Hermes (llama-server) + backend (autonomous) + collector, as launchd jobs. +# +# Prereqs you provide: +# - llama.cpp built (llama-server on PATH, or pass LLAMA_SERVER=/path/to/llama-server) +# - a Hermes GGUF (e.g. Hermes-3-Llama-3.1-8B Q4_K_M) -> pass MODEL_GGUF=/path +# - Google OAuth: credentials.json (+ token.json after first auth) in the repo dir +# - Full Disk Access for the python binary (the script prints how) +# +# Usage: +# INGEST_TOKEN=... MODEL_GGUF=~/models/hermes-3-8b-q4.gguf ./scripts/setup_mac.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +HOME_DIR="$HOME" +PYTHON="${PYTHON:-$(command -v python3)}" +LLAMA_SERVER="${LLAMA_SERVER:-$(command -v llama-server || true)}" +MODEL_GGUF="${MODEL_GGUF:?set MODEL_GGUF=/path/to/hermes.gguf}" +INGEST_TOKEN="${INGEST_TOKEN:?set INGEST_TOKEN=... (same value you use elsewhere)}" +LA="$HOME_DIR/Library/LaunchAgents" + +[ -n "$LLAMA_SERVER" ] || { echo "llama-server not found; set LLAMA_SERVER=/path"; exit 1; } +mkdir -p "$LA" "$HOME_DIR/.offgrid" "$HOME_DIR/Library/Logs" + +install_plist() { + local name="$1" + sed -e "s|__PYTHON__|$PYTHON|g" \ + -e "s|__REPO__|$REPO|g" \ + -e "s|__HOME__|$HOME_DIR|g" \ + -e "s|__LLAMA_SERVER__|$LLAMA_SERVER|g" \ + -e "s|__MODEL_GGUF__|$MODEL_GGUF|g" \ + -e "s|__INGEST_TOKEN__|$INGEST_TOKEN|g" \ + "$REPO/deploy/launchd/$name" > "$LA/$name" + launchctl unload "$LA/$name" 2>/dev/null || true + launchctl load "$LA/$name" + echo "loaded $name" +} + +"$PYTHON" -m pip install -q -r "$REPO/requirements-ci.txt" # runtime deps (no GPU model needed on Mac) + +install_plist com.offgrid.hermes.plist +install_plist com.offgrid.backend.plist +install_plist com.offgrid.collector.plist + +cat < llama-server (Hermes) on :8080 + com.offgrid.backend -> Gradio UI + /agent + /ingest on :7860 (AUTONOMOUS, Hermes brain) + com.offgrid.collector -> reads chat.db -> /ingest + +ONE MANUAL STEP: grant Full Disk Access to the python binary so the collector can read chat.db: + System Settings > Privacy & Security > Full Disk Access > + -> $PYTHON +Then: launchctl kickstart -k gui/\$(id -u)/com.offgrid.collector + +Dashboard: http://127.0.0.1:7860 (Activity = live runs, Memory = what it learned) +Logs: ~/Library/Logs/offgrid-*.log +Triggers on YOUR sent/accepted iMessages (TRIGGER_ON=outgoing). Set TRIGGER_ON=any to widen. +EOF diff --git a/scripts/start_space.sh b/scripts/start_space.sh new file mode 100644 index 0000000000000000000000000000000000000000..e68c6a6e7a34407c55024de50500dac4e83a9228 --- /dev/null +++ b/scripts/start_space.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Launch the official llama.cpp server + the agent app (Docker GPU Space). +# llama-server downloads the GGUF from HF on first run and serves it on :8080; +# the app calls it via INFERENCE_BASE_URL=http://127.0.0.1:8080/v1. +set -u + +# UI-only / preview mode: in stub mode there's no model, so skip llama-server +# entirely (otherwise it would download the ~20GB GGUF and fail on a CPU box). +# Lets the Space run the full UI for free on cpu-basic. See PLAN / docs. +if [ "${USE_STUB_EXTRACTOR:-0}" = "1" ]; then + echo "[start] UI-only (USE_STUB_EXTRACTOR=1) — skipping llama-server" + exec python3 app.py +fi + +LS="$(command -v llama-server || echo /app/llama-server)" +# The official binary's sibling .so (libllama-server-impl.so) lives next to it in +# /app; we run from /srv, so add its dir to the loader path. +export LD_LIBRARY_PATH="$(dirname "$LS"):/app:${LD_LIBRARY_PATH:-}" +echo "[start] using llama-server at: $LS (LD_LIBRARY_PATH=$LD_LIBRARY_PATH)" + +# Model selection: MODEL_FILE (explicit filename in MODEL_HF_REPO) is preferred — +# the repo holds multiple Q4_K_M GGUFs (31B + E4B edge), so the `-hf repo:quant` +# shorthand is ambiguous there. Falls back to -hf REPO:QUANT when MODEL_FILE unset. +if [ -n "${MODEL_FILE:-}" ]; then + echo "[start] model: ${MODEL_HF_REPO}/${MODEL_FILE} (explicit file; downloads on first run)" + MODEL_PATH="$(python3 -c "from huggingface_hub import hf_hub_download; print(hf_hub_download('${MODEL_HF_REPO}', '${MODEL_FILE}'))")" + MODEL_ARGS="-m $MODEL_PATH" +else + echo "[start] model: ${MODEL_HF_REPO}:${MODEL_QUANT:-Q4_K_M} (downloads on first run)" + MODEL_ARGS="-hf ${MODEL_HF_REPO}:${MODEL_QUANT:-Q4_K_M}" +fi + +# Vision: download the mmproj projector and pass --mmproj so llama-server accepts +# image_url inputs (screenshots/flyers). MMPROJ_REPO lets the projector come from a +# different repo than the LLM (the E4B edge model uses the base E4B's projector, +# not the 31B mmproj stored alongside it). Falls back to text-only if unavailable. +MMPROJ_ARG="" +if [ -n "${MMPROJ_FILE:-}" ]; then + MMPROJ_REPO="${MMPROJ_REPO:-$MODEL_HF_REPO}" + echo "[start] fetching mmproj ${MMPROJ_REPO}/${MMPROJ_FILE} for vision..." + MMPROJ_PATH="$(python3 -c "from huggingface_hub import hf_hub_download; print(hf_hub_download('${MMPROJ_REPO}', '${MMPROJ_FILE}'))" 2>/dev/null || true)" + if [ -n "$MMPROJ_PATH" ]; then + MMPROJ_ARG="--mmproj $MMPROJ_PATH" + echo "[start] mmproj ready: $MMPROJ_PATH" + else + echo "[start] mmproj download failed -> text-only" + fi +fi + +# -ngl 999 offloads all layers to the GPU; --jinja enables the chat/tool template. +"$LS" $MODEL_ARGS \ + --host 127.0.0.1 --port 8080 \ + -ngl 999 -c 8192 --jinja $MMPROJ_ARG & +LLAMA_PID=$! + +# Optional second llama-server: the Agent tab's MiniCPM planner. OFF unless +# PLANNER_HF_REPO+PLANNER_FILE are set. VRAM note: E4B Q4 (~5GB) + MiniCPM-8B +# Q4 (~5GB) + KV is tight on a 16GB T4 — tune PLANNER_NGL (default 999; lower +# it for partial offload, planner outputs are short) or use the 1B variant +# (openbmb/MiniCPM5-1B-GGUF / MiniCPM5-1B-Q4_K_M.gguf). +# PLANNER_CTX (default 8192, matching the main model): a multi-step agent run +# accumulates the tool schemas + task + thread + each step's observations, so +# 4096 overflows on real threads ("request (4142 tokens) exceeds context"). +if [ -n "${PLANNER_HF_REPO:-}" ] && [ -n "${PLANNER_FILE:-}" ]; then + echo "[start] planner: ${PLANNER_HF_REPO}/${PLANNER_FILE} on :${PLANNER_PORT:-8081}" + PLANNER_PATH="$(python3 -c "from huggingface_hub import hf_hub_download; print(hf_hub_download('${PLANNER_HF_REPO}', '${PLANNER_FILE}'))")" + "$LS" -m "$PLANNER_PATH" \ + --host 127.0.0.1 --port "${PLANNER_PORT:-8081}" \ + -ngl "${PLANNER_NGL:-999}" -c "${PLANNER_CTX:-8192}" --jinja & + echo "[start] planner launching (PLANNER_BASE_URL should be http://127.0.0.1:${PLANNER_PORT:-8081}/v1)" +fi + +echo "[start] waiting for llama-server health (model download can take minutes)..." +for i in $(seq 1 900); do + if ! kill -0 "$LLAMA_PID" 2>/dev/null; then + echo "[start] ERROR: llama-server exited early"; break + fi + if curl -sf http://127.0.0.1:8080/health >/dev/null 2>&1; then + echo "[start] llama-server ready after ~$((i*2))s"; break + fi + sleep 2 +done + +echo "[start] launching app (UI + /agent) -> INFERENCE_BASE_URL=$INFERENCE_BASE_URL" +exec python3 app.py diff --git a/scripts/verify_gcal_e2e.py b/scripts/verify_gcal_e2e.py new file mode 100644 index 0000000000000000000000000000000000000000..d1ffacc69ade33ee109a923239977416c1a9ef63 --- /dev/null +++ b/scripts/verify_gcal_e2e.py @@ -0,0 +1,159 @@ +"""End-to-end Google Calendar verification: agent-extracted event -> real push +-> API readback -> cleanup. Manual-run only (needs a real per-user token and +the google libs); never imported by CI. + +One-time token bootstrap: connect in the app (Step 2a), then DevTools -> +Application -> Local Storage -> copy the `gcal_token` value into a file. + + python scripts/verify_gcal_e2e.py --token-file tok.json [--check-only] [--keep] +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import uuid +from datetime import datetime +from pathlib import Path + +os.environ.setdefault("USE_STUB_EXTRACTOR", "1") +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dateutil import parser as dtparser # noqa: E402 + +from calendar_out import gcal # noqa: E402 + +# The canonical appointment-confirmation sample (kept in sync with +# tests/test_agent.py::CANON — copied because CI test modules aren't a package). +CANON = ( + "Thank you for scheduling your appointment with Primary Care of Manhattan. " + "We look forward to seeing you!\n" + "\n" + "Date: Monday, June 22, 2026\n" + "Time: 10:30 AM\n" + "Duration: Approx. 30–45 min\n" + "(Please arrive 15 minutes early to complete intake forms)\n" + "\n" + "\U0001f4cd 112A West 72nd Street\n" + "New York, NY 10023\n" + "(Upper West Side — 72nd & Columbus)\n" +) +EXPECT_START = "2026-06-22T10:15:00" +EXPECT_LOCATION = "112A West 72nd Street, New York, NY 10023" +EXPECT_REMINDER = 60 + +_results: list[tuple[bool, str]] = [] + + +def _check(ok: bool, label: str) -> bool: + _results.append((ok, label)) + print(("PASS " if ok else "FAIL ") + label) + return ok + + +def _bare_creds(token_json: str): + """Access-token-only Credentials: works for API calls while the token is + fresh, but cannot refresh.""" + from google.oauth2.credentials import Credentials + + return Credentials(token=json.loads(token_json).get("token")) + + +def _enable_bare_token_fallback(token: str) -> None: + """Space-minted tokens may lack client_secret (it exists only in the + Space's env, and GOOGLE_OAUTH_CLIENT_SECRET isn't set locally). google-auth + then refuses to build refresh-capable creds — fall back to using the bare + access token directly (valid ~1h after minting).""" + try: + gcal._creds_from_token_json(token) + except ValueError as e: + if "client_secret" not in str(e): + raise + print("note: token has no client_secret and none in env -> using the " + "access token directly (no refresh; re-mint if it expires)") + gcal._creds_from_token_json = _bare_creds + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--token-file", default=os.environ.get("GCAL_TOKEN_FILE", "")) + ap.add_argument("--check-only", action="store_true", + help="only liveness-check the token; no event is created") + ap.add_argument("--keep", action="store_true", + help="leave the test event in the calendar (default: delete)") + ap.add_argument("--calendar-id", default="primary") + args = ap.parse_args() + + if not args.token_file or not Path(args.token_file).exists(): + print("ERROR: pass --token-file (or set GCAL_TOKEN_FILE) pointing at the " + "localStorage gcal_token JSON") + return 1 + token = Path(args.token_file).read_text(encoding="utf-8").strip() + _enable_bare_token_fallback(token) + + # 1. liveness check (same call the /oauth2/check endpoint makes) + res = gcal.check_token(token) + if not _check(res["ok"], f"check_token: {res if not res['ok'] else 'token is live'}"): + return 1 + if res.get("refreshed_token"): + token = res["refreshed_token"] + Path(args.token_file).write_text(token, encoding="utf-8") + print(" (token was refreshed; token file updated)") + if args.check_only: + return 0 + + # 2. agent extraction (stub mode = deterministic) + invariants + from server.agent import run_agent + + plan = run_agent(CANON, now=datetime(2026, 6, 12, 9, 0)) + if not _check(len(plan.events) == 1, f"agent extracted 1 event (got {len(plan.events)})"): + return 1 + ev = plan.events[0] + _check(ev.start == EXPECT_START, f"start == {EXPECT_START} (got {ev.start})") + _check(ev.location == EXPECT_LOCATION, f"location == {EXPECT_LOCATION!r} (got {ev.location!r})") + _check(ev.reminder_minutes == EXPECT_REMINDER, + f"reminder == {EXPECT_REMINDER} (got {ev.reminder_minutes})") + + # 3. push with a nonce title so readback/cleanup can never touch a real event + nonce = f"e2e-{uuid.uuid4().hex[:6]}" + ev.title = f"{ev.title} [{nonce}]" + links = gcal.push_events_with_token(token, [ev], calendar_id=args.calendar_id) + _check(bool(links and links[0]), f"push returned an event link: {links[0] if links else '-'}") + + # 4. read it back through the API and compare what actually landed + from googleapiclient.discovery import build + + creds = gcal._creds_from_token_json(token) + svc = build("calendar", "v3", credentials=creds) + found = svc.events().list( + calendarId=args.calendar_id, q=nonce, singleEvents=True, + timeMin="2026-06-21T00:00:00Z", timeMax="2026-06-23T00:00:00Z", + ).execute().get("items", []) + if _check(len(found) == 1, f"readback found exactly 1 event (got {len(found)})"): + got = found[0] + _check(nonce in got.get("summary", ""), f"summary carries nonce (got {got.get('summary')!r})") + _check(got.get("location") == EXPECT_LOCATION, + f"location landed (got {got.get('location')!r})") + want = dtparser.isoparse(gcal._dt_field(ev.start)["dateTime"]) + have = dtparser.isoparse(got["start"]["dateTime"]) + # compare instants — the API echoes in the calendar's zone + _check(want == have or want.replace(tzinfo=None) == have.replace(tzinfo=None), + f"start instant matches (sent {want.isoformat()}, got {have.isoformat()})") + overrides = (got.get("reminders") or {}).get("overrides") or [] + _check(any(o.get("minutes") == EXPECT_REMINDER for o in overrides), + f"reminder override {EXPECT_REMINDER} min landed (got {overrides})") + if not args.keep: + svc.events().delete(calendarId=args.calendar_id, eventId=got["id"]).execute() + print(f" (test event {got['id']} deleted)") + else: + print(f" (kept: {got.get('htmlLink')})") + + failures = [label for ok, label in _results if not ok] + print(f"\n{'PASS' if not failures else 'FAIL'}: " + f"{len(_results) - len(failures)}/{len(_results)} checks passed") + return 0 if not failures else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/server/agent.py b/server/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..d28e341aed28c4f2b851eb03746cc9323dc51b50 --- /dev/null +++ b/server/agent.py @@ -0,0 +1,475 @@ +"""The scheduling agent: thread (+images) -> validated ActionPlan. + +Replaces the old one-shot extractor. The model reasons over a whole conversation +and emits a single constrained ActionPlan: events, conflicts (vs the user's +existing calendar), proposed alternative times, a reply draft, and an optional +clarification question. Output is grammar-constrained so it always parses. +""" +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timedelta +from typing import Optional + +from dateutil import parser as dtparser +from pydantic import ValidationError + +from . import events, memory +from .schema import ActionPlan, Event + +SYSTEM = ( + "You are a scheduling assistant reading a chat conversation (text, and sometimes images " + "such as screenshots, invites, or flyers). Decide what calendar action is warranted and " + "return ONLY a JSON object matching the ActionPlan schema:\n" + "- reasoning: one or two sentences of why.\n" + "- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current " + "datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread " + "often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n" + "- title: a short, self-contained calendar title summarizing the action and subject " + "(e.g. \"Pick up Priya — Terminal 4\", \"Mia — dental cleaning\"), not a quote of the " + "message.\n" + "- location: the venue or address when one is mentioned (join multi-line addresses into one " + "string); null otherwise.\n" + "- end: when a duration is stated (\"Duration: 30–45 min\", \"for 2 hours\", \"runs 90 " + "minutes\"), set end = start + duration, using the LOWER bound of a range; when an end time " + "is stated (\"7-9pm\"), use it; otherwise null. Never guess a duration that was not given.\n" + "- early arrival: if told to arrive N minutes early (\"please arrive 15 minutes early\"), " + "start = the arrival time (stated time minus N); end still counts from the STATED time; put " + "the stated time and the reason in notes.\n" + "- reminder_minutes: a stated lead time always wins (\"remind me 2 hours before\" -> 120); " + "otherwise 60 for doctor/medical visits, 30 for parties, 45 for carpools or school events; " + "for anything else use your judgment.\n" + "- conflicts: for any event that clashes with the provided existing calendar, the event_index, " + "what it clashes with, and severity (overlap|adjacent|tight).\n" + "- proposed_times: ISO 8601 alternatives when there is a conflict.\n" + "- reply_draft: a short, natural reply the user could send back.\n" + "- needs_clarification: a question if the plan is ambiguous, else null. If something should " + "be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime " + "soon\"), leave events empty and ASK via needs_clarification instead of guessing.\n" + "Do not invent events that were not discussed." +) + + +def _existing_block(existing: list[Event]) -> str: + if not existing: + return "Existing calendar: (none provided)" + lines = [f"- {e.title}: {e.start}..{e.end or e.start}" for e in existing] + return "Existing calendar:\n" + "\n".join(lines) + + +def build_messages( + thread: str, + now: datetime, + existing: list[Event], + images: Optional[list[str]] = None, + memory_block: Optional[str] = None, +) -> list[dict]: + """Build chat messages. ``images`` are base64 data URIs (used from phase 3). + ``memory_block`` is the caller's recall block (per-user/localStorage memory); + when None, fall back to the server-side global memory.recall().""" + mem = memory.recall() if memory_block is None else memory_block + mem_block = f"{mem}\n\n" if mem else "" + text = ( + f"Current datetime: {now.strftime('%A')}, {now.isoformat()}\n" + f"{_existing_block(existing)}\n\n" + f"{mem_block}" + f"Conversation:\n{thread}\n\n" + "Return the ActionPlan JSON now." + ) + if not images: + return [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": text}, + ] + # Multimodal content format understood by llama.cpp vision chat handlers. + content = [{"type": "text", "text": text}] + for uri in images: + content.append({"type": "image_url", "image_url": {"url": uri}}) + return [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": content}, + ] + + +def run_agent( + thread: str, + now: Optional[datetime] = None, + existing: Optional[list[Event]] = None, + images: Optional[list[str]] = None, + memory_block: Optional[str] = None, +) -> ActionPlan: + now = now or datetime.now() + existing = existing or [] + + with events.run_scope("analyze"): + if images: + events.emit("vision", f"reading {len(images)} image(s)", images=len(images)) + + if os.environ.get("USE_STUB_EXTRACTOR") == "1": + plan = _stub_plan(thread, now) + else: + from .model import complete_json # lazy: avoids llama.cpp in stub mode + + raw = complete_json( + build_messages(thread, now, existing, images, memory_block), + json_schema=ActionPlan.model_json_schema(), + ) + plan = apply_text_rules(thread, _polish_titles(thread, _parse_plan(raw))) + # Global path only: with client-owned (per-user) memory, the UI merges + # learned contacts itself (memory.learn_from_plan) so we don't pollute the + # shared server file. + if memory_block is None: + memory.observe_plan(plan) # grows-with-you: learn recurring contacts + events.emit("decision", f"{len(plan.events)} event(s) detected", events=len(plan.events)) + return plan + + +def _parse_plan(raw: str) -> ActionPlan: + try: + return ActionPlan(**json.loads(raw)) + except (json.JSONDecodeError, ValidationError): + # Grammar should prevent this; degrade to an empty plan rather than 500. + return ActionPlan(reasoning="Could not parse model output.") + + +# --------------------------------------------------------------------------- # +# Title polish (optional second pass, TITLE_POLISH=1): rewrite each extracted +# event's title into a calendar-ready action+subject summary. The extraction +# pass already gets a title style instruction; this pass gives the model one +# focused job, which helps on echo-prone inputs (flyers, forwarded notices). +# --------------------------------------------------------------------------- # +TITLE_SYSTEM = ( + "You rewrite calendar event titles. Given a conversation and the events extracted from " + "it, return ONLY a JSON object {\"titles\": [...]} with exactly one title per event, in " + "the same order. Each title is a short, self-contained calendar entry summarizing the " + "action and subject (e.g. \"Pick up Priya — Terminal 4\", \"Mia — dental cleaning\"). " + "Keep names and places; drop filler, hype and sender wording. Never add facts that are " + "not in the conversation." +) + +TITLES_SCHEMA = { + "type": "object", + "properties": {"titles": {"type": "array", "items": {"type": "string"}}}, + "required": ["titles"], +} + + +def build_title_messages(thread: str, events: list[dict]) -> list[dict]: + """Messages for the polish pass. ``events`` are Event-shaped dicts.""" + lines = [ + f"{i + 1}. {e.get('title') or '(untitled)'} @ {e.get('start')}" + + (f" ({e['location']})" if e.get("location") else "") + for i, e in enumerate(events) + ] + text = ( + f"Conversation:\n{thread}\n\n" + "Extracted events:\n" + "\n".join(lines) + "\n\n" + "Return the titles JSON now." + ) + return [ + {"role": "system", "content": TITLE_SYSTEM}, + {"role": "user", "content": text}, + ] + + +def merge_titles(plan: ActionPlan, raw: str) -> ActionPlan: + """Apply a polish-pass response onto the plan; on any mismatch keep the + original titles (the polish pass must never be able to lose an event).""" + try: + titles = json.loads(raw).get("titles") + except (json.JSONDecodeError, AttributeError): + return plan + if not isinstance(titles, list) or len(titles) != len(plan.events): + return plan + for ev, title in zip(plan.events, titles): + if isinstance(title, str) and title.strip(): + ev.title = title.strip()[:80] + return plan + + +def apply_text_rules(thread: str, plan: ActionPlan) -> ActionPlan: + """Deterministic guarantees for explicitly-communicated logistics (same + philosophy as conflict detection: don't leave must-hold rules to the model). + Single-event plans only — multi-event threads keep per-event model judgment. + + - "arrive N minutes early" -> start = arrival time, but ONLY when the model + demonstrably did not shift already (its start equals the stated time). + - end = STATED time + stated duration: a self-shifting model often counts + the duration from the arrival time (10:15+30=10:45 instead of 11:00). + - reminder: an explicit stated lead time always wins; else type defaults + (medical 60 / party 30 / carpool-school 45); else the model's judgment. + """ + if len(plan.events) != 1: + return plan + ev = plan.events[0] + early = _EARLY_RE.search(thread) + stated = _find_time(thread) + if early and stated: + try: + start_dt = datetime.fromisoformat(ev.start) + except ValueError: + start_dt = None + if start_dt is not None: + mins = int(early.group(1)) + appt_dt = start_dt.replace(hour=stated[0], minute=stated[1]) + if start_dt == appt_dt: # model did not shift -> start at arrival + start_dt = appt_dt - timedelta(minutes=mins) + ev.start = start_dt.isoformat() + if start_dt == appt_dt - timedelta(minutes=mins): + # The event covers arrival (we or the model shifted it): anchor + # the END to the stated time + stated duration, and make sure + # the official time survives in the notes. + duration = _find_duration_minutes(thread) + if duration: + ev.end = (appt_dt + timedelta(minutes=duration)).isoformat() + hhmm = appt_dt.strftime("%H:%M") + if hhmm not in (ev.notes or ""): + note = f"Appointment at {hhmm}; arrive {mins} min early" + ev.notes = f"{ev.notes} — {note}" if ev.notes else note + m = _REMIND_EXPLICIT_RE.search(thread) + if m: + n = int(m.group(1)) + ev.reminder_minutes = n * 60 if m.group(2).lower().startswith("h") else n + elif _MEDICAL_RE.search(thread): + ev.reminder_minutes = 60 + elif _PARTY_RE.search(thread): + ev.reminder_minutes = 30 + elif _CARPOOL_SCHOOL_RE.search(thread): + ev.reminder_minutes = 45 + return plan + + +def _polish_titles(thread: str, plan: ActionPlan) -> ActionPlan: + if not plan.events or os.environ.get("TITLE_POLISH") != "1": + return plan + from .model import complete_json # lazy: avoids llama.cpp in stub mode + + try: + raw = complete_json( + build_title_messages(thread, [e.model_dump() for e in plan.events]), + json_schema=TITLES_SCHEMA, + max_tokens=256, + ) + except Exception: # noqa: BLE001 polish is best-effort, never fatal + return plan + return merge_titles(plan, raw) + + +def run_agent_stream( + thread: str, + now: Optional[datetime] = None, + existing: Optional[list[Event]] = None, + images: Optional[list[str]] = None, + busy=None, + memory_block: Optional[str] = None, +): + """Generator for the UI: yields (partial_text, plan_or_None). Streams the + model output for a live 'thinking' panel, then yields the final ActionPlan + (with deterministic conflicts annotated if ``busy`` intervals are given). + ``memory_block`` carries the caller's per-user (localStorage) memory.""" + now = now or datetime.now() + existing = existing or [] + + with events.run_scope("analyze"): + if images: + events.emit("vision", f"reading {len(images)} image(s)", images=len(images)) + + if os.environ.get("USE_STUB_EXTRACTOR") == "1": + plan = _stub_plan(thread, now) + text = json.dumps(plan.model_dump(), indent=2) + events.emit("model", "stub inference", latency_ms=0) + acc = "" + for i in range(0, len(text), 24): # simulate token streaming + acc += text[i : i + 24] + yield acc, None + else: + from .model import stream_complete_json + + acc = "" + for delta in stream_complete_json( + build_messages(thread, now, existing, images, memory_block), + ActionPlan.model_json_schema(), + ): + acc += delta + yield acc, None + plan = apply_text_rules(thread, _polish_titles(thread, _parse_plan(acc))) + + # Global path only (see run_agent): client memory is merged by the UI. + if memory_block is None: + memory.observe_plan(plan) # grows-with-you: learn recurring contacts + events.emit("decision", f"{len(plan.events)} event(s) detected", events=len(plan.events)) + if busy: + from calendar_out.freebusy import annotate_conflicts # lazy: avoid cycle + + plan = annotate_conflicts(plan, busy) + yield (json.dumps(plan.model_dump(), indent=2), plan) + + +_TIME_RE = re.compile(r"\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b", re.IGNORECASE) +_TIME_LABEL_RE = re.compile(r"(?im)^\s*time\s*[:\-]\s*(.+)$") +_MONTH_DATE_RE = re.compile( + r"\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|" + r"aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\.?\s+" + r"\d{1,2}(?:st|nd|rd|th)?(?:,?\s*\d{4})?\b", re.IGNORECASE) +_WEEKDAY_RE = re.compile( + r"\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", re.IGNORECASE) +_LOCATION_RE = re.compile( + r"(?i)^\s*(?:(?:location|where|address)\s*[:\-]|\U0001F4CD)\s*(.*)$") +_LABEL_LINE_RE = re.compile(r"^\s*[A-Za-z][A-Za-z ]{0,20}:\s") # "Time: ...", "Notes: ..." +_DURATION_RE = re.compile(r"(?im)^\s*duration\s*[:\-]\s*(.*)$") +_EARLY_RE = re.compile(r"(?i)arrive\s+(\d{1,3})\s*min(?:ute)?s?\s+early") +_REMIND_EXPLICIT_RE = re.compile( + r"(?i)\b(?:remind(?:er)?|notify|alert)\s*(?:me\s+)?(?:for\s+)?" + r"(\d{1,3})\s*(min(?:ute)?s?|h(?:ou)?rs?)\s*(?:before|ahead|prior|early)") +_MEDICAL_RE = re.compile( + r"(?i)\b(?:doctor|dr\b\.?|clinic|dentist|dental|pediatric\w*|physician|" + r"medical|check-?up|primary\s+care|intake\s+forms?)") +_PARTY_RE = re.compile( # "party of 4" is a group size, not a party + r"(?i)\b(?:birthday|bday)\b|\bparty\b(?!\s+of\s+\d)") +_CARPOOL_SCHOOL_RE = re.compile(r"(?i)\bcarpool\w*\b|\bschool\b|drive\s+the\s+kids") + + +def _find_time(thread: str) -> Optional[tuple[int, int]]: + """First plausible clock time, or None. A bare integer ("June 22", "112A") + is not a time — a match needs a minute component or an am/pm marker.""" + label = _TIME_LABEL_RE.search(thread) + scope = label.group(1) if label else thread + for m in _TIME_RE.finditer(scope): + if not (m.group(2) or m.group(3)): + continue + hour, minute = int(m.group(1)), int(m.group(2) or 0) + if hour > 23 or minute > 59: + continue + mer = (m.group(3) or "").lower() + if mer == "pm" and hour < 12: + hour += 12 + elif mer == "am" and hour == 12: + hour = 0 + return hour, minute + return None + + +def _find_date(thread: str, now: datetime): + """Resolve the event's day: explicit date > today/tomorrow > weekday > tomorrow.""" + m = _MONTH_DATE_RE.search(thread) + if m: + try: + return dtparser.parse(m.group(0), default=now).date() + except (ValueError, OverflowError): + pass + if re.search(r"\btomorrow\b", thread, re.IGNORECASE): + return (now + timedelta(days=1)).date() + if re.search(r"\btoday\b|\btonight\b", thread, re.IGNORECASE): + return now.date() + m = _WEEKDAY_RE.search(thread) + if m: + try: + return dtparser.parse(m.group(1), default=now).date() # next-or-same day + except (ValueError, OverflowError): + pass + return (now + timedelta(days=1)).date() + + +def _find_location(lines: list[str]) -> tuple[Optional[str], set[int]]: + """A "Location:" line plus continuation lines (a wrapped address) until a + blank line or the next "Label:" line. Returns (joined location, line idxs).""" + for i, line in enumerate(lines): + m = _LOCATION_RE.match(line) + if not m: + continue + parts, used = [m.group(1).strip()], {i} + for j in range(i + 1, len(lines)): + nxt = lines[j].strip() + if not nxt or nxt.startswith("(") or _LABEL_LINE_RE.match(lines[j]): + break + parts.append(nxt) + used.add(j) + loc = ", ".join(p for p in parts if p) + return (loc or None), used + return None, set() + + +def _find_duration_minutes(thread: str) -> Optional[int]: + m = _DURATION_RE.search(thread) + if m: + num = re.search(r"\d+", m.group(1)) + if num: + return int(num.group(0)) + return None + + +def _reminder_minutes(thread: str) -> int: + """Notification lead time: an explicit ask wins, else event-type defaults + (medical 60, party 30, carpool/school 45 — checked in that order), else 30.""" + m = _REMIND_EXPLICIT_RE.search(thread) + if m: + n = int(m.group(1)) + return n * 60 if m.group(2).lower().startswith("h") else n + if _MEDICAL_RE.search(thread): + return 60 + if _PARTY_RE.search(thread): + return 30 + if _CARPOOL_SCHOOL_RE.search(thread): + return 45 + return 30 + + +def _is_date_line(line: str, now: datetime) -> bool: + try: + dtparser.parse(line, default=now) # non-fuzzy: chatter raises ParserError + return True + except (ValueError, OverflowError): + return False + + +def _pick_title(lines: list[str], now: datetime, location_idx: set[int]) -> str: + nonempty = [(i, ln.strip()) for i, ln in enumerate(lines) if ln.strip()] + if not nonempty: + return "Event" + first_i, first = nonempty[0] + if not _is_date_line(first, now): + return first[:60] + # First line is just the date — find a more descriptive line instead. + for i, ln in nonempty[1:]: + if i in location_idx or _LABEL_LINE_RE.match(ln) or ln.startswith("("): + continue + if _is_date_line(ln, now): + continue + return ln[:60] + return "Appointment" + + +def _stub_plan(thread: str, now: datetime) -> ActionPlan: + """Heuristic ActionPlan so phases without a model still demo end to end.""" + time_found = _find_time(thread) + if not time_found: + return ActionPlan(reasoning="No time found.", reply_draft="Got it, thanks!") + hour, minute = time_found + lines = thread.strip().splitlines() + location, loc_idx = _find_location(lines) + day = _find_date(thread, now) + appt = now.replace(year=day.year, month=day.month, day=day.day, + hour=hour, minute=minute, second=0, microsecond=0) + duration = _find_duration_minutes(thread) or 60 + # "Arrive N minutes early" -> start at the ARRIVAL time; the end (and the + # notes) stay anchored to the stated appointment time. + early = _EARLY_RE.search(thread) + start = appt - timedelta(minutes=int(early.group(1))) if early else appt + notes = (f"Appointment at {appt.strftime('%H:%M')}; arrive {early.group(1)} min early" + if early else "(stub agent — wire the model to replace this)") + return ActionPlan( + reasoning="(stub) parsed time/date/location heuristically.", + events=[ + Event( + title=_pick_title(lines, now, loc_idx), + start=start.isoformat(), + end=(appt + timedelta(minutes=duration)).isoformat(), + location=location, + reminder_minutes=_reminder_minutes(thread), + notes=notes, + ) + ], + reply_draft="Sounds good, see you then!", + ) diff --git a/server/dedup.py b/server/dedup.py new file mode 100644 index 0000000000000000000000000000000000000000..b4099991ed6e19dd3efe9095891a56587da4b466 --- /dev/null +++ b/server/dedup.py @@ -0,0 +1,84 @@ +"""Idempotency for autonomous mode: don't create the same event twice. + +As more messages stream into a chat, re-running the agent over a rolling window +re-surfaces events already captured. ``filter_new`` returns only events not seen +before, keyed by normalized title + minute-rounded start. Durable JSON store +mirrors ``server/impact.py`` (env path + lock; no DB — local-first). +""" +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path + +from dateutil import parser as dtparser + +from .schema import Event + +_lock = threading.Lock() + + +def _path() -> Path: + return Path(os.environ.get("DEDUP_PATH", "/tmp/agent_seen.json")) + + +def event_key(ev: Event) -> str: + """Normalized identity: lowercased title + start rounded to the minute. + + Conservative by design — if the model rewords a title between messages we may + miss a dedup (a duplicate event), which is safer than dropping a real event. + """ + title = (ev.title or "").strip().lower() + try: + start = dtparser.isoparse(ev.start).replace(second=0, microsecond=0).isoformat() + except (ValueError, TypeError): + start = (ev.start or "").strip() + return f"{title}|{start}" + + +def _load() -> list[str]: + try: + return json.loads(_path().read_text()) + except Exception: # noqa: BLE001 missing/corrupt -> start fresh + return [] + + +def filter_new(events: list[Event], record: bool = True) -> list[Event]: + """Return only events whose key hasn't been recorded. + + ``record=False`` filters WITHOUT persisting — callers delivering the events + somewhere fallible (e.g. a calendar push) should filter first and + ``mark_seen`` only after delivery succeeds; otherwise a transient failure + permanently swallows the events ("seen" but never delivered).""" + with _lock: + seen = set(_load()) + fresh = [] + for ev in events: + k = event_key(ev) + if k in seen: + continue + seen.add(k) + fresh.append(ev) + if fresh and record: + _path().write_text(json.dumps(sorted(seen), indent=2)) + return fresh + + +def mark_seen(events: list[Event]) -> None: + """Persist the keys of successfully delivered events.""" + if not events: + return + with _lock: + seen = set(_load()) + seen.update(event_key(ev) for ev in events) + _path().write_text(json.dumps(sorted(seen), indent=2)) + + +def reset() -> None: + """Drop the seen-set (test helper).""" + with _lock: + try: + _path().unlink() + except FileNotFoundError: + pass diff --git a/server/events.py b/server/events.py new file mode 100644 index 0000000000000000000000000000000000000000..bbe97f0c8ecf7edafbea19dde68da8f7b2dc79c3 --- /dev/null +++ b/server/events.py @@ -0,0 +1,116 @@ +"""In-process activity bus: every pipeline stage emits structured events here so +the Activity dashboard can show what the LLM and agent are doing in real time. + +A thread-safe ring buffer holds recent events. A contextvar (run_scope) tags all +events emitted during one agent run with the same run id, so the dashboard can +group them into per-run traces. +""" +from __future__ import annotations + +import threading +from collections import deque +from contextlib import contextmanager +from contextvars import ContextVar +from datetime import datetime +from itertools import count + +MAXLEN = 800 + +# Stages of the pipeline, in display order (used by the stepper + chart). +STAGES = ["ingest", "vision", "model", "decision", "conflict", "calendar"] + +_BUF: deque[dict] = deque(maxlen=MAXLEN) +_lock = threading.Lock() +_run_var: ContextVar = ContextVar("agent_run", default=None) +_seq = count(1) + + +def _now() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def emit(stage: str, message: str, level: str = "info", **payload) -> dict: + """Record one activity event. ``payload`` may carry latency_ms, events, + conflicts, images, tokens, etc. Returns the event dict.""" + ev = { + "id": next(_seq), + "ts": _now(), + "stage": stage, + "level": level, + "message": message, + "run": _run_var.get(), + **payload, + } + with _lock: + _BUF.append(ev) + return ev + + +@contextmanager +def run_scope(label: str = ""): + """Tag every event emitted inside the block with a shared run id.""" + run_id = f"{next(_seq)}:{label}" if label else str(next(_seq)) + token = _run_var.set(run_id) + try: + yield run_id + finally: + # Best-effort: when used inside a streaming generator that the server drives + # across different contexts (e.g. Gradio's queue), reset(token) raises + # "Token was created in a different Context". Clearing is enough either way. + try: + _run_var.reset(token) + except ValueError: + _run_var.set(None) + + +def recent(n: int = 120) -> list[dict]: + with _lock: + return list(_BUF)[-n:][::-1] # newest first + + +def current_stage() -> str | None: + with _lock: + return _BUF[-1]["stage"] if _BUF else None + + +def metrics() -> dict: + with _lock: + evs = list(_BUF) + lat = [e["latency_ms"] for e in evs if e.get("latency_ms")] + return { + "messages": sum(1 for e in evs if e["stage"] == "ingest"), + "events_created": sum(e.get("events", 0) for e in evs if e["stage"] == "decision"), + "conflicts": sum(e.get("conflicts", 0) for e in evs if e["stage"] == "conflict"), + "images_read": sum(e.get("images", 0) for e in evs), + "model_calls": len(lat), + "avg_latency_ms": round(sum(lat) / len(lat)) if lat else 0, + "errors": sum(1 for e in evs if e["level"] == "error"), + } + + +def stage_counts() -> list[dict]: + """Counts per stage, ready for gr.BarPlot.""" + with _lock: + evs = list(_BUF) + counts = {s: 0 for s in STAGES} + for e in evs: + if e["stage"] in counts: + counts[e["stage"]] += 1 + return [{"stage": s, "count": counts[s]} for s in STAGES] + + +def recent_runs(n: int = 8) -> list[tuple[str, list[dict]]]: + """Group recent events by run id (newest run first).""" + with _lock: + evs = list(_BUF) + groups: dict[str, list[dict]] = {} + order: list[str] = [] + for e in evs: + rid = e.get("run") + if not rid: + continue + if rid not in groups: + groups[rid] = [] + order.append(rid) + groups[rid].append(e) + return [(rid, groups[rid]) for rid in order[-n:][::-1]] diff --git a/server/health.py b/server/health.py new file mode 100644 index 0000000000000000000000000000000000000000..ec3a8bd4a6cb521254a59d3035f7bb962a67598a --- /dev/null +++ b/server/health.py @@ -0,0 +1,54 @@ +"""Liveness + a hardware-adequacy signal for the UI banner and external monitors. + +`health_status()` powers both `GET /health` and the on-page status banner. It +reports `degraded: true` when the *real* model would run on CPU-only hardware +(where extraction can be slow or time out). `FORCE_DEGRADED=1` forces it on so +the banner can be exercised without actually changing the Space's hardware. +""" +from __future__ import annotations + +import os + + +def gpu_available() -> bool: + """Best-effort GPU probe. This app serves via llama.cpp (no torch), so we + can't rely on torch.cuda — check for an NVIDIA device or `nvidia-smi`.""" + import glob + import shutil + import subprocess + + if glob.glob("/dev/nvidia[0-9]*"): + return True + if shutil.which("nvidia-smi"): + try: + return subprocess.run(["nvidia-smi"], capture_output=True, timeout=5).returncode == 0 + except Exception: # noqa: BLE001 any probe failure -> treat as no GPU + return False + return False + + +def health_status() -> dict: + """Return liveness + the degraded/device/model signal (read at call time).""" + if os.environ.get("FORCE_DEGRADED") == "1": + return { + "ok": True, "device": "cpu", "model": "real", "degraded": True, + "reason": "Running the model on CPU-only hardware. Extraction may be slow " + "or time out. Upgrade to a GPU.", + } + # Stub mode is a deliberate, fast, free preview — not a degraded state. + if os.environ.get("USE_STUB_EXTRACTOR") == "1": + return {"ok": True, "device": "cpu", "model": "stub", "degraded": False, "reason": ""} + + # Real model. On the Space, INFERENCE_BASE_URL points at the *local* llama-server, + # so local inference still depends on this host having a GPU. A non-localhost URL + # means inference runs elsewhere, so this host's hardware is irrelevant. + base = os.environ.get("INFERENCE_BASE_URL", "") + local = (not base) or ("127.0.0.1" in base) or ("localhost" in base) + gpu = gpu_available() + degraded = local and not gpu + device = "cuda" if (local and gpu) else ("cpu" if local else "remote") + reason = ( + "Running the model on CPU-only hardware. Extraction may be slow or time out. " + "Upgrade to a GPU." if degraded else "" + ) + return {"ok": True, "device": device, "model": "real", "degraded": degraded, "reason": reason} diff --git a/server/imageutil.py b/server/imageutil.py new file mode 100644 index 0000000000000000000000000000000000000000..02d9aaec4573b2b5e52f3bb24ff29a5a47b9b81c --- /dev/null +++ b/server/imageutil.py @@ -0,0 +1,61 @@ +"""Encode images as base64 data URIs for llama.cpp vision chat handlers. + +Shared by the Mac collector (attachments) and the UI (manual upload). +""" +from __future__ import annotations + +import base64 +import mimetypes +from pathlib import Path + +# Skip anything bigger than this to keep payloads/context sane. +MAX_BYTES = 4 * 1024 * 1024 # 4 MB + +IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/heic"} + + +def is_image(path: str) -> bool: + mime, _ = mimetypes.guess_type(path) + return mime in IMAGE_MIMES + + +def _heic_to_jpeg(p: Path) -> bytes | None: + """Transcode HEIC/HEIF to JPEG bytes (pillow-heif), or None if unavailable. + llama.cpp's clip handler can't decode HEIC, so raw pass-through would fail + or waste context — and iPhone attachments are predominantly HEIC.""" + try: + import io + + import pillow_heif + from PIL import Image + + pillow_heif.register_heif_opener() + img = Image.open(p).convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=88) + return buf.getvalue() + except Exception: # noqa: BLE001 no pillow-heif / corrupt file -> skip + return None + + +def to_data_uri(path: str) -> str | None: + """Return a `data:;base64,...` URI, or None if not a usable image. + HEIC is transcoded to JPEG (the vision stack can't decode HEIC); when + transcoding isn't available the file is skipped, never sent undecodable.""" + p = Path(path) + if not p.exists() or p.stat().st_size > MAX_BYTES: + return None + mime, _ = mimetypes.guess_type(str(p)) + if mime not in IMAGE_MIMES: + return None + if mime == "image/heic" or p.suffix.lower() in (".heic", ".heif"): + jpeg = _heic_to_jpeg(p) + if jpeg is None: + return None + return "data:image/jpeg;base64," + base64.b64encode(jpeg).decode("ascii") + b64 = base64.b64encode(p.read_bytes()).decode("ascii") + return f"data:{mime};base64,{b64}" + + +def paths_to_data_uris(paths: list[str]) -> list[str]: + return [u for u in (to_data_uri(p) for p in paths or []) if u] diff --git a/server/impact.py b/server/impact.py new file mode 100644 index 0000000000000000000000000000000000000000..988c36e93c725a868c462ae144c942ee8c7ae894 --- /dev/null +++ b/server/impact.py @@ -0,0 +1,87 @@ +"""Durable weekly impact metrics: how much the agent actually saved the user. + +Unlike the in-memory activity bus (``server/events.py``), which is an 800-entry +ring buffer lost on restart, this records a small set of counters **per ISO week** +to a JSON file, so the "This week" panel accumulates over real use and survives +restarts. + +Counters per week: ``events_captured``, ``conflicts_caught``, ``minutes_saved``. +A "capture" is the user *accepting* events by exporting them (``.ics`` download or +Google Calendar push) — see ``ui/blocks.py``. ``minutes_saved`` is a deliberately +conservative, fully configurable **estimate** (not a measurement): a fixed number +of minutes per event captured plus per conflict caught. + +Persistence mirrors ``app.py``'s ``_append_feed``: an env-overridable JSON file +under ``/tmp`` by default. No database — local-first by design. +""" +from __future__ import annotations + +import json +import os +import threading +from datetime import datetime +from pathlib import Path + +_lock = threading.Lock() + +_ZERO = {"events_captured": 0, "conflicts_caught": 0, "minutes_saved": 0} + + +def _path() -> Path: + return Path(os.environ.get("IMPACT_PATH", "/tmp/impact_weeks.json")) + + +def _min_per_event() -> int: + return int(os.environ.get("IMPACT_MIN_PER_EVENT", "8")) + + +def _min_per_conflict() -> int: + return int(os.environ.get("IMPACT_MIN_PER_CONFLICT", "15")) + + +def _week_key(when: datetime | None = None) -> str: + iso = (when or datetime.now()).isocalendar() + return f"{iso.year}-W{iso.week:02d}" + + +def _load() -> dict: + try: + return json.loads(_path().read_text()) + except Exception: # noqa: BLE001 missing/corrupt file -> start fresh + return {} + + +def record_capture(events_captured: int, conflicts_caught: int = 0) -> dict: + """Durably add to the current week's counters; return that week's record. + + Re-reads the file before incrementing so concurrent writers and restarts + never drop prior counts (append/aggregate, never overwrite-from-memory). + """ + minutes = events_captured * _min_per_event() + conflicts_caught * _min_per_conflict() + key = _week_key() + with _lock: + data = _load() + wk = {**_ZERO, **data.get(key, {})} + wk["events_captured"] += events_captured + wk["conflicts_caught"] += conflicts_caught + wk["minutes_saved"] += minutes + data[key] = wk + _path().write_text(json.dumps(data, indent=2)) + return dict(wk) + + +def this_week() -> dict: + """Read-only current-week record (all zeros if nothing recorded yet). + + The durable, weekly analogue of ``events.metrics()``. + """ + return {**_ZERO, **_load().get(_week_key(), {})} + + +def reset() -> None: + """Drop all recorded impact (test helper).""" + with _lock: + try: + _path().unlink() + except FileNotFoundError: + pass diff --git a/server/mcp_tools.py b/server/mcp_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..1382fc6d765143d741c5ea84be977191ac75993f --- /dev/null +++ b/server/mcp_tools.py @@ -0,0 +1,117 @@ +"""Agent-facing tool wrappers exposed via Gradio's MCP server. + +Each function below has a clean signature + docstring on purpose — Gradio's MCP +layer (`mcp_server=True` in app.py) reads the type hints and docstring to build +the JSON-Schema a remote MCP client sees. Keep them stateless and JSON-friendly: +inputs are str / list[dict] / etc., outputs are dict / str / list[dict] (never +pydantic objects, which don't serialise through the MCP boundary). + +These wrap the existing pipeline (server/pipeline.run_pipeline) and free/busy +math (calendar_out/freebusy) — no new business logic lives here, just the +shape adaptation an external agent expects. +""" +from __future__ import annotations + +import base64 +import time +from collections import OrderedDict +from typing import Optional + +from calendar_out.freebusy import Busy, check_conflicts as _freebusy_check_conflicts, load_ics_busy +from calendar_out.ics import events_to_ics +from server.pipeline import AgentRequest, run_pipeline +from server.schema import Event + +# Short-lived extraction cache. The Agent-tab orchestrator extracts TWICE per +# run — once when the MiniCPM planner calls this tool over MCP, then again when +# the scripted path finalizes — and each call runs the full gemma-cal E4B. With +# identical inputs the second call is pure waste, so memoize on the EXACT inputs +# (thread + images + memory). Different memory/images -> different key -> a fresh +# (correct) extraction; the win is the common no-memory case. TTL is generous so +# the scripted call still hits after a ~2-min planner run; small maxsize bounds +# cross-request staleness (same input -> same output anyway). +_EXTRACT_CACHE: "OrderedDict[tuple, tuple[float, dict]]" = OrderedDict() +_EXTRACT_TTL = 600.0 +_EXTRACT_MAX = 8 + + +def extract_events(thread: str, images: Optional[list[str]] = None, + memory: Optional[str] = None) -> dict: + """Extract calendar events from a pasted iMessage thread (and optional screenshots). + + The headline tool. Reads a chat or screenshot, returns an ActionPlan with the + events found, any conflicts against the user's calendar, and a suggested reply. + Runs 100% locally inside the Space via llama.cpp — no cloud AI APIs. + + Args: + thread: Plain-text iMessage conversation, e.g. "Alice: pickup 5pm Thursday". + Either ``thread`` or ``images`` must be non-empty. + images: Optional list of base64-encoded screenshots (raw base64 or data URIs). + Useful when the schedule lives in a screenshot rather than text. + memory: Optional plain-text recall block about the user (people and their + roles, preferences like default reminders or days they decline) — used + to personalize extraction. e.g. "Dana is the soccer coach". + + Returns: + ActionPlan as a JSON-serialisable dict with keys: ``reasoning``, + ``events`` (list of {title, start, end, location, attendees, ...}), + ``conflicts``, ``proposed_times``, ``reply_draft``, ``needs_clarification``. + """ + key = (thread or "", tuple(images or []), memory or "") + now = time.monotonic() + hit = _EXTRACT_CACHE.get(key) + if hit is not None and now - hit[0] < _EXTRACT_TTL: + _EXTRACT_CACHE.move_to_end(key) + return hit[1] + req = AgentRequest(thread=thread or "", images=images or [], memory=memory, + return_ics=False) + resp = run_pipeline(req) + plan = resp.plan.model_dump() + _EXTRACT_CACHE[key] = (now, plan) + _EXTRACT_CACHE.move_to_end(key) + while len(_EXTRACT_CACHE) > _EXTRACT_MAX: + _EXTRACT_CACHE.popitem(last=False) + return plan + + +def make_ics(events: list[dict]) -> str: + """Render a list of event dicts as an .ics file (base64-encoded). + + Args: + events: List of event dicts in the shape returned by ``extract_events`` + — each needs at least ``title`` and ``start`` (ISO 8601). Optional: + ``end``, ``location``, ``attendees``, ``reminder_minutes``, ``notes``. + + Returns: + Base64-encoded VCALENDAR bytes. Decode and write to ``something.ics`` to + import into any calendar app. + """ + ev_objs = [Event(**e) for e in events] + return base64.b64encode(events_to_ics(ev_objs)).decode("ascii") + + +def check_conflicts(events: list[dict], ics_base64: str) -> list[dict]: + """Find clashes between proposed events and busy intervals from an .ics calendar. + + Deterministic free/busy math — runs without the LLM, so it's safe for agents + to call as a fast verification step after ``extract_events``. + + Args: + events: List of proposed event dicts (same shape as ``extract_events`` + output). Each event needs at least ``title`` and ``start``. + ics_base64: Base64-encoded .ics calendar to check against. Typically the + user's current calendar exported from Google/Apple/Outlook. + + Returns: + List of conflict dicts: ``{event_index, clashes_with, severity}`` where + severity is one of ``"overlap"``, ``"adjacent"``, ``"tight"``. Empty list + if nothing clashes. + """ + if not ics_base64: + return [] + try: + busy: list[Busy] = load_ics_busy(base64.b64decode(ics_base64)) + except Exception: # noqa: BLE001 malformed .ics -> no conflict context + return [] + ev_objs = [Event(**e) for e in events] + return [c.model_dump() for c in _freebusy_check_conflicts(ev_objs, busy)] diff --git a/server/memory.py b/server/memory.py new file mode 100644 index 0000000000000000000000000000000000000000..fdc0be40304b518352dd5710d519c6fc6b012c8b --- /dev/null +++ b/server/memory.py @@ -0,0 +1,174 @@ +"""Persistent 'grows-with-you' agent memory. + +Durable facts and preferences that personalize extraction over time: +people->roles ("Dana is the soccer coach"), rules ("you decline Mondays"), +default locations. Stored as JSON at MEMORY_PATH. + +- recall() -> a compact block injected into the agent prompt (server/agent.py) +- remember() -> add/strengthen a fact (Memory tab, or a Hermes `remember` tool-call) +- forget() -> drop a fact (Memory tab) +- observe_plan()-> conservatively learn recurring contacts from extracted events + +This is the "memory" half of a Hermes-style grows-with-you agent; the model +(served via INFERENCE_BASE_URL) is the reasoning half. +""" +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path + +MEMORY_PATH = Path(os.environ.get("MEMORY_PATH", "/tmp/agent_memory.json")) +MAX_FACTS = 200 +KINDS = ("contact", "preference", "location", "note") + +_lock = threading.Lock() + + +def _norm(text: str) -> str: + return " ".join(text.lower().split()) + + +def _load() -> dict: + try: + data = json.loads(MEMORY_PATH.read_text()) + if isinstance(data, dict) and isinstance(data.get("facts"), list): + return data + except Exception: # noqa: BLE001 missing/corrupt -> empty + pass + return {"facts": [], "seq": 0} + + +def _save(state: dict) -> None: + MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True) + MEMORY_PATH.write_text(json.dumps(state, indent=2)) + + +def remember(text: str, kind: str = "note") -> dict | None: + """Add a fact, or strengthen (bump weight) an existing one with the same text.""" + text = (text or "").strip() + if not text: + return None + if kind not in KINDS: + kind = "note" + key = _norm(text) + with _lock: + state = _load() + for f in state["facts"]: + if _norm(f["text"]) == key: + f["weight"] = f.get("weight", 1) + 1 + _save(state) + return f + state["seq"] += 1 + fact = {"id": state["seq"], "kind": kind, "text": text, "weight": 1} + state["facts"].append(fact) + state["facts"] = state["facts"][-MAX_FACTS:] + _save(state) + return fact + + +def forget(fact_id: int) -> bool: + with _lock: + state = _load() + before = len(state["facts"]) + state["facts"] = [f for f in state["facts"] if f["id"] != int(fact_id)] + changed = len(state["facts"]) != before + if changed: + _save(state) + return changed + + +def list_facts() -> list[dict]: + return _load()["facts"] + + +def recall(limit: int = 20) -> str: + """Compact 'what I know about you' block for the prompt; '' if empty. + + Strongest (most-reinforced) facts first so the prompt stays small but useful. + """ + facts = sorted(list_facts(), key=lambda f: f.get("weight", 1), reverse=True)[:limit] + if not facts: + return "" + lines = "\n".join(f"- {f['text']}" for f in facts) + return "What I know about you (memory):\n" + lines + + +def observe_plan(plan) -> None: + """Conservatively learn from an extracted ActionPlan: record event attendees as + contacts (reinforced over time). Cheap, deterministic 'growth' without an LLM + round-trip; explicit facts still come via remember()/the Memory tab/tool-calls.""" + try: + for ev in getattr(plan, "events", []) or []: + for name in getattr(ev, "attendees", []) or []: + name = (name or "").strip() + if name and len(name) <= 40: + remember(f"{name} is a contact you make plans with", kind="contact") + except Exception: # noqa: BLE001 memory must never break extraction + pass + + +def reset() -> None: + """Clear memory (used by tests).""" + with _lock: + _save({"facts": [], "seq": 0}) + + +# --------------------------------------------------------------------------- # +# Client-owned memory (per-user, browser localStorage). These are PURE helpers +# that operate on a passed-in facts list — no global file — so each visitor's +# memory can live on their device and be threaded through the agent per request. +# --------------------------------------------------------------------------- # +def facts_to_recall(facts: list[dict], limit: int = 20) -> str: + """Same compact 'what I know about you' block as recall(), but for a passed + facts list (client/localStorage memory). '' if empty.""" + facts = sorted(facts or [], key=lambda f: f.get("weight", 1), reverse=True)[:limit] + if not facts: + return "" + lines = "\n".join(f"- {f['text']}" for f in facts if (f or {}).get("text")) + return "What I know about you (memory):\n" + lines if lines else "" + + +def merge_facts(facts: list[dict], texts, kind: str = "note") -> list[dict]: + """Add texts to a facts list (dedup by normalized text → bump weight), keeping + ids stable. Returns a NEW list (caller persists it). `texts` is an iterable of + strings, or of (text, kind) pairs.""" + facts = [dict(f) for f in (facts or [])] + by_key = {_norm(f["text"]): f for f in facts if f.get("text")} + next_id = max((int(f.get("id", 0)) for f in facts), default=0) + 1 + for item in texts or []: + if isinstance(item, (tuple, list)): + text, k = item[0], (item[1] if len(item) > 1 else kind) + else: + text, k = item, kind + text = (text or "").strip() + if not text: + continue + if k not in KINDS: + k = "note" + key = _norm(text) + if key in by_key: + f = by_key[key] + f["weight"] = f.get("weight", 1) + 1 + else: + f = {"id": next_id, "kind": k, "text": text, "weight": 1} + next_id += 1 + facts.append(f) + by_key[key] = f + return facts[-MAX_FACTS:] + + +def learn_from_plan(plan) -> list[str]: + """Contact texts to learn from an ActionPlan (the observe_plan() logic, but + RETURNED for client-side merge instead of written to the global file).""" + out: list[str] = [] + try: + for ev in getattr(plan, "events", []) or []: + for name in getattr(ev, "attendees", []) or []: + name = (name or "").strip() + if name and len(name) <= 40: + out.append(f"{name} is a contact you make plans with") + except Exception: # noqa: BLE001 memory must never break extraction + pass + return out diff --git a/server/model.py b/server/model.py new file mode 100644 index 0000000000000000000000000000000000000000..94cb9903d3d4fd69d5d6abf4a00dee6cb48021cd --- /dev/null +++ b/server/model.py @@ -0,0 +1,317 @@ +"""Load the fine-tuned Gemma 4 GGUF and run inference via llama.cpp. + +Llama Champion: all generation goes through llama-cpp-python — no cloud AI API. +The GGUF is downloaded from HF at startup so the Space image stays small. + +Two inference locations, selected by env: +- in-process llama.cpp, GPU-offloaded inside an @spaces.GPU lease (ZeroGPU), or +- a remote OpenAI-compatible / llama.cpp server via INFERENCE_BASE_URL + (e.g. a llama-server on the phone itself, or a backend). +""" +from __future__ import annotations + +import os +import threading +import time + +from huggingface_hub import hf_hub_download + +from . import events + +# The platform runs the gemma-cal EDGE fine-tune (Gemma-4 E4B, ~5GB Q4) — our own +# calendar-native model, eval-gated before every publish (docs/eval-roadmap.md). +# MODEL SIZE (hackathon hard constraint, <= 32B): E4B = ~4B effective params. +# All inference is local via llama.cpp (no cloud AI). +MODEL_REPO = os.environ.get("MODEL_REPO", "ParetoOptimal/gemma-4-cal-gguf") +MODEL_FILE = os.environ.get("MODEL_FILE", "gemma-cal-e4b-Q4_K_M.gguf") +# Vision projector (mmproj). Set to enable image input; leave empty for text-only. +# MMPROJ_REPO lets the projector come from a different repo than the LLM — the E4B +# edge model pairs with the base E4B's projector, not a projector in our repo. +MMPROJ_REPO = os.environ.get("MMPROJ_REPO", "") or os.environ.get("MODEL_REPO", "ParetoOptimal/gemma-4-cal-gguf") +MMPROJ_FILE = os.environ.get("MMPROJ_FILE", "") +# llama-cpp-python vision handler class (in llama_cpp.llama_chat_format). Gemma 4 +# vision may ship a dedicated handler; the generic clip/Llava handler is the default. +CHAT_HANDLER = os.environ.get("CHAT_HANDLER", "Llava15ChatHandler") + +N_CTX = int(os.environ.get("N_CTX", "8192")) +N_GPU_LAYERS = int(os.environ.get("N_GPU_LAYERS", "-1")) # -1 = offload all (GPU) +GPU_DURATION = int(os.environ.get("GPU_DURATION", "120")) # ZeroGPU lease seconds + +# Configurable inference location. If INFERENCE_BASE_URL is set, generation is +# delegated to a remote OpenAI-compatible / llama.cpp server (e.g. a llama-server +# running on the phone itself, or a backend) instead of loading the GGUF in-process. +# This is how the same agent runs on-device OR thin-client — selected by env. +INFERENCE_BASE_URL = os.environ.get("INFERENCE_BASE_URL", "") +INFERENCE_API_KEY = os.environ.get("INFERENCE_API_KEY", "") +INFERENCE_MODEL = os.environ.get("INFERENCE_MODEL", "local") +# Let a tool-calling model (Hermes) write its own long-term memory mid-run. +# Only applies to the remote path (server/tools.py); off by default. +HERMES_TOOLS = os.environ.get("HERMES_TOOLS") == "1" + +_llm = None +_lock = threading.Lock() + + +# ZeroGPU: GPU-bound work must run inside an @spaces.GPU function (the GPU is +# attached only for that call). Locally / in CI the `spaces` package is absent, +# so `gpu` degrades to a no-op decorator and stub mode never touches this path. +try: + from spaces import GPU as _spaces_gpu + + def gpu(fn): + return _spaces_gpu(duration=GPU_DURATION)(fn) +except Exception: # noqa: BLE001 - spaces not installed (local/CI) + + def gpu(fn): + return fn + + +def _preload_cuda_libs(): + """Preload CUDA userspace libs so the prebuilt CUDA llama-cpp-python wheel can + dlopen. The ZeroGPU/Gradio-SDK env lacks libcudart.so.12 on the default loader + path; the nvidia-*-cu12 pip packages provide them. We CDLL them RTLD_GLOBAL so + the llama .so's NEEDED deps resolve. Path-independent (no LD_LIBRARY_PATH guess); + a no-op off-Linux / when the packages aren't installed.""" + import ctypes + import glob + import os + + try: + import nvidia # namespace package from nvidia-*-cu12 wheels + except Exception: # noqa: BLE001 + return + # nvidia is a PEP 420 namespace package: __file__ is None, use __path__. + bases = list(getattr(nvidia, "__path__", []) or []) + # cublas before its dependents is unnecessary ($ORIGIN RPATH resolves siblings). + for base in bases: + for sub in ("cuda_runtime", "cuda_nvrtc", "cublas"): + for so in sorted(glob.glob(os.path.join(base, sub, "lib", "*.so*"))): + try: + ctypes.CDLL(so, mode=ctypes.RTLD_GLOBAL) + except OSError: + pass + + +def _build_chat_handler(): + """Return a vision chat handler if MMPROJ_FILE is set, else None (text-only).""" + if not MMPROJ_FILE: + return None + import llama_cpp.llama_chat_format as fmt + + mmproj_path = hf_hub_download(repo_id=MMPROJ_REPO, filename=MMPROJ_FILE) + handler_cls = getattr(fmt, CHAT_HANDLER) + return handler_cls(clip_model_path=mmproj_path, verbose=False) + + +def get_llm(): + """Lazily download + load the GGUF once, thread-safe.""" + global _llm + if _llm is None: + with _lock: + if _llm is None: + _preload_cuda_libs() # satisfy libcudart.so.12 etc. before loading + from llama_cpp import Llama # imported lazily so tests can stub + + path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE) + _llm = Llama( + model_path=path, + n_ctx=N_CTX, + n_gpu_layers=N_GPU_LAYERS, + chat_handler=_build_chat_handler(), # enables image_url inputs + verbose=False, + ) + return _llm + + +# --- GPU-scoped inner functions (run inside the ZeroGPU lease) --- +# These do the actual in-process llama.cpp work; emits stay in the main-process +# wrappers below because in-memory state (the events bus) isn't shared back from +# the ZeroGPU subprocess. +@gpu +def _infer_text(messages: list[dict], temperature: float, max_tokens: int) -> str: + out = get_llm().create_chat_completion( + messages=messages, temperature=temperature, max_tokens=max_tokens + ) + return out["choices"][0]["message"]["content"] + + +@gpu +def _infer_json(messages: list[dict], json_schema: dict, temperature: float, max_tokens: int): + out = get_llm().create_chat_completion( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format={"type": "json_object", "schema": json_schema}, + ) + usage = out.get("usage") or {} + return out["choices"][0]["message"]["content"], usage.get("completion_tokens") + + +@gpu +def _infer_stream(messages: list[dict], json_schema: dict, temperature: float, max_tokens: int): + stream = get_llm().create_chat_completion( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format={"type": "json_object", "schema": json_schema}, + stream=True, + ) + for chunk in stream: + delta = chunk["choices"][0].get("delta", {}).get("content") + if delta: + yield delta + + +# --- remote inference seam (on-device / thin-client via INFERENCE_BASE_URL) --- +def _remote_payload(messages, json_schema, temperature, max_tokens, stream): + return { + "model": INFERENCE_MODEL, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + # llama-server accepts json_schema (OpenAI-style); the in-process path uses + # the json_object+schema form. Both grammar-constrain the output. + "response_format": { + "type": "json_schema", + "json_schema": {"name": "ActionPlan", "schema": json_schema, "strict": True}, + }, + "stream": stream, + } + + +def _remote_headers() -> dict: + h = {"Content-Type": "application/json"} + if INFERENCE_API_KEY: + h["Authorization"] = f"Bearer {INFERENCE_API_KEY}" + return h + + +def _remote_complete_json(messages, json_schema, temperature, max_tokens) -> str: + import requests # already a dependency; imported here to keep import light + + t0 = time.perf_counter() + + if HERMES_TOOLS: + # Tool-calling loop: the model may call `remember` to update memory before + # returning the final ActionPlan JSON. See server/tools.py. + from .tools import TOOL_SPECS, run_with_tools + + def _post(msgs): + payload = _remote_payload(msgs, json_schema, temperature, max_tokens, False) + payload["tools"] = TOOL_SPECS + r = requests.post( + f"{INFERENCE_BASE_URL.rstrip('/')}/chat/completions", + json=payload, + headers=_remote_headers(), + timeout=120, + ) + r.raise_for_status() + return r.json() + + content, out = run_with_tools(list(messages), _post) + usage = out.get("usage") or {} + events.emit( + "model", + "remote inference complete (tools)", + latency_ms=round((time.perf_counter() - t0) * 1000), + tokens=usage.get("completion_tokens"), + ) + return content + + resp = requests.post( + f"{INFERENCE_BASE_URL.rstrip('/')}/chat/completions", + json=_remote_payload(messages, json_schema, temperature, max_tokens, False), + headers=_remote_headers(), + timeout=120, + ) + resp.raise_for_status() + out = resp.json() + usage = out.get("usage") or {} + events.emit( + "model", + "remote inference complete", + latency_ms=round((time.perf_counter() - t0) * 1000), + tokens=usage.get("completion_tokens"), + ) + return out["choices"][0]["message"]["content"] + + +def _remote_stream_json(messages, json_schema, temperature, max_tokens): + import json as _json + + import requests + + t0 = time.perf_counter() + events.emit("model", "remote inference started") + with requests.post( + f"{INFERENCE_BASE_URL.rstrip('/')}/chat/completions", + json=_remote_payload(messages, json_schema, temperature, max_tokens, True), + headers=_remote_headers(), + timeout=120, + stream=True, + ) as resp: + resp.raise_for_status() + for raw in resp.iter_lines(): + if not raw: + continue + line = raw.decode("utf-8").removeprefix("data: ").strip() + if not line or line == "[DONE]": + continue + try: + delta = _json.loads(line)["choices"][0].get("delta", {}).get("content") + except (ValueError, KeyError, IndexError): + continue + if delta: + yield delta + events.emit( + "model", "remote stream complete", latency_ms=round((time.perf_counter() - t0) * 1000) + ) + + +# --- main-process wrappers (own the activity-bus emits; pick local vs remote) --- +def complete(messages: list[dict], temperature: float = 0.2, max_tokens: int = 1024) -> str: + """Chat-completion helper returning the assistant text.""" + return _infer_text(messages, temperature, max_tokens) + + +def complete_json( + messages: list[dict], + json_schema: dict, + temperature: float = 0.2, + max_tokens: int = 2048, +) -> str: + """Constrained completion: grammar-constrained so the output always parses. + Delegates to a remote server if INFERENCE_BASE_URL is set, else runs the + GPU-offloaded in-process llama.cpp path.""" + if INFERENCE_BASE_URL: + return _remote_complete_json(messages, json_schema, temperature, max_tokens) + t0 = time.perf_counter() + text, tokens = _infer_json(messages, json_schema, temperature, max_tokens) + events.emit( + "model", + "inference complete", + latency_ms=round((time.perf_counter() - t0) * 1000), + tokens=tokens, + ) + return text + + +def stream_complete_json( + messages: list[dict], + json_schema: dict, + temperature: float = 0.2, + max_tokens: int = 2048, +): + """Streaming constrained completion: yields text deltas so the UI can show the + model 'thinking'. Remote seam when INFERENCE_BASE_URL is set, else GPU-offloaded + in-process llama.cpp. Emits model events around the call.""" + if INFERENCE_BASE_URL: + yield from _remote_stream_json(messages, json_schema, temperature, max_tokens) + return + t0 = time.perf_counter() + events.emit("model", "inference started") + for delta in _infer_stream(messages, json_schema, temperature, max_tokens): + yield delta + events.emit( + "model", "stream complete", latency_ms=round((time.perf_counter() - t0) * 1000) + ) diff --git a/server/orchestrator.py b/server/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..f7d7793037c953e4d50b3d708ea2c716e80889c9 --- /dev/null +++ b/server/orchestrator.py @@ -0,0 +1,191 @@ +"""MiniCPM-planned agent orchestrator over the Space's own MCP tools. + +The Agent tab's engine: a small planner LLM (OpenBMB MiniCPM via a second +llama-server, OpenAI-compatible) drives smolagents' ToolCallingAgent against +the SAME tools this Space already exposes over MCP (extract_events / +check_conflicts / make_ics) — consumed via the localhost MCP endpoint, so the +agent demonstrably works through the public tool contract, not private +imports. Everything stays local llama.cpp: no cloud AI APIs, every model +under the 32B cap (gemma-cal E4B ~4B + MiniCPM 8B or 1B). + +Stub mode (USE_STUB_EXTRACTOR=1, used by the free preview and CI) — or any +planner failure — falls back to ScriptedPlanner: the same tool sequence run +deterministically, emitting identical step events, so the tab always works +and tests never need a model. + +Steps are plain JSON-serialisable dicts: + {"kind": "plan"|"tool_call"|"tool_result"|"final"|"error", ...} +""" +from __future__ import annotations + +import json +import os +from typing import Iterator, Optional + +from server import events as bus + +# Planner serving (second llama-server) — env-selected, OFF by default. +# 8B default for planning quality; MiniCPM5-1B is the <=4B tiny variant. +PLANNER_BASE_URL = os.environ.get("PLANNER_BASE_URL", "http://127.0.0.1:8081/v1") +PLANNER_MODEL_ID = os.environ.get("PLANNER_MODEL_ID", "minicpm-planner") +# Self MCP endpoint (localhost — no HF edge/auth between us and ourselves). +MCP_SSE_URL = os.environ.get( + "MCP_SSE_URL", f"http://127.0.0.1:{os.environ.get('PORT', '7860')}/gradio_api/mcp/sse" +) + +ORCH_TASK = """You are a scheduling agent for a busy parent. Read the thread below. + +Call exactly ONE tool — extract_events on the thread — then STOP. It returns the +events (the fine-tuned calendar model does the real work), a reply draft, and any +clarification. After that one call, return a short JSON summary: {{"events": }}. +Do NOT call any other tool: conflict-checking and the .ics file are handled for you. + +{memory} + +Thread: +{thread} +""" + + +def _planner_configured() -> bool: + return bool(os.environ.get("PLANNER_HF_REPO") or os.environ.get("PLANNER_BASE_URL")) + + +def _use_llm_planner() -> bool: + return os.environ.get("USE_STUB_EXTRACTOR") != "1" and _planner_configured() + + +def _short(obj, limit: int = 1200) -> str: + try: + s = obj if isinstance(obj, str) else json.dumps(obj, default=str) + except Exception: # noqa: BLE001 + s = str(obj) + return s if len(s) <= limit else s[:limit] + " …" + + +# --------------------------------------------------------------------------- # +# ScriptedPlanner — deterministic fallback / stub-mode path +# --------------------------------------------------------------------------- # +def _scripted_steps(thread: str, ics_b64: Optional[str], + memory_block: Optional[str], + images: Optional[list[str]] = None) -> Iterator[dict]: + from server import mcp_tools + + yield {"kind": "plan", + "text": "Playbook: extract events from the thread" + + (f" + {len(images)} screenshot(s)" if images else "") + + (", check conflicts against the provided calendar" if ics_b64 else "") + + ", then render an .ics."} + + yield {"kind": "tool_call", "tool": "extract_events", + "args": {"thread": _short(thread, 300), + **({"images": f"{len(images)} image(s)"} if images else {}), + **({"memory": ""} if memory_block else {})}} + plan = mcp_tools.extract_events(thread, images or None, memory_block) + yield {"kind": "tool_result", "tool": "extract_events", + "result": {"events": len(plan.get("events", [])), + "reply_draft": _short(plan.get("reply_draft") or "", 200)}} + + conflicts: list = list(plan.get("conflicts") or []) + if ics_b64 and plan.get("events"): + yield {"kind": "tool_call", "tool": "check_conflicts", + "args": {"events": f"{len(plan['events'])} event(s)", "ics_base64": ""}} + conflicts = mcp_tools.check_conflicts(plan["events"], ics_b64) + plan["conflicts"] = conflicts + yield {"kind": "tool_result", "tool": "check_conflicts", + "result": {"conflicts": len(conflicts)}} + + ics_out = None + if plan.get("events"): + yield {"kind": "tool_call", "tool": "make_ics", + "args": {"events": f"{len(plan['events'])} event(s)"}} + ics_out = mcp_tools.make_ics(plan["events"]) + yield {"kind": "tool_result", "tool": "make_ics", + "result": {"ics_bytes": len(ics_out or "")}} + + yield {"kind": "final", "plan": plan, "ics_base64": ics_out, + "summary": {"events": len(plan.get("events", [])), "conflicts": len(conflicts)}} + + +# --------------------------------------------------------------------------- # +# smolagents path — MiniCPM planner over the self MCP endpoint +# --------------------------------------------------------------------------- # +def _smol_steps(thread: str, ics_b64: Optional[str], + memory_block: Optional[str], max_steps: int, + images: Optional[list[str]] = None) -> Iterator[dict]: + # Lazy imports: smolagents is only needed on the real path, keeping CI and + # the stub preview dependency-free. + from smolagents import OpenAIServerModel, ToolCallingAgent # noqa: PLC0415 + from smolagents.mcp_client import MCPClient # noqa: PLC0415 + + model = OpenAIServerModel( + model_id=PLANNER_MODEL_ID, api_base=PLANNER_BASE_URL, + api_key=os.environ.get("PLANNER_API_KEY", "local"), temperature=0.0, + ) + task = ORCH_TASK.format( + memory=(f"What you know about this user:\n{memory_block}" if memory_block else ""), + thread=thread, + ) + + yield {"kind": "plan", "text": f"MiniCPM planner ({PLANNER_MODEL_ID}) engaged — " + f"tools via MCP at {MCP_SSE_URL}"} + with MCPClient({"url": MCP_SSE_URL, "transport": "sse"}) as tools: + # Minimal-footprint planner: expose ONLY extract_events and cap the loop + # at a couple of steps. The fine-tuned E4B (inside extract_events) does + # the real work; conflict-checking and the .ics are finalized + # deterministically by _scripted_steps below. This keeps the planner to a + # single tool call so it stays fast and never accumulates enough context + # to overflow (multi-step runs hit ~207s and 'request exceeds context'). + # Restricting tools also avoids the File-input callbacks whose schemas + # $ref #/$defs/FileData (which the planner's jinja rendering can't resolve). + _WANTED = {"extract_events"} + tools = [t for t in tools if getattr(t, "name", "") in _WANTED] + agent = ToolCallingAgent(tools=tools, model=model, max_steps=min(max_steps, 3)) + result = None + for step in agent.run(task, stream=True): + kind = type(step).__name__ + if kind == "ActionStep": + for call in (getattr(step, "tool_calls", None) or []): + yield {"kind": "tool_call", + "tool": getattr(call, "name", "?"), + "args": _short(getattr(call, "arguments", ""))} + obs = getattr(step, "observations", None) + if obs: + yield {"kind": "tool_result", "tool": "(observation)", + "result": _short(obs)} + elif kind == "FinalAnswerStep": + result = getattr(step, "final_answer", None) or getattr(step, "output", None) + yield {"kind": "plan", "text": f"Planner finished: {_short(result, 300)}"} + + # The planner's free-text answer isn't the product — re-derive the + # structured plan through the deterministic path so the UI always gets a + # valid ActionPlan + ics, with the planner trace above as the evidence. + yield from _scripted_steps(thread, ics_b64, memory_block, images) + + +# --------------------------------------------------------------------------- # +# Entry point +# --------------------------------------------------------------------------- # +def run_orchestrator(thread: str, ics_b64: Optional[str] = None, + memory_block: Optional[str] = None, + max_steps: int = 6, + images: Optional[list[str]] = None) -> Iterator[dict]: + """Yield orchestration steps for a thread (+ optional screenshot data URIs); + always ends with a 'final' step (or an 'error' followed by the scripted + fallback's steps).""" + with bus.run_scope("agent"): + bus.emit("decision", "agent orchestrator run started") + if _use_llm_planner(): + try: + yield from _smol_steps(thread, ics_b64, memory_block, max_steps, images) + bus.emit("decision", "agent orchestrator run finished (MiniCPM planner)") + return + except Exception as e: # noqa: BLE001 planner down -> scripted fallback + # Surface the actual message (e.g. which module is missing), not + # just the type — a bare "ModuleNotFoundError" hides the cause. + detail = f"{type(e).__name__}: {e}".strip().rstrip(":") + yield {"kind": "error", + "text": f"Planner unavailable ({_short(detail, 160)}) — " + "falling back to the scripted playbook."} + yield from _scripted_steps(thread, ics_b64, memory_block, images) + bus.emit("decision", "agent orchestrator run finished (scripted)") diff --git a/server/pipeline.py b/server/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..ab1d2413d635884cc3f7d144b62c8d07cbe40008 --- /dev/null +++ b/server/pipeline.py @@ -0,0 +1,98 @@ +"""The shared 'thread in -> ActionPlan out' pipeline. + +One implementation behind both the synchronous ``POST /agent`` endpoint and the +autonomous ingest path, so the two can never drift. Stateless: it does not touch +the feed or the dedup store (callers own statefulness). + +Importable without Gradio. Google Calendar is imported lazily so CI / stub mode +(which exclude the google libs) stay clean. +""" +from __future__ import annotations + +import base64 +from typing import Optional + +from dateutil import parser as dtparser +from pydantic import BaseModel + +from calendar_out.freebusy import DEFAULT_DURATION, Busy, _as_dt, annotate_conflicts, load_ics_busy +from calendar_out.ics import events_to_ics +from server import events as bus +from server.agent import run_agent +from server.schema import ActionPlan, Event +from server.threads import format_thread + + +class AgentMessage(BaseModel): + sender: str = "?" + text: str = "" + + +class AgentRequest(BaseModel): + thread: Optional[str] = None + messages: Optional[list[AgentMessage]] = None + images: list[str] = [] # base64 data URIs + existing_ics: Optional[str] = None # base64-encoded .ics bytes + existing_events: list[Event] = [] + now: Optional[str] = None # ISO 8601; defaults to datetime.now() + push_gcal: bool = False + return_ics: bool = False + memory: Optional[str] = None # per-user recall block (else server global memory) + + +class AgentResponse(BaseModel): + plan: ActionPlan + ics_base64: Optional[str] = None + gcal_links: list[str] = [] + + +def _busy_from_request(req: AgentRequest) -> list[Busy]: + """Build busy intervals from an uploaded .ics or structured existing events.""" + if req.existing_ics: + try: + return load_ics_busy(base64.b64decode(req.existing_ics)) + except Exception: # noqa: BLE001 malformed .ics -> no conflict context + return [] + busy: list[Busy] = [] + for ev in req.existing_events: + start = _as_dt(ev.start) + if start is None: + continue + end = _as_dt(ev.end) or (start + DEFAULT_DURATION) + busy.append(Busy(start=start, end=end, title=ev.title)) + return busy + + +def _thread_text(req: AgentRequest) -> str: + if req.thread: + return req.thread + if req.messages: + return format_thread([m.model_dump() for m in req.messages]) + return "" + + +def run_pipeline(req: AgentRequest) -> AgentResponse: + """thread/messages -> run_agent -> deterministic conflicts -> optional ics/gcal.""" + thread = _thread_text(req) + now = dtparser.isoparse(req.now) if req.now else None + busy = _busy_from_request(req) + + plan = run_agent(thread, now=now, existing=req.existing_events, images=req.images, + memory_block=req.memory) + if busy: + plan = annotate_conflicts(plan, busy) + + resp = AgentResponse(plan=plan) + + if req.return_ics: + resp.ics_base64 = base64.b64encode(events_to_ics(plan.events)).decode("ascii") + + if req.push_gcal and plan.events: + try: + from calendar_out.gcal import push_events # lazy: google libs optional + + resp.gcal_links = push_events(plan.events) + except Exception as e: # noqa: BLE001 no token.json / offline -> degrade + bus.emit("calendar", f"Google Calendar push skipped: {e}", level="error") + + return resp diff --git a/server/schema.py b/server/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..88ca73ffbf549158c5688d8194a9ca97c6aec206 --- /dev/null +++ b/server/schema.py @@ -0,0 +1,43 @@ +"""Shared pydantic schemas for the scheduling agent. + +The model is constrained to emit an ActionPlan (see server/agent.py); these types +are also the contract used by the UI and the calendar outputs. +""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class Event(BaseModel): + title: str + start: str # ISO 8601, e.g. 2026-06-10T13:00:00 + end: Optional[str] = None + location: Optional[str] = None + attendees: list[str] = Field(default_factory=list) + reminder_minutes: Optional[int] = None + notes: Optional[str] = None + + +class Conflict(BaseModel): + event_index: int = Field(description="index into ActionPlan.events") + clashes_with: str = Field(description="summary of the existing event it clashes with") + severity: str = Field(description='one of: "overlap", "adjacent", "tight"') + + +class ActionPlan(BaseModel): + """Everything the agent decides for one thread, in one constrained object.""" + + reasoning: Optional[str] = Field( + default=None, description="brief chain of thought shown to the user" + ) + events: list[Event] = Field(default_factory=list) + conflicts: list[Conflict] = Field(default_factory=list) + proposed_times: list[str] = Field( + default_factory=list, description="ISO 8601 alternatives when there is a conflict" + ) + reply_draft: str = Field(default="", description="suggested reply to send back") + needs_clarification: Optional[str] = Field( + default=None, description="a question to ask if the plan is ambiguous" + ) diff --git a/server/threads.py b/server/threads.py new file mode 100644 index 0000000000000000000000000000000000000000..fa0e8bfc598f665e4665264717bc71722364b54a --- /dev/null +++ b/server/threads.py @@ -0,0 +1,59 @@ +"""Assemble a conversation thread from individual messages. + +Used by both the ``/agent`` endpoint (join a posted ``messages[]`` into a thread) +and autonomous mode (build a per-chat rolling window from the ingest feed). Pure — +no Gradio / llama / network — so it's trivially unit-testable in stub mode. +""" +from __future__ import annotations + +import os + +from dateutil import parser as dtparser + + +def format_thread(messages: list[dict]) -> str: + """Render messages as ``"sender: text"`` lines, skipping empty bodies.""" + lines = [] + for m in messages: + text = (m.get("text") or "").strip() + if not text: + continue + sender = (m.get("sender") or "?").strip() + lines.append(f"{sender}: {text}") + return "\n".join(lines) + + +def _ts(value) -> float | None: + try: + return dtparser.parse(str(value)).timestamp() + except (ValueError, TypeError, OverflowError): + return None + + +def rolling_thread( + feed: list[dict], + chat: str, + window: int | None = None, + minutes: int | None = None, +) -> str: + """Build a thread from the most recent messages of one chat in the feed. + + Keeps the last ``window`` messages for ``chat`` that fall within ``minutes`` of + the newest one (env-tunable via AUTO_THREAD_WINDOW / AUTO_THREAD_MINUTES). + """ + window = window or int(os.environ.get("AUTO_THREAD_WINDOW", "20")) + minutes = minutes or int(os.environ.get("AUTO_THREAD_MINUTES", "720")) + + msgs = [m for m in feed if (m.get("chat") or "") == chat] + if not msgs: + return "" + msgs = msgs[-window:] + + # Drop messages older than `minutes` before the newest (when timestamps parse). + stamps = [(_ts(m.get("timestamp")), m) for m in msgs] + newest = max((s for s, _ in stamps if s is not None), default=None) + if newest is not None: + cutoff = newest - minutes * 60 + msgs = [m for s, m in stamps if s is None or s >= cutoff] + + return format_thread(msgs) diff --git a/server/tools.py b/server/tools.py new file mode 100644 index 0000000000000000000000000000000000000000..dadc86a145ca9ff2ce78d7b0ad6a88eef16566f2 --- /dev/null +++ b/server/tools.py @@ -0,0 +1,81 @@ +"""Hermes tool-calling: let the model write its own long-term memory. + +Hermes is a tool-calling fine-tune. When `HERMES_TOOLS=1`, the remote inference +path (server/model.py) advertises these tools so the model can call `remember` +mid-run to save durable facts ("Dana is the soccer coach", "you decline Mondays") +— the active half of "grows with you". Kept separate + small so the round-trip +logic is unit-testable without a live server. +""" +from __future__ import annotations + +import json + +from . import memory + +# OpenAI-compatible tool specs (llama-server understands these with --jinja). +TOOL_SPECS = [ + { + "type": "function", + "function": { + "name": "remember", + "description": ( + "Save a durable fact or preference about the user to long-term memory " + "so future scheduling is more personal. Use for stable facts only " + "(roles, recurring preferences, default locations), not one-off details." + ), + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "the fact, e.g. 'Dana is the soccer coach'", + }, + "kind": { + "type": "string", + "enum": ["contact", "preference", "location", "note"], + }, + }, + "required": ["text"], + }, + }, + } +] + + +def dispatch(name: str, arguments) -> str: + """Execute one tool call; returns a short result string for the tool message.""" + if name != "remember": + return f"unknown tool: {name}" + try: + args = json.loads(arguments) if isinstance(arguments, str) else (arguments or {}) + except (ValueError, TypeError): + args = {} + text = (args.get("text") or "").strip() + if not text: + return "no text provided" + memory.remember(text, args.get("kind", "note")) + return f"remembered: {text}" + + +def run_with_tools(messages: list[dict], post_fn, max_rounds: int = 3): + """Drive a tool-calling loop. ``post_fn(messages) -> openai_response_dict`` does + the actual HTTP POST (tools already configured by the caller); injectable so the + loop is testable. Returns (final_content, last_response).""" + msgs = list(messages) + resp = {} + for _ in range(max_rounds): + resp = post_fn(msgs) + msg = resp["choices"][0]["message"] + tool_calls = msg.get("tool_calls") or [] + if not tool_calls: + return msg.get("content", ""), resp + msgs.append(msg) # assistant turn carrying the tool_calls + for tc in tool_calls: + fn = tc.get("function", {}) + result = dispatch(fn.get("name", ""), fn.get("arguments", "{}")) + msgs.append( + {"role": "tool", "tool_call_id": tc.get("id", ""), "content": result} + ) + # ran out of rounds — one final call to get content + resp = post_fn(msgs) + return resp["choices"][0]["message"].get("content", ""), resp diff --git a/server/trace.py b/server/trace.py new file mode 100644 index 0000000000000000000000000000000000000000..b95b4270067ef0962dbf76d19f13eaff9026c6af --- /dev/null +++ b/server/trace.py @@ -0,0 +1,98 @@ +"""Export an agent run as a portable, shareable trace (Sharing is Caring). + +The activity bus (``server/events.py``) groups every event from one agent run +under a ``run_scope`` id. This module serializes such a run into a small, +self-contained JSON envelope that a user can download and (optionally) publish to +the Hugging Face Hub with ``training/share_trace.py``. + +Privacy: the bus is structural by design — every ``emit(...)`` carries counts + +short status strings, never event titles or raw thread text. The *only* free-text +that can carry personal data is the chat-name suffix in the ingest message +(``app.py``: ``"N msg(s) from {chats}"``). With ``redact=True`` (the default) that +tail is dropped. Steps use a fixed key allowlist, so a future payload key can't +silently leak into a shared trace. +""" +from __future__ import annotations + +import json +import os +import re +import tempfile +from datetime import datetime + +from . import events as bus + +TRACE_SCHEMA = "imessage-cal-trace" +TRACE_SCHEMA_VERSION = 1 + +# Only these keys ever appear in an exported step (allowlist, not denylist). +_STEP_KEYS = ("stage", "level", "ts", "latency_ms", "events", "conflicts", "images", "tokens") + + +def _scrub_message(stage: str, message: str, redact: bool) -> str: + """All bus messages are structural except the ingest one, which appends + ``" from {chats}"`` (chat names — PII). Drop that tail when redacting.""" + if redact and stage == "ingest": + # "3 msg(s) from 3rd grade chat" -> "3 msg(s)" + return re.sub(r"\s+from\s+.*$", "", message) + return message + + +def _step(ev: dict, redact: bool) -> dict: + step = {k: ev[k] for k in _STEP_KEYS if k in ev} + step["message"] = _scrub_message(ev.get("stage", ""), ev.get("message", ""), redact) + return step + + +def export_run(run_id: str | None = None, redact: bool = True) -> dict: + """Serialize one agent run (newest by default) into a shareable envelope. + + Returns a valid empty envelope (``steps == []``) when there is no matching + run, so callers don't need to handle exceptions. + """ + runs = bus.recent_runs(n=50) # newest first + evs: list[dict] = [] + rid = run_id + if run_id is None: + if runs: + rid, evs = runs[0] + else: + for r, e in runs: + if r == run_id: + evs = e + break + + steps = [_step(e, redact) for e in evs] + summary = { + "steps": len(steps), + "events": sum(s.get("events", 0) for s in steps), + "conflicts": sum(s.get("conflicts", 0) for s in steps), + "images": sum(s.get("images", 0) for s in steps), + "model_calls": sum(1 for s in steps if s.get("latency_ms") is not None), + "total_latency_ms": sum(s.get("latency_ms", 0) for s in steps), + } + return { + "schema": TRACE_SCHEMA, + "version": TRACE_SCHEMA_VERSION, + "exported_at": datetime.now().isoformat(timespec="seconds"), + "run_id": rid, + # run ids look like "12:analyze" — the label is the part after ":". + "run_label": (rid.split(":", 1)[1] if rid and ":" in rid else None), + "redacted": redact, + "steps": steps, + "summary": summary, + } + + +def write_trace(trace: dict, path: str | None = None) -> str: + """Write a trace envelope to a JSON file and return the path (Gradio download). + + Mirrors ``calendar_out.ics.write_ics``. Deliberately does NOT emit a bus event + — that would mutate the very run being exported. + """ + if path is None: + fd, path = tempfile.mkstemp(suffix=".json", prefix="trace_") + os.close(fd) + with open(path, "w", encoding="utf-8") as f: + json.dump(trace, f, indent=2, ensure_ascii=False) + return path diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000000000000000000000000000000000000..fb27eaae91f78fb9293366827f426f1451ced225 --- /dev/null +++ b/static/app.css @@ -0,0 +1,961 @@ +/* OffGridSchedula — "daylight planner" theme. + Soft lavender-paper canvas, deep-ink text (high contrast), violet→cyan identity + used on the primary action + key accents. Fraunces (display) + Hanken Grotesk. */ +@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Hanken+Grotesk:wght@400;500;600;700&display=swap'); + +:root { + --bg: #f4f2fb; /* soft lavender paper */ + --bg2: #ffffff; /* inputs */ + --surface: #ffffff; /* cards */ + --surface2: #efecf9; + --line: rgba(31,25,60,0.12); + --text: #1e1934; /* deep ink */ + --muted: #645c84; + --violet: #6d4be0; + --cyan: #0e8ea0; + --coral: #d83a60; + --mint: #15894f; + --amber: #b3700a; + --accent: linear-gradient(100deg, #6d4be0 0%, #0b8294 100%); + --radius: 16px; + --shadow: 0 12px 30px rgba(45,32,90,0.12); +} + +/* ---- canvas + base type ---- */ +.gradio-container, .gradio-container * { + font-family: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif !important; +} +.gradio-container { + background: + radial-gradient(1100px 520px at 12% -8%, #ece5ff 0%, transparent 55%), + radial-gradient(900px 500px at 100% 0%, #ddf2f4 0%, transparent 50%), + var(--bg) !important; + color: var(--text) !important; + + /* Map Gradio's own theme tokens to our light palette so every component is + light-surface / dark-text and stays readable. */ + --body-background-fill: var(--bg); + --body-text-color: var(--text); + --body-text-color-subdued: var(--muted); + --background-fill-primary: var(--surface); + --background-fill-secondary: var(--surface2); + --block-background-fill: var(--surface); + --block-label-background-fill: var(--surface); + --block-label-text-color: var(--muted); + --block-title-text-color: var(--text); + --block-info-text-color: var(--muted); + --block-border-color: var(--line); + --border-color-primary: var(--line); + --border-color-accent: rgba(109,75,224,.5); + --input-background-fill: var(--bg2); + --input-border-color: var(--line); + --input-placeholder-color: var(--muted); + --button-secondary-background-fill: var(--surface2); + --button-secondary-text-color: var(--text); + --button-secondary-border-color: var(--line); + --link-text-color: var(--cyan); + --link-text-color-hover: var(--cyan); + --color-accent: var(--violet); + --color-accent-soft: rgba(109,75,224,.14); + --table-text-color: var(--text); + --table-even-background-fill: var(--surface); + --table-odd-background-fill: var(--surface2); +} +/* Belt-and-suspenders for the common readable bits (markdown, labels, inputs). */ +.gradio-container .prose, +.gradio-container .prose p, .gradio-container .prose li, +.gradio-container .prose h1, .gradio-container .prose h2, .gradio-container .prose h3, +.gradio-container .prose strong, .gradio-container .prose em, +.gradio-container label, .gradio-container .gr-box label, +.gradio-container input, .gradio-container textarea { + color: var(--text) !important; +} +/* NOTE: checkboxes/radios are excluded — the `background` shorthand would wipe + Gradio's checked-state background-image (the checkmark), so they never look + checked. Style their accent instead and leave the rest to Gradio. */ +.gradio-container input:not([type="checkbox"]):not([type="radio"]), +.gradio-container textarea { background: var(--bg2) !important; } +.gradio-container input[type="checkbox"], .gradio-container input[type="radio"] { + accent-color: var(--violet); cursor: pointer; } +.gradio-container .tab-nav button { color: var(--muted); } +.gradio-container .tab-nav button.selected { color: var(--text); border-bottom-color: var(--violet); } +.gradio-container h1, .gradio-container h2, .gradio-container h3, +#app-header, .evx-title, .evx-head { + font-family: "Fraunces", Georgia, serif !important; + letter-spacing: -0.01em; +} + +#app-header { + display: flex; align-items: center; gap: .5rem; + font-size: 1.9rem; font-weight: 700; line-height: 1.1; + margin-bottom: .15rem; + background: var(--accent); + -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* ---- Review: input card ---- */ +.rv-input { + background: var(--surface) !important; + border: 1px solid var(--line) !important; + border-radius: var(--radius) !important; + padding: 14px !important; + box-shadow: var(--shadow); +} +#rv-textbox textarea { + font-size: 1rem !important; + line-height: 1.5 !important; + background: var(--bg2) !important; + border-radius: 12px !important; +} +#rv-actions { gap: 10px; margin-top: 10px; } + +/* primary action — the one place the full accent gradient lives */ +#rv-analyze button { + background: var(--accent) !important; + color: #fff !important; + font-weight: 700 !important; + font-size: 1.02rem !important; + border: none !important; + min-height: 50px !important; + border-radius: 12px !important; + box-shadow: 0 8px 20px rgba(109,75,224,0.28); + transition: transform .12s ease, box-shadow .12s ease, filter .12s ease; +} +#rv-analyze button:hover { transform: translateY(-1px); filter: brightness(1.06); box-shadow: 0 12px 28px rgba(11,130,148,0.3); } +#rv-analyze button:active { transform: translateY(0); } + +.rv-status { color: var(--muted); font-size: .9rem; min-height: 1.2em; padding: 2px 2px 0; } + +/* ---- plan summary (reasoning + conflict badges + free-slot chips) ---- */ +.pl-summary { + background: var(--surface) !important; + border: 1px solid var(--line); + border-left: 3px solid var(--violet); + border-radius: 12px; + padding: 14px 16px; + margin: 10px 0; + box-shadow: var(--shadow); + animation: rise .35s ease both; +} +.pl-reason { margin: 0 0 8px; color: var(--text); font-size: .96rem; line-height: 1.5; } +.pl-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; } +.pl-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +.pl-badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 11px; border-radius: 999px; font-size: .82rem; font-weight: 600; +} +.pl-conflict { background: rgba(216,58,96,.12); color: #b21d44; border: 1px solid rgba(216,58,96,.3); } +.pl-chip { + padding: 5px 11px; border-radius: 999px; font-size: .82rem; font-weight: 600; + background: rgba(14,142,160,.12); color: #0a6f7d; border: 1px solid rgba(14,142,160,.3); +} +.pl-clarify { margin: 10px 0 0; color: var(--amber); font-size: .9rem; } +.pl-clear { margin: 0; color: var(--mint); font-weight: 600; } + +/* ---- events: billboard (featured) + cards ---- */ +.evx-head { font-size: 1.05rem; font-weight: 600; color: var(--text); margin: 6px 2px 10px; } + +/* Billboard / carousel slide: a soft tinted card with dark text */ +.bb { + position: relative; overflow: hidden; border-radius: 18px; margin: 0 0 18px; + min-height: 196px; display: flex; align-items: flex-end; + background: linear-gradient(120deg, #ece4ff 0%, #d7f1f4 100%); + border: 1px solid var(--line); box-shadow: var(--shadow); +} +.bb-scrim { position: absolute; inset: 0; + background: linear-gradient(to top, rgba(255,255,255,.45) 0%, rgba(255,255,255,.1) 45%, transparent 100%); } +.bb-body { position: relative; z-index: 2; padding: 22px 24px; width: 100%; animation: rise .45s ease both; } +.bb-kicker { color: var(--cyan); font-weight: 700; font-size: .74rem; letter-spacing: .14em; text-transform: uppercase; margin-bottom: 8px; } +.bb-title { font-family: "Fraunces", serif; font-size: 2rem; line-height: 1.05; margin: 0 0 10px; color: var(--text); } +.bb-when { font-size: 1rem; font-weight: 600; color: #3a3357; } +.bb-note { margin-top: 8px; color: var(--muted); font-style: italic; } + +.evx-sec { font-size: .76rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 2px 2px 10px; } +.evx-cards { display: flex; flex-direction: column; gap: 12px; } +.evx-card { + position: relative; display: flex; gap: 0; + background: var(--surface); border: 1px solid var(--line); + border-radius: 14px; overflow: hidden; + box-shadow: 0 4px 14px rgba(45,32,90,.10); + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; + animation: rise .4s ease both; animation-delay: calc(var(--i, 0) * 70ms); +} +.evx-card:hover { /* subtle tactile lift */ + transform: translateY(-3px) scale(1.012); + box-shadow: 0 14px 32px rgba(45,32,90,.18); border-color: rgba(109,75,224,.4); +} +.evx-bar { width: 5px; flex: 0 0 5px; background: var(--accent); } +.evx-body { padding: 13px 16px; min-width: 0; } +.evx-title { margin: 0 0 8px; font-size: 1.1rem; font-weight: 600; color: var(--text); } +.evx-chip { display: inline-block; padding: 4px 10px; border: 1px solid var(--line); + border-radius: 8px; font-size: .82rem; font-weight: 600; color: var(--text); background: var(--surface2); } +.evx-when { font-size: .92rem; color: var(--muted); font-weight: 600; } +.evx-meta { font-size: .85rem; color: var(--muted); margin-top: 8px; } +.evx-note { font-size: .82rem; color: var(--muted); margin-top: 6px; font-style: italic; } +/* per-event one-click quick-add links (Online mode) */ +.evx-add { font-size: .8rem; color: var(--muted); margin-top: 8px; } +.evx-add a { color: var(--cyan); font-weight: 700; text-decoration: none; } +.evx-add a:hover { text-decoration: underline; } +/* Agent tab: the orchestrator's step trace */ +.ag-trace { display: flex; flex-direction: column; gap: 6px; } +.ag-step { display: flex; gap: 10px; align-items: flex-start; background: var(--surface); + border: 1px solid var(--line); border-left: 3px solid var(--violet); + border-radius: 10px; padding: 9px 12px; font-size: .9rem; animation: rise .3s ease both; } +.ag-step code { background: var(--surface2); padding: 1px 6px; border-radius: 6px; + font-size: .82em; word-break: break-all; } +.ag-tool_call { border-left-color: var(--cyan); } +.ag-tool_result { border-left-color: var(--mint); } +.ag-final { border-left-color: var(--mint); background: rgba(21,137,79,.07); font-weight: 600; } +.ag-error { border-left-color: var(--coral); } +.ag-ico { flex: none; } + +/* iPhone share-sheet Shortcut callout (export bar) */ +.ship-note { color: var(--muted); font-size: .82rem; margin-top: 8px; } +.ship-note a { color: var(--cyan); font-weight: 600; text-decoration: none; } +.ship-note a:hover { text-decoration: underline; } +.evx-empty { color: var(--muted); padding: 18px; text-align: center; border: 1px dashed var(--line); border-radius: 12px; } + +/* Horizontal swipe rail (kept for any list use) */ +.evx-rail { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 6px; + scroll-snap-type: x mandatory; scrollbar-width: none; } +.evx-rail::-webkit-scrollbar { display: none; } +.evx-rail .evx-card { flex: 0 0 78%; max-width: 320px; scroll-snap-align: start; } + +/* ---- rotating hero carousel (auto-advance + arrows + dots) ---- */ +.carousel { position: relative; border-radius: 18px; overflow: hidden; margin: 0 0 16px; + box-shadow: var(--shadow); border: 1px solid var(--line); } +.cz-track { position: relative; min-height: 200px; } +.cz-slide { position: absolute; inset: 0; opacity: 0; transform: translateX(16px); + transition: opacity .5s ease, transform .5s ease; pointer-events: none; + border: 0 !important; border-radius: 0 !important; box-shadow: none !important; margin: 0 !important; } +.cz-slide.is-active { opacity: 1; transform: none; pointer-events: auto; } +.carousel .bb-body { padding: 24px 125px 42px 150px; } /* clear the prev arrow (left) and keep text 125px off the next button (right) */ +.cz-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 5; + width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--line); + background: rgba(31,25,60,.55); color: #fff; font-size: 1.3rem; line-height: 1; cursor: pointer; + display: flex; align-items: center; justify-content: center; transition: background .15s, transform .15s; } +.cz-arrow:hover { background: rgba(31,25,60,.8); transform: translateY(-50%) scale(1.08); } +.cz-prev { left: 12px; } .cz-next { right: 12px; } +.cz-dots { position: absolute; bottom: 12px; left: 0; right: 0; z-index: 5; + display: flex; gap: 8px; justify-content: center; } +.cz-dot { width: 8px; height: 8px; border-radius: 50%; border: 0; cursor: pointer; padding: 0; + background: rgba(31,25,60,.28); transition: width .2s ease, background .2s ease; } +.cz-dot.is-active { width: 22px; border-radius: 4px; background: var(--accent); } +@media (prefers-reduced-motion: reduce) { .cz-slide { transition: opacity .01s linear; } } +@media (max-width: 640px) { + .cz-track { min-height: 172px; } + .carousel .bb-body { padding: 18px 18px 34px 56px; } /* clear the (smaller) mobile prev arrow */ + .cz-arrow { width: 32px; height: 32px; font-size: 1.1rem; } +} + +/* ---- reply + export ---- */ +.rv-reply { margin-top: 14px; } +.rv-copy button { background: var(--surface2) !important; border: 1px solid var(--line) !important; color: var(--text) !important; } +#rv-export, .ag-export { + margin-top: 14px; padding: 12px !important; + background: var(--surface) !important; border: 1px solid var(--line) !important; + border-radius: 14px !important; box-shadow: var(--shadow); +} +#rv-export button, .ag-export button { min-height: 46px !important; font-weight: 600 !important; border-radius: 11px !important; } +#rv-export .gr-button-primary, #rv-export button.primary, +.ag-export .gr-button-primary, .ag-export button.primary { + background: var(--accent) !important; color: #fff !important; border: none !important; +} + +/* ---- shared (Activity / Memory) ---- */ +.muted { color: var(--muted); font-style: italic; padding: 8px 2px; } +.stepper { display: flex; gap: 8px; flex-wrap: wrap; margin: 4px 0 10px; } +.step { position: relative; padding: 6px 14px; border-radius: 999px; font-size: .8rem; font-weight: 600; + color: var(--muted); background: var(--surface); border: 1px solid var(--line); } +.step:not(:last-child)::after { content: "→"; position: absolute; right: -14px; top: 50%; transform: translateY(-50%); color: var(--muted); } +.step.active { color: #fff; background: var(--c); border-color: var(--c); animation: pulse 1.4s infinite; } +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 70%, transparent); } + 70% { box-shadow: 0 0 0 10px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} +.tiles { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 14px; } +.tile { flex: 1 1 110px; background: var(--surface); border: 1px solid rgba(109,75,224,.18); + border-radius: 12px; padding: 12px 14px; text-align: center; box-shadow: 0 3px 12px rgba(45,32,90,.07); } +.tile-v { font-size: 1.5rem; font-weight: 700; font-family: "Fraunces", serif; + background: var(--accent); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } +.tile-k { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } +.timeline { display: flex; flex-direction: column; gap: 6px; max-height: 360px; overflow-y: auto; flex: 1; } +.evt { display: grid; grid-template-columns: 84px 1fr auto auto; gap: 10px; align-items: center; + padding: 7px 12px; background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--c); border-radius: 8px; font-size: .82rem; } +.evt.err { border-left-color: var(--coral); background: #fdecef; } +.evt-stage { color: var(--c); font-weight: 700; text-transform: uppercase; font-size: .7rem; filter: brightness(.78); } +.evt-msg { color: var(--text); } +.evt-meta, .evt-ts { color: var(--muted); font-family: ui-monospace, monospace; font-size: .72rem; } +.trace { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-bottom: 6px; } +.trace summary { cursor: pointer; font-weight: 600; color: var(--text); } +.trace-line { font-family: ui-monospace, monospace; font-size: .78rem; color: var(--muted); padding: 2px 0 2px 14px; } +.trace-stage { font-weight: 700; text-transform: uppercase; font-size: .68rem; margin-right: 6px; filter: brightness(.78); } +.event-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } + +@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + +/* ---- mobile-first ---- */ +@media (max-width: 640px) { + #app-header { font-size: 1.5rem; } + #rv-actions { flex-direction: column; } + #rv-actions button { width: 100% !important; } + .evx-title { font-size: 1.05rem; } + .bb { min-height: 168px; } + .bb-body { padding: 18px; } + .bb-title { font-size: 1.55rem; } + .evx-rail .evx-card { flex: 0 0 86%; } + .tiles { gap: 8px; } + .tile { flex: 1 1 calc(50% - 8px); } + .evt { grid-template-columns: 1fr; gap: 2px; } + .evt-meta, .evt-ts { display: none; } + /* keep the export actions reachable on a phone */ + #rv-export { + position: sticky; bottom: 0; z-index: 20; + background: rgba(255,255,255,.94) !important; + backdrop-filter: blur(8px); + box-shadow: 0 -8px 24px rgba(45,32,90,.18); + } +} + +/* ---- showcase carousel: image-background slides (data-URI SVG illustrations) ---- */ +/* !important so Gradio's own h2/theme heading color can't override the slide text */ +.carousel .bb-img .bb-title, .bb-img .bb-title { color: #ffffff !important; } +.carousel .bb-img .bb-when, .bb-img .bb-when { color: #ffffff !important; } +.bb-img .bb-note { color: #e7e2fb !important; } +.bb-img .bb-kicker { color: #bff2f8 !important; } +.bb-scrim-dark { background: linear-gradient(to top, rgba(12,8,28,.86) 0%, rgba(12,8,28,.42) 55%, rgba(12,8,28,.15) 100%); } +.cz-bg-chat { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MDAgNDAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBzbGljZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnMSIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzNhMmE3MiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBlNWY2ZSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNDAwIiBmaWxsPSJ1cmwoI2cxKSIvPjxnIGZpbGw9IiNmZmZmZmYiIG9wYWNpdHk9IjAuMTYiPjxyZWN0IHg9IjkwIiB5PSI5MCIgcng9IjIyIiByeT0iMjIiIHdpZHRoPSIyNDAiIGhlaWdodD0iMTIwIi8+PHJlY3QgeD0iMzYwIiB5PSIxNzAiIHJ4PSIyMiIgcnk9IjIyIiB3aWR0aD0iMzAwIiBoZWlnaHQ9IjEyMCIvPjwvZz48ZyBmaWxsPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjUiPjxyZWN0IHg9IjEyMCIgeT0iMTIwIiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjE0IiByeD0iNyIvPjxyZWN0IHg9IjEyMCIgeT0iMTUwIiB3aWR0aD0iMTEwIiBoZWlnaHQ9IjE0IiByeD0iNyIvPjxyZWN0IHg9IjM5MCIgeT0iMjAwIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE0IiByeD0iNyIvPjxyZWN0IHg9IjM5MCIgeT0iMjMwIiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjE0IiByeD0iNyIvPjwvZz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2MDAsNzApIiBvcGFjaXR5PSIwLjg1Ij48cmVjdCB3aWR0aD0iMTEwIiBoZWlnaHQ9IjEwMCIgcng9IjE0IiBmaWxsPSIjNTRkOGUyIi8+PHJlY3Qgd2lkdGg9IjExMCIgaGVpZ2h0PSIyNiIgcng9IjE0IiBmaWxsPSIjMGI4Mjk0Ii8+PGcgZmlsbD0iIzBlMjIzMCI+PHJlY3QgeD0iMTgiIHk9IjQ2IiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIzIi8+PHJlY3QgeD0iNDYiIHk9IjQ2IiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIzIi8+PHJlY3QgeD0iNzQiIHk9IjQ2IiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIzIi8+PHJlY3QgeD0iMTgiIHk9IjcyIiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIzIi8+PC9nPjwvZz48L3N2Zz4="); background-size: cover; background-position: center; } +.cz-bg-flyer { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MDAgNDAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBzbGljZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnMiIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzViMmE4NiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBlNmY3ZSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNDAwIiBmaWxsPSJ1cmwoI2cyKSIvPjxnIHRyYW5zZm9ybT0icm90YXRlKC04IDMwMCAyMDApIj48cmVjdCB4PSIxNTAiIHk9IjcwIiB3aWR0aD0iMjYwIiBoZWlnaHQ9IjI2MCIgcng9IjE2IiBmaWxsPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjkyIi8+PHJlY3QgeD0iMTgwIiB5PSIxMDAiIHdpZHRoPSIyMDAiIGhlaWdodD0iNzAiIHJ4PSI4IiBmaWxsPSIjNmQ0YmUwIiBvcGFjaXR5PSIwLjg1Ii8+PGcgZmlsbD0iIzlhOTNiOCI+PHJlY3QgeD0iMTgwIiB5PSIxOTAiIHdpZHRoPSIyMDAiIGhlaWdodD0iMTIiIHJ4PSI2Ii8+PHJlY3QgeD0iMTgwIiB5PSIyMTQiIHdpZHRoPSIxNjAiIGhlaWdodD0iMTIiIHJ4PSI2Ii8+PHJlY3QgeD0iMTgwIiB5PSIyMzgiIHdpZHRoPSIxODAiIGhlaWdodD0iMTIiIHJ4PSI2Ii8+PC9nPjxyZWN0IHg9IjE4MCIgeT0iMjc4IiB3aWR0aD0iMTIwIiBoZWlnaHQ9IjI2IiByeD0iMTMiIGZpbGw9IiMwYjgyOTQiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTIwLDE1MCkiPjxyZWN0IHdpZHRoPSIxNzAiIGhlaWdodD0iMTMwIiByeD0iMTgiIGZpbGw9IiMwZTIyMzAiIG9wYWNpdHk9IjAuOSIvPjxjaXJjbGUgY3g9Ijg1IiBjeT0iNzAiIHI9IjQyIiBmaWxsPSJub25lIiBzdHJva2U9IiM1NGQ4ZTIiIHN0cm9rZS13aWR0aD0iOCIvPjxjaXJjbGUgY3g9Ijg1IiBjeT0iNzAiIHI9IjE4IiBmaWxsPSIjNTRkOGUyIi8+PHJlY3QgeD0iMTIwIiB5PSIyMCIgd2lkdGg9IjM0IiBoZWlnaHQ9IjE0IiByeD0iNyIgZmlsbD0iIzU0ZDhlMiIvPjwvZz48L3N2Zz4="); background-size: cover; background-position: center; } +.cz-bg-cal { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MDAgNDAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBzbGljZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnMyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzJmMmE3OCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBlNjQ3MCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNDAwIiBmaWxsPSJ1cmwoI2czKSIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI1MCw3MCkiPjxyZWN0IHdpZHRoPSIzMDAiIGhlaWdodD0iMjYwIiByeD0iMTgiIGZpbGw9IiNmZmZmZmYiIG9wYWNpdHk9IjAuOTUiLz48cmVjdCB3aWR0aD0iMzAwIiBoZWlnaHQ9IjU0IiByeD0iMTgiIGZpbGw9IiM2ZDRiZTAiLz48cmVjdCB4PSIyMCIgeT0iODAiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSI2NiIgeT0iODAiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSIxMTIiIHk9IjgwIiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iMTU4IiB5PSI4MCIgd2lkdGg9IjM0IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iI2U3ZTNmNSIvPjxyZWN0IHg9IjIwNCIgeT0iODAiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSIyNTAiIHk9IjgwIiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iMjAiIHk9IjEyNiIgd2lkdGg9IjM0IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iI2U3ZTNmNSIvPjxyZWN0IHg9IjY2IiB5PSIxMjYiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSIxMTIiIHk9IjEyNiIgd2lkdGg9IjM0IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iI2U3ZTNmNSIvPjxyZWN0IHg9IjE1OCIgeT0iMTI2IiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iMjA0IiB5PSIxMjYiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSIyNTAiIHk9IjEyNiIgd2lkdGg9IjM0IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iI2U3ZTNmNSIvPjxyZWN0IHg9IjIwIiB5PSIxNzIiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSI2NiIgeT0iMTcyIiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iMTEyIiB5PSIxNzIiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSIxNTgiIHk9IjE3MiIgd2lkdGg9IjM0IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iI2U3ZTNmNSIvPjxyZWN0IHg9IjIwNCIgeT0iMTcyIiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iMjUwIiB5PSIxNzIiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSIyMCIgeT0iMjE4IiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iNjYiIHk9IjIxOCIgd2lkdGg9IjM0IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iI2U3ZTNmNSIvPjxyZWN0IHg9IjExMiIgeT0iMjE4IiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iMTU4IiB5PSIyMTgiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiNlN2UzZjUiLz48cmVjdCB4PSIyMDQiIHk9IjIxOCIgd2lkdGg9IjM0IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iI2U3ZTNmNSIvPjxyZWN0IHg9IjI1MCIgeT0iMjE4IiB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI2IiBmaWxsPSIjZTdlM2Y1Ii8+PHJlY3QgeD0iMTU4IiB5PSIxNzIiIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgcng9IjYiIGZpbGw9IiMwYjgyOTQiLz48Y2lyY2xlIGN4PSIxNzUiIGN5PSIxODkiIHI9IjgiIGZpbGw9IiNmZmZmZmYiLz48L2c+PC9zdmc+"); background-size: cover; background-position: center; } +.cz-bg-reply { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MDAgNDAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBzbGljZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnNCIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzRhMmE4MCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBlNWY2ZSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNDAwIiBmaWxsPSJ1cmwoI2c0KSIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE1MCwxMjApIj48cmVjdCB3aWR0aD0iMzIwIiBoZWlnaHQ9IjE1MCIgcng9IjI2IiBmaWxsPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjk1Ii8+PHBhdGggZD0iTTYwIDE1MCBsMCA1MCBsNTAgLTUwIHoiIGZpbGw9IiNmZmZmZmYiIG9wYWNpdHk9IjAuOTUiLz48ZyBmaWxsPSIjOWE5M2I4Ij48cmVjdCB4PSI0MCIgeT0iNDQiIHdpZHRoPSIyNDAiIGhlaWdodD0iMTQiIHJ4PSI3Ii8+PHJlY3QgeD0iNDAiIHk9Ijc2IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjE0IiByeD0iNyIvPjwvZz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTQwLDE1MCkiPjxjaXJjbGUgY3g9IjYwIiBjeT0iNjAiIHI9IjYwIiBmaWxsPSIjMTViMDcwIi8+PHBhdGggZD0iTTMyIDYyIGwyMCAyMCBsNDAgLTQ0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMTIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvZz48L3N2Zz4="); background-size: cover; background-position: center; } +.cz-bg-carpool { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MDAgNDAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBzbGljZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnYyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzNhMmE3MiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBlNjQ3MCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNDAwIiBmaWxsPSJ1cmwoI2djKSIvPjxyZWN0IHg9IjAiIHk9IjMwMCIgd2lkdGg9IjgwMCIgaGVpZ2h0PSIxMCIgZmlsbD0iI2ZmZmZmZiIgb3BhY2l0eT0iMC4zIi8+PGcgZmlsbD0iI2ZmZmZmZiIgb3BhY2l0eT0iMC41Ij48cmVjdCB4PSI2MCIgeT0iMzAxIiB3aWR0aD0iNjAiIGhlaWdodD0iNiIvPjxyZWN0IHg9IjE4MCIgeT0iMzAxIiB3aWR0aD0iNjAiIGhlaWdodD0iNiIvPjxyZWN0IHg9IjMwMCIgeT0iMzAxIiB3aWR0aD0iNjAiIGhlaWdodD0iNiIvPjxyZWN0IHg9IjQyMCIgeT0iMzAxIiB3aWR0aD0iNjAiIGhlaWdodD0iNiIvPjwvZz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjAsMTUwKSI+PHJlY3QgeD0iMCIgeT0iNzAiIHdpZHRoPSIyNjAiIGhlaWdodD0iNzIiIHJ4PSIyNCIgZmlsbD0iIzU0ZDhlMiIvPjxwYXRoIGQ9Ik00NCA3MiBxMjggLTU2IDkyIC01NiBsNjQgMCBxNDQgMCA2NCA1NiB6IiBmaWxsPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjkyIi8+PHJlY3QgeD0iNzAiIHk9IjMwIiB3aWR0aD0iNjAiIGhlaWdodD0iNDAiIHJ4PSI2IiBmaWxsPSIjNmQ0YmUwIiBvcGFjaXR5PSIwLjg1Ii8+PHJlY3QgeD0iMTUwIiB5PSIzMCIgd2lkdGg9IjYwIiBoZWlnaHQ9IjQwIiByeD0iNiIgZmlsbD0iIzZkNGJlMCIgb3BhY2l0eT0iMC44NSIvPjxjaXJjbGUgY3g9Ijc0IiBjeT0iMTQ4IiByPSIyOCIgZmlsbD0iIzBlMjIzMCIvPjxjaXJjbGUgY3g9Ijc0IiBjeT0iMTQ4IiByPSIxMyIgZmlsbD0iIzU0ZDhlMiIvPjxjaXJjbGUgY3g9IjIwNiIgY3k9IjE0OCIgcj0iMjgiIGZpbGw9IiMwZTIyMzAiLz48Y2lyY2xlIGN4PSIyMDYiIGN5PSIxNDgiIHI9IjEzIiBmaWxsPSIjNTRkOGUyIi8+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2MCw5MCkiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzU0ZDhlMiIgc3Ryb2tlLXdpZHRoPSIxMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48Y2lyY2xlIGN4PSI2MCIgY3k9IjYwIiByPSI0MCIgc3Ryb2tlPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjg1Ii8+PHBhdGggZD0iTTYwIDM2IGwwIDI0IGwxNiAxMCIgc3Ryb2tlPSIjNTRkOGUyIi8+PC9nPjwvc3ZnPg=="); background-size: cover; background-position: center; } +.cz-bg-appt { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MDAgNDAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBzbGljZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnYSIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzJmMmE3OCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBlNjQ3MCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNDAwIiBmaWxsPSJ1cmwoI2dhKSIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDQ0MCw5MCkiPjxyZWN0IHdpZHRoPSIyNjAiIGhlaWdodD0iMjIwIiByeD0iMTYiIGZpbGw9IiNmZmZmZmYiIG9wYWNpdHk9IjAuOTUiLz48cmVjdCB3aWR0aD0iMjYwIiBoZWlnaHQ9IjUwIiByeD0iMTYiIGZpbGw9IiM2ZDRiZTAiLz48ZyBmaWxsPSIjZTdlM2Y1Ij48cmVjdCB4PSIyMiIgeT0iNzgiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgcng9IjYiLz48cmVjdCB4PSI3OCIgeT0iNzgiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgcng9IjYiLz48cmVjdCB4PSIxMzQiIHk9Ijc4IiB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHJ4PSI2Ii8+PHJlY3QgeD0iMTkwIiB5PSI3OCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiByeD0iNiIvPjxyZWN0IHg9IjIyIiB5PSIxMzAiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgcng9IjYiLz48cmVjdCB4PSI3OCIgeT0iMTMwIiB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHJ4PSI2Ii8+PHJlY3QgeD0iMTkwIiB5PSIxMzAiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgcng9IjYiLz48L2c+PHJlY3QgeD0iMTM0IiB5PSIxMzAiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgcng9IjYiIGZpbGw9IiMwYjgyOTQiLz48Y2lyY2xlIGN4PSIxNTQiIGN5PSIxNTAiIHI9IjkiIGZpbGw9IiNmZmZmZmYiLz48L2c+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTIwLDExMCkiPjxjaXJjbGUgY3g9Ijk1IiBjeT0iOTUiIHI9Ijk1IiBmaWxsPSIjMGUyMjMwIiBvcGFjaXR5PSIwLjkyIi8+PGNpcmNsZSBjeD0iOTUiIGN5PSI5NSIgcj0iNzgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzU0ZDhlMiIgc3Ryb2tlLXdpZHRoPSI5Ii8+PHBhdGggZD0iTTk1IDk1IEw5NSA0NiIgc3Ryb2tlPSIjNTRkOGUyIiBzdHJva2Utd2lkdGg9IjExIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48cGF0aCBkPSJNOTUgOTUgTDEzNiAxMTIiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSI4IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L2c+PC9zdmc+"); background-size: cover; background-position: center; } +.cz-bg-party { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MDAgNDAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCBzbGljZSI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncCIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzViMmE4NiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzBlNmY3ZSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNDAwIiBmaWxsPSJ1cmwoI2dwKSIvPjxnIG9wYWNpdHk9IjAuNzUiPjxyZWN0IHg9IjEyMCIgeT0iNTAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgcng9IjMiIGZpbGw9IiM1NGQ4ZTIiIHRyYW5zZm9ybT0icm90YXRlKDIwIDEyOCA1OCkiLz48cmVjdCB4PSIzMDAiIHk9IjQwIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHJ4PSIzIiBmaWxsPSIjZmZmZmZmIiB0cmFuc2Zvcm09InJvdGF0ZSgtMTUgMzA3IDQ3KSIvPjxyZWN0IHg9IjY0MCIgeT0iNjAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgcng9IjMiIGZpbGw9IiM1NGQ4ZTIiIHRyYW5zZm9ybT0icm90YXRlKDMwIDY0OCA2OCkiLz48Y2lyY2xlIGN4PSI1MjAiIGN5PSI1MCIgcj0iOCIgZmlsbD0iI2ZmZmZmZiIvPjxjaXJjbGUgY3g9IjIyMCIgY3k9IjM0MCIgcj0iOCIgZmlsbD0iIzU0ZDhlMiIvPjxyZWN0IHg9IjcwMCIgeT0iMzIwIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHJ4PSIzIiBmaWxsPSIjZmZmZmZmIiB0cmFuc2Zvcm09InJvdGF0ZSgyNSA3MDcgMzI3KSIvPjwvZz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxNTAsODApIj48ZWxsaXBzZSBjeD0iNzAiIGN5PSI5MCIgcng9IjY0IiByeT0iNzgiIGZpbGw9IiM1NGQ4ZTIiLz48cGF0aCBkPSJNNzAgMTY4IGwtOSAyMCBsMTggMCB6IiBmaWxsPSIjNTRkOGUyIi8+PHBhdGggZD0iTTcwIDE4OCBxMjQgMzQgLTEyIDY2IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iNCIgZmlsbD0ibm9uZSIgb3BhY2l0eT0iMC43Ii8+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDQ3MCwxNzApIj48cmVjdCB3aWR0aD0iMTkwIiBoZWlnaHQ9IjE1MCIgcng9IjE0IiBmaWxsPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjk1Ii8+PHJlY3QgeD0iODIiIHdpZHRoPSIyNiIgaGVpZ2h0PSIxNTAiIGZpbGw9IiM2ZDRiZTAiLz48cmVjdCB5PSI1OCIgd2lkdGg9IjE5MCIgaGVpZ2h0PSIyNiIgZmlsbD0iIzZkNGJlMCIvPjxwYXRoIGQ9Ik05NSA1OCBxLTQ2IC01NCAtMTQgLTU0IHEzNCAwIDE0IDU0IHEyMCAtNTQgNTQgLTU0IHEzNCAwIC0xNCA1NCB6IiBmaWxsPSIjMGI4Mjk0Ii8+PC9nPjwvc3ZnPg=="); background-size: cover; background-position: center; } + +/* ===================================================================== */ +/* Landing-page remaster — bold dark hero + light body */ +/* ===================================================================== */ +html { overflow-x: hidden; scroll-behavior: smooth; } +.gradio-container { scroll-behavior: smooth; } + +/* ---- sticky top nav (full-bleed) ---- */ +#site-nav { width: 100vw; margin-left: calc(50% - 50vw); position: sticky; top: 0; z-index: 60; + background: rgba(255,255,255,.82); backdrop-filter: blur(12px) saturate(1.2); + border-bottom: 1px solid var(--line); } +#site-nav .nav-inner { max-width: 1100px; margin: 0 auto; padding: 12px 22px; + display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.nav-brand { display: inline-flex; align-items: center; gap: 9px; + font-family: "Fraunces", serif; font-weight: 700; font-size: 1.16rem; text-decoration: none; } +/* plain ink in the site display typeface — the gradient text-clip trick left + the title invisible whenever the clip didn't apply */ +.nav-brand span { color: var(--text) !important; -webkit-text-fill-color: currentColor; } +.nav-logo { width: 30px; height: 30px; object-fit: contain; flex: none; } +/* calendar-option notes inside the step-2 dropdown */ +.cal-note { color: var(--muted); font-size: .86rem; margin-bottom: 6px; } +.cal-note code { background: var(--surface2); padding: 1px 5px; border-radius: 5px; } +.nav-links { display: flex; align-items: center; gap: 18px; } +/* grouped dropdowns */ +.nav-group { position: relative; } +.nav-top { background: none; border: 0; cursor: pointer; color: var(--muted); font-weight: 600; + font-size: .92rem; padding: 6px 4px; display: inline-flex; align-items: center; gap: 6px; + font-family: inherit; transition: color .15s; } +.nav-group:hover .nav-top, .nav-group:focus-within .nav-top, .nav-top:hover { color: var(--text); } +.nav-caret { font-size: .7rem; opacity: .7; transition: transform .15s; } +.nav-group:hover .nav-caret, .nav-group:focus-within .nav-caret { transform: rotate(180deg); } +.nav-menu { position: absolute; top: calc(100% + 8px); left: 0; min-width: 190px; + background: #fff; border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); + padding: 8px; display: none; flex-direction: column; gap: 2px; z-index: 70; } +.nav-group:hover .nav-menu, .nav-group:focus-within .nav-menu { display: flex; } +.nav-item { display: block; padding: 8px 12px; border-radius: 8px; color: var(--text) !important; + text-decoration: none; font-weight: 600; font-size: .92rem; cursor: pointer; white-space: nowrap; } +.nav-item:hover { background: var(--surface2); } +.nav-cta { background: var(--accent); color: #fff !important; padding: 8px 18px; border-radius: 999px; + text-decoration: none; font-weight: 700; font-size: .92rem; box-shadow: 0 6px 18px rgba(109,75,224,.35); + transition: filter .15s; } +.nav-cta:hover { filter: brightness(1.06); } + +/* Hide the default Gradio tab strip — the banner is the only navigation now. + IMPORTANT: don't use display:none / width:0 — Gradio 6's tab bar is responsive + (measures its width and overflows tabs that don't fit into a "more" menu as a + non-interactive clone the nav JS can't click). Collapsing it while keeping its + NATURAL width broke on mobile: a ~390px phone viewport can't fit 7 tabs, so the + later ones (Memory/Feed/Submission, index >= 3) overflowed into that dead clone + and their banner links did nothing — while desktop's ~1100px fit them all. + Fix: park the strip far off-screen at a FIXED LARGE width so the responsive + measurement always sees room for every tab on one row, no overflow menu is + ever built, and all tab buttons stay real and programmatically clickable. */ +.gradio-container .tab-wrapper, +.gradio-container .tab-nav { + height: 0 !important; min-height: 0 !important; overflow: hidden !important; + opacity: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; + pointer-events: none; + /* KEEP the strip from ever overflowing tabs into the dead "more" clone: + force its measured width far past the tab count and forbid wrapping, so on + a phone all 7 tabs still "fit" and stay real, clickable buttons. height:0 + + overflow:hidden keeps it invisible; html{overflow-x:hidden} clips the + oversized width so it can't add a horizontal scrollbar. */ + min-width: 1600px !important; max-width: none !important; flex-wrap: nowrap !important; +} + +/* ---- hero (full-bleed DARK band, split: copy + example-card grid) ---- */ +#hero { width: 100vw; margin-left: calc(50% - 50vw); + padding: 64px max(22px, calc(50vw - 560px)) 104px; /* extra bottom so the tool card overlaps */ + display: flex !important; flex-wrap: nowrap; align-items: center; gap: 40px !important; + background: + radial-gradient(900px 520px at 82% -12%, rgba(109,75,224,.42) 0%, transparent 60%), + radial-gradient(760px 520px at -5% 115%, rgba(11,130,148,.34) 0%, transparent 55%), + linear-gradient(160deg, #0e0b1c 0%, #1a1230 58%, #0e1622 100%) !important; } +/* inner Gradio wrappers transparent so the dark band shows (NOT #hero itself) */ +#hero-left, #hero-right, +#hero .block, #hero .form, #hero .gr-group, #hero .gradio-html, #hero > * { + background: transparent !important; border: 0 !important; box-shadow: none !important; } +#hero-left { flex: 1 1 520px; min-width: 0; } +#hero-right { flex: 0 0 380px; max-width: 400px; } +.hero-copy { max-width: 620px; animation: rise .5s ease both; } +.hero-eyebrow { color: #bff2f8 !important; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; + font-size: .8rem; margin-bottom: 14px; } +.hero-title { font-family: "Fraunces", serif; color: #fff !important; font-size: clamp(2.2rem, 5vw, 4rem); + line-height: 1.03; letter-spacing: -.02em; margin: 0 0 18px; } +.hero-accent { background: linear-gradient(100deg, #b39bff, #5fd5e6); + -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } +.hero-sub { color: #ded8f0 !important; font-size: clamp(1rem, 1.5vw, 1.18rem); line-height: 1.55; max-width: 560px; margin: 0 0 12px; } +.hero-trust { color: #b3add0 !important; font-size: .9rem; margin: 0; } +#hero-cta { gap: 14px !important; align-items: center; margin-top: 20px; flex-wrap: wrap; } +#hero-cta #hero-try { flex: 0 0 auto; } +#hero-try button { background: var(--accent) !important; color: #fff !important; border: none !important; + font-weight: 700 !important; border-radius: 999px !important; padding: 13px 28px !important; min-height: 0 !important; + box-shadow: 0 12px 32px rgba(109,75,224,.46) !important; transition: transform .15s, filter .15s !important; } +#hero-try button:hover { transform: translateY(-2px); filter: brightness(1.07); } +.hero-ghost { color: #fff; text-decoration: none; font-weight: 600; border: 1px solid rgba(255,255,255,.42); + padding: 12px 22px; border-radius: 999px; transition: background .15s; white-space: nowrap; } +.hero-ghost:hover { background: rgba(255,255,255,.12); } + +/* hero example-card grid (chat -> event), echoes the reference's message cards */ +.hx-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.hx-card { background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12); + border-radius: 12px; padding: 12px; box-shadow: 0 10px 24px rgba(0,0,0,.25); animation: rise .5s ease both; } +.hx-from { color: #8f88b5; font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; margin-bottom: 5px; } +.hx-chat { color: #ece9f8; font-size: .84rem; line-height: 1.35; margin-bottom: 8px; } +.hx-event { background: #fff; color: #1e1934; border-radius: 8px; padding: 6px 9px; font-size: .78rem; + font-weight: 700; border-left: 3px solid #6d4be0; } +.hx-event + .hx-event { margin-top: 6px; } /* rule 5: deadline + event entries */ +.hx-assumed { color: #8f88b5; font-size: .85em; font-weight: 600; } /* rule 2: inference flag */ + +/* ---- light marketing sections ---- */ +.lp-section { max-width: 1080px; margin: 0 auto; padding: 78px 22px 6px; text-align: center; scroll-margin-top: 80px; } +.lp-eyebrow { color: var(--cyan); font-weight: 700; letter-spacing: .14em; text-transform: uppercase; font-size: .8rem; margin-bottom: 10px; } +.lp-title { font-family: "Fraunces", serif; color: var(--text); font-size: clamp(1.7rem, 3.4vw, 2.6rem); + line-height: 1.1; letter-spacing: -.01em; margin: 0 auto 34px; max-width: 780px; } +.lp-grid { display: grid; gap: 18px; text-align: left; } +.lp-grid-3 { grid-template-columns: repeat(3, 1fr); } +.lp-card, .lp-step, .lp-priv { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; + padding: 24px; box-shadow: 0 6px 20px rgba(45,32,90,.07); transition: transform .18s, box-shadow .18s; } +.lp-card:hover, .lp-step:hover, .lp-priv:hover { transform: translateY(-4px); box-shadow: 0 16px 34px rgba(45,32,90,.16); } +.lp-ico { font-size: 1.9rem; margin-bottom: 10px; line-height: 1; } +.lp-step-n { font-family: "Fraunces", serif; font-size: 1.7rem; font-weight: 700; margin-bottom: 8px; + background: var(--accent); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } +.lp-card-t { font-size: 1.12rem; font-weight: 700; color: var(--text); margin: 0 0 8px; } +.lp-card-d { color: var(--muted); font-size: .96rem; line-height: 1.55; margin: 0; } +.lp-card-d code { background: var(--surface2); padding: 1px 6px; border-radius: 6px; font-size: .86em; } +.lp-tool-head { padding-bottom: 2px; } + +/* tool anchor offset for the sticky nav */ +.tool-anchor { scroll-margin-top: 84px; } +#rv-results { scroll-margin-top: 84px; } + +/* ---- footer (full-bleed dark band) ---- */ +#site-footer { width: 100vw; margin-left: calc(50% - 50vw); margin-top: 66px; + background: linear-gradient(160deg, #0e0b1c 0%, #181030 100%); color: #fff; } +.footer-inner { max-width: 1080px; margin: 0 auto; padding: 58px 22px; text-align: center; } +/* !important: the global .prose h2 readability rule (top of file) would + otherwise repaint this ink-dark on the dark band. */ +.footer-cta-t, #site-footer .footer-cta-t { font-family: "Fraunces", serif; color: #fff !important; + font-size: clamp(1.6rem, 3vw, 2.4rem); margin: 0 0 18px; } +a.footer-cta { display: inline-block; background: var(--accent); color: #fff; text-decoration: none; font-weight: 700; + padding: 13px 28px; border-radius: 999px; box-shadow: 0 12px 32px rgba(109,75,224,.42); transition: transform .15s, filter .15s; } +a.footer-cta:hover { transform: translateY(-2px); filter: brightness(1.07); } +.footer-meta { color: #9b93c2; font-size: .86rem; margin-top: 18px; } +.footer-meta a { color: #bff2f8; text-decoration: none; } + +/* Gradio's own footer (Use via API or MCP · Built with Gradio · Settings), + relocated into the banner by wireFooter() in the injected JS — bare white + hyperlinked text only: no pills, boxes, borders, or backgrounds. */ +#site-footer footer, #site-footer footer * { background: transparent !important; + border: 0 !important; box-shadow: none !important; border-radius: 0 !important; } +#site-footer footer { justify-content: center; margin-top: 10px; padding: 0 !important; } +#site-footer footer, #site-footer footer a, #site-footer footer button, +#site-footer footer span { color: #fff !important; font-size: .86rem; } +#site-footer footer a, #site-footer footer button { cursor: pointer; + text-decoration: none; padding: 0 !important; margin: 0 4px; min-width: 0 !important; } +#site-footer footer a:hover, #site-footer footer button:hover { + color: #fff !important; text-decoration: underline; } + +/* ---- mobile ---- */ +@media (max-width: 760px) { + #site-nav .nav-inner { gap: 10px; padding: 10px 16px; } + .nav-links { gap: 10px; } + .nav-brand { font-size: 1rem; } + .nav-top { font-size: .86rem; padding: 6px 2px; } + .nav-menu { right: 0; left: auto; } /* keep menus on-screen near the edge */ + .lp-grid-3 { grid-template-columns: 1fr; } + #hero { padding: 46px 20px 40px; flex-direction: column; align-items: stretch; gap: 26px !important; } + #hero-left, #hero-right { flex: 1 1 auto; max-width: 100%; } + #hero-cta { justify-content: flex-start; } + .lp-section { padding-top: 56px; } +} + +/* ===================================================================== */ +/* FAQ — left-aligned title + tabs + search + row-divider accordion */ +/* ===================================================================== */ +.lp-faq-section { max-width: 1080px; margin: 0 auto; padding: 78px 22px 12px; + text-align: left; scroll-margin-top: 80px; } +.lp-faq-head { display: flex; align-items: flex-end; justify-content: space-between; + gap: 24px; margin-bottom: 22px; flex-wrap: wrap; } +.lp-faq-h { font-family: "Fraunces", serif; color: var(--text); + font-size: clamp(1.7rem, 3.4vw, 2.4rem); line-height: 1.1; letter-spacing: -.01em; + margin: 0; font-weight: 700; } + +/* Search input — bottom-border-only with svg icon on the right */ +.lp-faq-search { position: relative; display: flex; align-items: center; + min-width: 260px; border-bottom: 1px solid var(--line); padding: 6px 0; + transition: border-color .15s; } +.lp-faq-search:focus-within { border-color: var(--violet); } +.lp-faq-search input { flex: 1; border: 0; background: transparent; color: var(--text); + font-size: .98rem; font-family: inherit; outline: none; padding: 4px 28px 4px 0; + -webkit-appearance: none; appearance: none; } +.lp-faq-search input::placeholder { color: var(--muted); } +.lp-faq-search input::-webkit-search-cancel-button { -webkit-appearance: none; } +.lp-faq-search svg { width: 18px; height: 18px; color: var(--muted); + position: absolute; right: 2px; pointer-events: none; } + +/* Tabs */ +.lp-faq-tabs { display: flex; gap: 32px; border-bottom: 1px solid var(--line); + margin: 0 0 4px; } +.lp-faq-tab { background: transparent; border: 0; padding: 12px 0 14px; + cursor: pointer; font-family: inherit; font-size: 1rem; color: var(--muted); + font-weight: 600; position: relative; transition: color .15s; } +.lp-faq-tab:hover { color: var(--text); } +.lp-faq-tab.is-active { color: var(--text); } +.lp-faq-tab.is-active::after { content: ""; position: absolute; left: 0; right: 0; + bottom: -1px; height: 2.5px; background: var(--violet); border-radius: 2px; } + +/* Lists + rows */ +.lp-faq-list { display: block; } +.lp-faq-list.is-hidden { display: none; } +.lp-faq-item { border-bottom: 1px solid var(--line); padding: 0; } +.lp-faq-q { display: flex; justify-content: space-between; align-items: center; + gap: 16px; padding: 22px 0; cursor: pointer; list-style: none; + font-size: 1.05rem; color: var(--text); font-weight: 500; transition: color .15s; } +.lp-faq-q::-webkit-details-marker { display: none; } +.lp-faq-q:hover { color: var(--violet); } +.lp-faq-qt { flex: 1; min-width: 0; } +.lp-faq-ico { flex: 0 0 26px; display: inline-flex; color: var(--muted); + transition: color .15s; } +.lp-faq-ico svg { width: 26px; height: 26px; } +.lp-faq-q:hover .lp-faq-ico, +.lp-faq-item[open] .lp-faq-ico { color: var(--violet); } +.lp-faq-ico-v { transition: opacity .15s; } +.lp-faq-item[open] .lp-faq-ico-v { opacity: 0; } + +.lp-faq-a { padding: 0 0 22px; color: var(--muted); line-height: 1.6; + font-size: .97rem; max-width: 760px; } +.lp-faq-a p { margin: 0 0 10px; } +.lp-faq-a p:last-child { margin-bottom: 0; } +.lp-faq-a ul { margin: 6px 0 0; padding-left: 20px; } +.lp-faq-a li { margin-bottom: 4px; } +.lp-faq-a code { background: rgba(31,25,60,.06); padding: 1px 6px; border-radius: 5px; + font-size: .9em; } +.lp-faq-a a { color: var(--violet); text-decoration: none; font-weight: 600; } +.lp-faq-a a:hover { text-decoration: underline; } +.lp-faq-a b { color: var(--text); } + +/* Empty-state message when search filters everything out */ +.lp-faq-empty { color: var(--muted); padding: 28px 0; text-align: center; } +.lp-faq-empty.is-hidden { display: none; } + +@media (max-width: 720px) { + .lp-faq-section { padding-top: 56px; } + .lp-faq-head { align-items: flex-start; } + .lp-faq-search { min-width: 100%; } + .lp-faq-tabs { gap: 22px; } +} + +/* ===================================================================== */ +/* Hackathon: Submission compliance scorecard */ +/* ===================================================================== */ + +/* standalone nav link (between the dropdown groups and the CTA) */ +/* Home / Submission share the SAME typography as the Learn/Workspace tops */ +.nav-solo { background: none; border: 0; cursor: pointer; color: var(--muted) !important; font-weight: 600; + font-size: .92rem; padding: 6px 4px; display: inline-flex; align-items: center; gap: 6px; + font-family: inherit; text-decoration: none; transition: color .15s; } +.nav-solo:hover { color: var(--text) !important; } + +/* submission scorecard */ +.sub-wrap { max-width: 920px; margin: 0 auto; padding: 10px 4px 28px; } +.sub-group { margin: 20px 0; } +.sub-h { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 0 10px; } +.sub-lead { color: var(--text); line-height: 1.6; background: var(--surface); border: 1px solid var(--line); + border-left: 3px solid var(--violet); border-radius: 12px; padding: 14px 16px; box-shadow: 0 3px 12px rgba(45,32,90,.06); } +.sub-lead code { background: var(--surface2); padding: 1px 6px; border-radius: 6px; } +.sub-row { display: flex; gap: 12px; align-items: flex-start; background: var(--surface); border: 1px solid var(--line); + border-radius: 12px; padding: 12px 14px; margin-bottom: 8px; box-shadow: 0 3px 12px rgba(45,32,90,.06); } +.sub-pill { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; + justify-content: center; font-weight: 800; font-size: .9rem; } +.sub-ok { background: rgba(21,137,79,.14); color: var(--mint); } +.sub-warn { background: rgba(179,112,10,.16); color: var(--amber); } +.sub-rt { min-width: 0; } +.sub-title { font-weight: 700; color: var(--text); } +.sub-ev { color: var(--muted); font-size: .92rem; margin-top: 2px; line-height: 1.5; } +.sub-ev a { color: var(--cyan); } +.sub-ev code, .sub-title code { background: var(--surface2); padding: 1px 6px; border-radius: 6px; } + +/* ===================================================================== */ +/* Reference-style redesign: nav pill · elevated tool card · 2-col input */ +/* ===================================================================== */ + +/* fine-tuned-model pill (top-right of the nav) — links to the model */ +.nav-status { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; + background: rgba(21,137,79,.12); color: var(--mint); font-size: .78rem; font-weight: 700; + white-space: nowrap; text-decoration: none; transition: filter .15s; } +.nav-status:hover { filter: brightness(1.05); } +.nav-status b { font-weight: 800; } + +/* elevated tool card that overlaps the hero (the agent, up top) */ +#tool-card { max-width: 1000px; margin: -84px auto 0 !important; position: relative; z-index: 5; + background: #fff !important; border: 1px solid var(--line) !important; border-radius: 20px !important; + box-shadow: 0 30px 70px rgba(20,12,50,.28) !important; padding: 26px !important; scroll-margin-top: 80px; } +.tc-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; } +.tc-eyebrow { color: var(--cyan); font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-size: .76rem; margin-bottom: 6px; } +.tc-title { font-family: "Fraunces", serif; font-size: 1.7rem; color: var(--text); margin: 0; line-height: 1.1; } +/* "Powered by fine-tuned Gemma 4" — green pill, right side of the tool-card head */ +.tc-poweredby { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; + border-radius: 999px; background: rgba(21,137,79,.10); border: 1px solid rgba(21,137,79,.35); + color: var(--mint); font-size: .82rem; font-weight: 600; text-decoration: none; + transition: background .15s; } +.tc-poweredby:hover { background: rgba(21,137,79,.18); } +.tc-poweredby b { color: var(--mint); font-weight: 800; } + +/* ---- mode theme: the ONE decision point recolors the whole workflow card. + Offline = forest green ("local, sealed"); Online = cyan ("cloud-connected"). + data-mode is set by wireModeTheme() on load + the mode.change JS. ---- */ +#tool-card { --mode-c: #15894f; --mode-soft: rgba(21,137,79,.07); + --mode-line: rgba(21,137,79,.35); } +#tool-card[data-mode="online"] { --mode-c: #0e8ea0; --mode-soft: rgba(14,142,160,.09); + --mode-line: rgba(14,142,160,.4); } +#tool-card { border-top: 4px solid var(--mode-c) !important; + background: linear-gradient(var(--mode-soft), transparent 170px), #fff !important; + transition: border-color .35s ease, background .35s ease; } + +/* ---- Offline / Online mode toggle, inside the full-width mode band ---- */ +#mode-band { background: var(--mode-soft) !important; border: 1px solid var(--mode-line) !important; + border-radius: 14px !important; padding: 12px 16px 10px !important; margin-bottom: 6px; + transition: background .35s ease, border-color .35s ease; } +/* ONE box only: flatten every Gradio wrapper inside the band (the radio's and + the note's own block chrome would otherwise draw nested containers). + :not(label) — Gradio puts data-testid on the radio OPTION labels too, and + this rule must not strip their button/pill styling below. */ +#mode-band .block, #mode-band .form, #mode-band fieldset, #mode-band .gradio-html, +#mode-band > div, #mode-band [data-testid]:not(label) { + background: transparent !important; border: 0 !important; box-shadow: none !important; + padding: 0 !important; margin: 0 !important; border-radius: 0 !important; } +/* ONE enclosing pill around both options — the eye lands here; the pill's + border wears the active mode color. !important throughout: the band's + flatten rule zeroes block chrome and must not strip the pill itself. */ +#mode-toggle { display: flex !important; justify-content: center; gap: 0; + width: fit-content; margin: 2px auto 8px !important; + background: #fff !important; border: 2px solid var(--mode-c) !important; + border-radius: 999px !important; padding: 4px !important; + box-shadow: 0 4px 14px rgba(20,12,50,.10) !important; + transition: border-color .35s ease; } +#mode-toggle .wrap, #mode-toggle > div { justify-content: center; gap: 6px; } +/* each option is its OWN button: outlined + raised when idle (clearly + clickable), filled with the mode color when selected. Double-id selector + out-ranks the band's flatten rule. */ +#mode-band #mode-toggle label { + background: #fff !important; border: 1.5px solid var(--line) !important; + border-radius: 999px !important; padding: 8px 20px !important; cursor: pointer; + font-weight: 700 !important; color: var(--text) !important; + box-shadow: 0 1px 3px rgba(20,12,50,.14) !important; + margin: 0 2px !important; transition: background .25s, color .25s, border-color .25s, + transform .1s; } +#mode-band #mode-toggle label:hover { border-color: var(--mode-c) !important; } +#mode-band #mode-toggle label:active { transform: translateY(1px); } +#mode-band #mode-toggle label:has(input:checked) { + background: var(--mode-c) !important; border-color: var(--mode-c) !important; + box-shadow: inset 0 1px 2px rgba(0,0,0,.15) !important; } +#mode-band #mode-toggle label:has(input:checked) span { color: #fff !important; } +#mode-toggle input[type="radio"] { display: none !important; } +.mode-note { display: flex; align-items: center; justify-content: center; gap: 10px; + flex-wrap: wrap; text-align: center; color: var(--muted); font-size: .85rem; margin: 0 0 2px; } +.mode-note code { background: var(--surface2); padding: 1px 5px; border-radius: 5px; } +.mode-chip { display: inline-block; padding: 3px 10px; border-radius: 999px; + background: var(--mode-c); color: #fff; font-size: .68rem; font-weight: 800; + letter-spacing: .08em; transition: background .35s ease; } + +/* ---- numbered workflow steps, tied by a dashed tail under each chip ---- + chips + connectors wear the active mode color (green offline / cyan online) */ +.flow-step { position: relative; display: flex; align-items: center; gap: 8px; + margin: 20px 0 8px; } +.flow-step::before { content: ""; position: absolute; left: 10px; top: 100%; + height: 20px; border-left: 2px dashed rgba(109,75,224,.45); } +#tool-card .flow-step::before { border-color: var(--mode-line); transition: border-color .35s ease; } +#tool-card .step-chip { background: var(--mode-c); transition: background .35s ease; } +.flow-t { font-weight: 700; color: var(--text); } +.flow-sub { color: var(--muted); font-size: .82rem; font-weight: 500; } +.flow-gcal { margin: -2px 0 10px 30px; } +.flow-gcal .gcal-state { color: var(--mint); font-size: .85rem; } + +/* two-column ① upload / ② paste */ +#io-cols { gap: 20px !important; align-items: stretch; } +.io-col { min-width: 0; } +.io-label { display: flex; align-items: center; gap: 8px; font-weight: 700; color: var(--text); font-size: .95rem; margin-bottom: 8px; } +.step-chip { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; + border-radius: 50%; background: var(--accent); color: #fff; font-size: .8rem; font-weight: 800; } +#io-drop { border: 2px dashed rgba(31,25,60,.22) !important; border-radius: 14px !important; + background: var(--surface2) !important; } + +/* char counter + helper line under the paste box */ +.rv-help { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 6px; + color: var(--muted); font-size: .8rem; } +.rv-counter { font-variant-numeric: tabular-nums; color: var(--muted); } + +/* centered primary / secondary actions + sample link */ +#rv-actions { justify-content: center !important; gap: 12px !important; margin-top: 16px; } +#rv-analyze button { min-width: 200px; } +.rv-secondary button { background: var(--surface2) !important; color: var(--text) !important; + border: 1px solid var(--line) !important; box-shadow: none !important; font-weight: 600 !important; } +.rv-linkbtn { display: flex; justify-content: center; margin-top: 8px; } +.rv-linkbtn button { background: none !important; border: none !important; box-shadow: none !important; + color: var(--cyan) !important; font-weight: 600 !important; min-height: 0 !important; } + +/* privacy-safe trace card */ +.trace-card { background: var(--surface2) !important; border: 1px solid var(--line) !important; + border-radius: 12px !important; padding: 12px 14px !important; margin-top: 14px; } +.trace-desc { color: var(--muted); font-size: .82rem; margin-top: 2px; } +.trace-ok { color: var(--mint); font-size: .85rem; font-weight: 600; margin-top: 6px; } + +/* screenshot-attached hint */ +.shot-status { color: var(--cyan); font-size: .82rem; font-weight: 600; margin-top: 6px; } + +/* mobile */ +@media (max-width: 760px) { + #io-cols { flex-direction: column; } + #tool-card { margin-top: -56px !important; padding: 18px !important; border-radius: 16px !important; } + .tc-head { align-items: flex-start; } + .hx-grid { grid-template-columns: 1fr; } + .nav-status { display: none; } +} + +/* hero trust badges (under the copy, on the dark band) */ +.hero-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; } +.hbadge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 999px; + background: rgba(255,255,255,.10); border: 1px solid rgba(255,255,255,.18); color: #fff; + font-size: .8rem; font-weight: 700; } + +/* onboarding panel (inside the tool card) — an accordion: open on first visit, + collapsed (but always reopenable) once the device has memory. */ +#onboard { background: var(--surface2) !important; border: 1px solid var(--line) !important; + border-radius: 14px !important; padding: 12px 18px !important; margin-bottom: 16px; } +#onboard .label-wrap span, #onboard > button span { + font-family: "Fraunces", serif !important; font-size: 1.1rem; font-weight: 700; + color: var(--text) !important; } +.ob-sub { color: var(--muted); font-size: .9rem; margin: 4px 0 12px; } + +/* per-user Google Calendar connect link (in the export bar) */ +.gcal-connect { display: inline-block; margin-top: 6px; color: var(--cyan); font-weight: 600; + text-decoration: none; cursor: pointer; font-size: .9rem; } +.gcal-connect:hover { text-decoration: underline; } +.gcal-state { color: var(--mint); font-weight: 700; font-size: .85rem; margin-left: 4px; } + +/* ---- condensed results card: events + export in ONE area ---- */ +#rv-resultcard { + background: var(--surface) !important; border: 1px solid var(--line) !important; + border-radius: 16px !important; padding: 16px 18px !important; + box-shadow: var(--shadow); margin-top: 12px; +} +/* the export cluster becomes a toolbar inside the card — drop its own panel + chrome, separate it from the events with a hairline */ +#rv-resultcard #rv-export { + margin-top: 12px; padding: 12px 0 0 !important; + background: transparent !important; border: none !important; + border-top: 1px solid var(--line) !important; border-radius: 0 !important; + box-shadow: none; +} +#rv-resultcard #rv-export button { min-height: 42px !important; } +/* keep the export toolbar reachable on a phone (re-assert the sticky bar at + the new, more specific selector) */ +@media (max-width: 640px) { + #rv-resultcard #rv-export { + position: sticky; bottom: 0; z-index: 20; padding: 10px !important; + background: rgba(255,255,255,.94) !important; + backdrop-filter: blur(8px); border-radius: 12px 12px 0 0 !important; + box-shadow: 0 -8px 24px rgba(45,32,90,.18); + } +} +/* prominent location line on the condensed event card */ +.evx-loc { font-size: .92rem; font-weight: 600; color: var(--text); margin-top: 8px; } +/* arrival-context callout (per-event notes, e.g. "arrive 15 min early") */ +.evx-notes { + margin-top: 8px; padding: 6px 10px; font-size: .86rem; color: var(--text); + background: rgba(240,180,60,.12); border-left: 3px solid var(--amber); + border-radius: 0 8px 8px 0; +} +/* the pre-generated .ics widget stays as the no-JS download fallback — slim it */ +#ics-file { margin-top: 8px; } +#ics-file, #ics-file .file-preview { max-height: 88px; overflow-y: auto; } + +/* ---- unified "Connect your calendar" block (Step 2a) ---- */ +.cal-connect { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 10px; + padding: 10px 14px; background: var(--surface2); border-radius: 10px; } +.cal-provider { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; + font-size: .9rem; } +.cal-prov-name { font-weight: 600; min-width: 160px; } +.cal-cap { color: var(--muted); font-size: .82rem; } +.cal-privacy { color: var(--muted); font-size: .78rem; margin-top: 2px; } +.cal-provider .gcal-connect { margin-top: 0; } +.cal-provider .gcal-disconnect { display: none; color: var(--muted); + font-size: .78rem; text-decoration: none; cursor: pointer; } +.cal-provider .gcal-disconnect:hover { text-decoration: underline; } +/* connected: collapse the CTA, show ✓ + a quiet disconnect */ +.cal-provider.is-connected .gcal-connect { display: none; } +.cal-provider.is-connected .gcal-disconnect { display: inline; } +.cal-provider.is-connected .cal-cap-online { display: none; } +/* offline mode: Google sync needs the cloud — swap the CTA for a hint */ +.cal-provider[data-provider="google"] .cal-cap-offline { display: none; } +#tool-card[data-mode="offline"] .cal-provider[data-provider="google"] .gcal-connect { display: none; } +#tool-card[data-mode="offline"] .cal-provider[data-provider="google"] .cal-cap-online { display: none; } +#tool-card[data-mode="offline"] .cal-provider[data-provider="google"]:not(.is-connected) .cal-cap-offline { display: inline; } + +/* ---- Google connection badge in the export toolbar (#rv-export) ---- */ +.gcal-badge-wrap { margin-top: 8px; } +.gcal-badge { display: inline-block; font-size: .78rem; color: var(--muted); + padding: 2px 10px; border: 1px solid var(--line); border-radius: 999px; } +.gcal-badge.is-on { color: var(--mint); border-color: currentColor; font-weight: 600; } +/* the Offline workflow hides Google everywhere — badge included */ +#tool-card[data-mode="offline"] .gcal-badge-wrap { display: none; } +/* ---- processing pipeline card (live agent stepper inside the trace accordion) ---- */ +.pipe-card { position: relative; background: var(--surface); border: 1px solid var(--line); + border-radius: 16px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); } +.pipe-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.pipe-title { font-size: .9rem; font-weight: 700; color: var(--text); } +.pipe-card[data-state="done"] .pipe-title { color: var(--mint); } +.pipe-card[data-state="error"] .pipe-title { color: var(--coral); } +/* the base rule forces Hanken Grotesk on everything — the clock must stay mono + so the ticking digits don't jitter */ +.pipe-clock { font-family: ui-monospace, Menlo, Consolas, monospace !important; + font-variant-numeric: tabular-nums; font-size: .85rem; color: var(--muted); } +.pipe-track { position: relative; display: flex; align-items: center; gap: 4px; + margin-top: 12px; padding: 6px 2px; overflow: hidden; border-radius: 10px; } +.pipe-stage { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; + align-items: center; gap: 4px; padding: 6px 2px; border-radius: 10px; + background: var(--surface2); transition: background .25s ease; } +.pipe-badge { width: 24px; height: 24px; border-radius: 50%; display: flex; + align-items: center; justify-content: center; font-size: .72rem; font-weight: 800; + background: var(--surface); color: var(--muted); border: 1px solid var(--line); + transition: background .25s ease, border-color .25s ease, color .25s ease; } +.pipe-lab { font-size: .66rem; font-weight: 700; letter-spacing: .08em; color: var(--muted); } +.pipe-chev { flex: 0 0 auto; color: var(--muted); opacity: .5; font-weight: 700; } +.pipe-stage.is-active { background: rgba(109,75,224,.10); } +.pipe-stage.is-active .pipe-badge { background: #6d4be0; border-color: #6d4be0; color: #fff; + --c: #6d4be0; animation: pulse 1.4s infinite; } /* reuses the Activity tab's pulse keyframes */ +.pipe-stage.is-active .pipe-lab { color: var(--text); } +.pipe-stage.is-done { background: rgba(21,137,79,.08); } +.pipe-stage.is-done .pipe-badge { background: var(--mint); border-color: var(--mint); color: #fff; } +.pipe-stage.is-done .pipe-lab { color: var(--mint); } +.pipe-stage.is-skip { opacity: .45; } +.pipe-stage.is-skip .pipe-badge { background: transparent; border-style: dashed; } +.pipe-stage.is-error .pipe-badge { background: var(--coral); border-color: var(--coral); color: #fff; } +/* indeterminate light sweep across the whole track while running */ +.pipe-shimmer { position: absolute; inset: 0; pointer-events: none; display: none; + width: 36%; background: linear-gradient(100deg, transparent 0%, + rgba(109,75,224,.14) 45%, rgba(14,142,160,.14) 55%, transparent 100%); } +.pipe-card[data-state="running"] .pipe-shimmer { display: block; + animation: pipe-sweep 2.5s ease-in-out infinite; } +@keyframes pipe-sweep { from { transform: translateX(-100%); } to { transform: translateX(280%); } } +.pipe-cap { margin-top: 8px; text-align: center; font-size: .8rem; color: var(--muted); } +/* static post-run summary: speed / confidence / evidence / counts */ +.pipe-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; + padding-top: 10px; border-top: 1px solid var(--line); } +.pipe-chip { padding: 4px 10px; border-radius: 999px; background: var(--surface2); + border: 1px solid var(--line); font-size: .75rem; font-weight: 700; color: var(--text); + max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pipe-chip .pipe-k { color: var(--muted); font-weight: 600; margin-right: 4px; + text-transform: uppercase; font-size: .66rem; letter-spacing: .06em; } +.pipe-chip b { font-family: ui-monospace, Menlo, Consolas, monospace !important; } +.pipe-chip.is-high { color: var(--mint); } +.pipe-chip.is-review { color: var(--amber); } +@media (prefers-reduced-motion: reduce) { + .pipe-card[data-state="running"] .pipe-shimmer { display: none !important; } + .pipe-stage.is-active .pipe-badge { animation: none; } +} + +/* ---- full-word pipeline stage labels: small, one line, equal-width boxes ---- */ +.pipe-lab { font-size: .58rem; letter-spacing: .03em; white-space: nowrap; } +.pipe-stage { padding: 6px 1px; } + +/* ---- single export surface: the per-event "Add to" links own exporting; the + header gains an all-events iCal link when 2+ events are found ---- */ +.evx-head { display: flex; align-items: baseline; justify-content: space-between; + gap: 10px; flex-wrap: wrap; } +.evx-add-all { font-size: .8rem; font-weight: 700; color: var(--cyan); + text-decoration: none; white-space: nowrap; } +.evx-add-all:hover { text-decoration: underline; } + +/* ---- consistent page width: Agent/Activity/Memory/Feed match the home + page's 1000px tool card (capping + centering only — internal layout of + the pages is untouched) ---- */ +.page-wrap { max-width: 1000px; margin-left: auto !important; margin-right: auto !important; width: 100%; } + +/* ---- Activity tab: metric values in the brand violet (the This-week cards + and the run tiles both render through .tile-v). Resets the gradient + text-clip so the color actually shows regardless of the base rule's state. ---- */ +.tile-v { color: var(--violet); background: none; + -webkit-background-clip: initial; background-clip: initial; + -webkit-text-fill-color: currentColor; } + +/* ---- mobile responsiveness fixes (phone) ---- */ + +/* Top nav: on a phone the brand wordmark + 5 links overflow a single row + (logo overlaps "Home", "Memory" clipped, "Feed" off-screen). Stack the brand + above a horizontally-scrollable link strip so nothing overlaps and every + page stays reachable. Reuses the classes from _nav_html(). */ +@media (max-width: 600px) { + #site-nav .nav-inner { flex-direction: column; align-items: stretch; + justify-content: flex-start; gap: 6px; padding: 8px 14px; } + .nav-brand { justify-content: flex-start; font-size: .98rem; } + /* WRAP, don't scroll: a horizontal overflow-x:auto strip swallowed taps on a + phone (a touch with any micro-movement reads as a scroll, and links past the + fold needed scrolling first) — which left Submission/Memory/Feed feeling + dead. Wrapping keeps every link fully on-screen and plainly tappable. */ + .nav-links { width: 100%; gap: 10px 16px; flex-wrap: wrap; } + .nav-solo { flex: 0 0 auto; white-space: nowrap; padding: 8px 6px; + touch-action: manipulation; } +} + +/* Activity "Activity by stage" chart shares a 2-col row with the timeline, so on + a phone it's stuck at half width and the x-axis stage labels collapse into + garbled overlapping text. Stack the row so the chart renders full-width. */ +@media (max-width: 760px) { + .act-chart-row { flex-direction: column !important; } + .act-chart-row > * { width: 100% !important; } +} + +/* Footer: keep "on Hugging Face" (its own in .footer-meta) on one line + instead of breaking as "on Hugging" / "Face". */ +.footer-meta a { white-space: nowrap; } + +/* Hardware-degraded banner — revealed by app.py's /health probe (inline JS). */ +#status-banner-host { padding: 0; margin: 0; } +.status-banner { + display: none; + padding: 10px 16px; + text-align: center; + font-weight: 600; + font-size: 0.95rem; + line-height: 1.4; + background: var(--accent); + color: #fff; +} diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..357fc24f285f14a79f57018944714d6f3189d504 Binary files /dev/null and b/static/logo.png differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..a0e503622ee1858263f2a35909cdc36172972739 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Pytest config: force stub mode + isolate side-effect paths, and make the repo +root importable so `server`, `calendar_out`, `ui` resolve.""" +import os +import sys +import tempfile +from pathlib import Path + +os.environ["USE_STUB_EXTRACTOR"] = "1" +os.environ.setdefault("FEED_PATH", os.path.join(tempfile.gettempdir(), "ci_feed.json")) +os.environ.setdefault("IMPACT_PATH", os.path.join(tempfile.gettempdir(), "ci_impact.json")) +os.environ.setdefault("DEDUP_PATH", os.path.join(tempfile.gettempdir(), "ci_dedup.json")) +os.environ.setdefault("MEMORY_PATH", os.path.join(tempfile.gettempdir(), "ci_memory.json")) +os.environ.setdefault("INGEST_TOKEN", "test-token") + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..b8d2474e452ace2ac7f517f56303a93360452cf0 --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from dateutil import parser as dtparser + +from server.agent import run_agent, run_agent_stream +from server.schema import ActionPlan + + +def test_stream_yields_partials_then_plan(): + steps = 0 + final = None + for acc, plan in run_agent_stream("Alex: lunch 1pm tomorrow?"): + steps += 1 + assert isinstance(acc, str) + if plan is not None: + final = plan + assert steps > 1 # streamed in chunks + assert isinstance(final, ActionPlan) + assert len(final.events) == 1 + # start/end parse as ISO 8601 + dtparser.isoparse(final.events[0].start) + dtparser.isoparse(final.events[0].end) + + +def test_no_time_thread_has_no_events(): + plan = run_agent("lol that meme killed me") + assert isinstance(plan, ActionPlan) + assert plan.events == [] + assert plan.reply_draft # still drafts a reply + + +def test_run_agent_marks_images(): + # stub ignores image content but the path should not error + plan = run_agent("dinner 6pm", images=["data:image/png;base64,xx"]) + assert isinstance(plan, ActionPlan) diff --git a/tests/test_agent_endpoint.py b/tests/test_agent_endpoint.py new file mode 100644 index 0000000000000000000000000000000000000000..63c834d0289366065c5e18531ce141640b30a1e6 --- /dev/null +++ b/tests/test_agent_endpoint.py @@ -0,0 +1,103 @@ +"""The /agent endpoint + autonomous /ingest (route functions called directly).""" +import base64 + +import pytest +from fastapi import HTTPException + +import app as app_module +from app import agent, ingest +from calendar_out.ics import events_to_ics +from server import dedup, events as bus +from server.pipeline import AgentRequest +from server.schema import Event + +TOKEN = "Bearer test-token" # matches conftest INGEST_TOKEN + + +@pytest.fixture(autouse=True) +def _clean(): + dedup.reset() + yield + dedup.reset() + + +def test_agent_requires_token(): + with pytest.raises(HTTPException) as e: + agent(AgentRequest(thread="lunch 1pm tomorrow"), authorization="") + assert e.value.status_code == 401 + + +def test_agent_returns_plan(): + resp = agent(AgentRequest(thread="lunch 1pm tomorrow"), authorization=TOKEN) + assert len(resp.plan.events) == 1 + assert resp.plan.events[0].start # ISO start present + + +def test_agent_return_ics(): + resp = agent(AgentRequest(thread="lunch 1pm tomorrow", return_ics=True), authorization=TOKEN) + assert resp.ics_base64 + assert b"BEGIN:VEVENT" in base64.b64decode(resp.ics_base64) + + +def test_agent_detects_conflict_from_existing_ics(): + # stub turns "lunch 1pm tomorrow" (now=Jun 5) into Jun 6 13:00-14:00 + busy_ics = events_to_ics([Event(title="Standup", start="2026-06-06T13:00:00", + end="2026-06-06T14:00:00")]) + resp = agent( + AgentRequest( + thread="lunch 1pm tomorrow", + now="2026-06-05T09:00:00", + existing_ics=base64.b64encode(busy_ics).decode(), + ), + authorization=TOKEN, + ) + assert resp.plan.conflicts + assert resp.plan.conflicts[0].severity == "overlap" + + +def _msg(text, chat="Mom"): + return {"chat": chat, "sender": chat, "text": text, "timestamp": "2026-06-05T10:00:00", "images": []} + + +def _ingest_and_flush(batch, authorization): + """Call the route directly, then run its queued background tasks — + autonomous work now happens off the request (collector-timeout fix).""" + from fastapi import BackgroundTasks + + bt = BackgroundTasks() + out = ingest(batch, bt, authorization=authorization) + for task in bt.tasks: + task.func(*task.args, **task.kwargs) + return out + + +def test_ingest_requires_token(): + from app import IngestBatch + from fastapi import BackgroundTasks + + with pytest.raises(HTTPException): + ingest(IngestBatch(messages=[]), BackgroundTasks(), authorization="") + + +def test_autonomous_ingest_dedupes(monkeypatch, tmp_path): + from app import IngestBatch + import calendar_out.gcal as gcal + + monkeypatch.setattr(app_module, "AUTONOMOUS", True) + monkeypatch.setattr(app_module, "TRIGGER_ON", "any") # this test isn't about direction + monkeypatch.setattr(app_module, "FEED_PATH", tmp_path / "feed.json") + # events are only marked seen after a SUCCESSFUL push — stub one in + monkeypatch.setattr(gcal, "push_events", + lambda evs, calendar_id="primary": [""] * len(evs)) + + _ingest_and_flush( + IngestBatch(messages=[app_module.IngestMessage(**_msg("dinner 6pm tomorrow"))]), + authorization=TOKEN) + seen_after_first = len(dedup._load()) + assert seen_after_first >= 1 + + # a follow-up message in the same chat must not re-create the same event + _ingest_and_flush( + IngestBatch(messages=[app_module.IngestMessage(**_msg("see you then"))]), + authorization=TOKEN) + assert len(dedup._load()) == seen_after_first diff --git a/tests/test_autonomous_push.py b/tests/test_autonomous_push.py new file mode 100644 index 0000000000000000000000000000000000000000..1dd41ad5c75fa6f6ae5f235cd4c0264a5ff8b3a0 --- /dev/null +++ b/tests/test_autonomous_push.py @@ -0,0 +1,71 @@ +"""Autonomous mode pushes to Google Calendar ONLY after dedup. + +Locks in the fix for the duplicate-push bug: the pipeline used to run with +push_gcal=True (pushing ALL plan events) and dedup only afterwards — so every +rolling-window re-run re-pushed already-captured events, directly contradicting +dedup.py's "don't create the same event twice".""" +import app as app_module +import calendar_out.gcal as gcal +from server import dedup + + +def _seed_feed(tmp_path, monkeypatch): + monkeypatch.setattr(app_module, "FEED_PATH", tmp_path / "feed.json") + monkeypatch.setenv("DEDUP_PATH", str(tmp_path / "seen.json")) + dedup.reset() + app_module._append_feed([{ + "chat": "Fam", "sender": "Mom", "text": "dinner 6pm tomorrow", + "timestamp": "2026-06-05T10:00:00", "images": [], "is_from_me": False, + }]) + + +def test_pipeline_runs_without_push_and_only_fresh_events_are_pushed(monkeypatch, tmp_path): + _seed_feed(tmp_path, monkeypatch) + + pushes: list[list[str]] = [] + monkeypatch.setattr( + gcal, "push_events", + lambda evs, calendar_id="primary": pushes.append([e.title for e in evs]) or [""] * len(evs), + ) + reqs = [] + real_pipeline = app_module.run_pipeline + + def spy_pipeline(req): + reqs.append(req) + return real_pipeline(req) + + monkeypatch.setattr(app_module, "run_pipeline", spy_pipeline) + + app_module._run_autonomous({"Fam"}) + + # the pipeline itself must NOT push — push happens after dedup, on the + # filtered set only + assert reqs and all(r.push_gcal is False for r in reqs) + assert len(pushes) == 1 and pushes[0], "fresh events pushed exactly once" + + # re-run over the same rolling window: nothing fresh -> NO second push + app_module._run_autonomous({"Fam"}) + assert len(pushes) == 1, "already-captured events must never be re-pushed" + + +def test_push_failure_is_retried_not_swallowed(monkeypatch, tmp_path): + """A transient push failure must NOT mark events seen — they'd be filtered + out forever ('seen' but never delivered). The next run retries them.""" + _seed_feed(tmp_path, monkeypatch) + + def boom(evs, calendar_id="primary"): + raise RuntimeError("transient gcal outage") + + monkeypatch.setattr(gcal, "push_events", boom) + app_module._run_autonomous({"Fam"}) # must not raise + assert dedup._load() == [], "failed push must not mark events seen" + + # outage over: the SAME events get delivered on the next run + pushes = [] + monkeypatch.setattr( + gcal, "push_events", + lambda evs, calendar_id="primary": pushes.append(len(evs)) or [""] * len(evs), + ) + app_module._run_autonomous({"Fam"}) + assert pushes == [1] + assert len(dedup._load()) >= 1, "successful push marks events seen" diff --git a/tests/test_cal_env.py b/tests/test_cal_env.py new file mode 100644 index 0000000000000000000000000000000000000000..dbee87688f800d8c491ad95234abb1f81996c962 --- /dev/null +++ b/tests/test_cal_env.py @@ -0,0 +1,37 @@ +"""CAL_ICS_PATH: a locally hosted install can auto-complete the conflict-check +step — _load_busy falls back to the env-pointed .ics when nothing is uploaded.""" +from calendar_out.ics import events_to_ics +from server.schema import Event +from ui import blocks + + +def _sample_ics_bytes() -> bytes: + return events_to_ics([ + Event(title="Dentist", start="2026-06-18T09:00:00", end="2026-06-18T10:00:00"), + ]) + + +def test_load_busy_env_fallback(tmp_path, monkeypatch): + ics = tmp_path / "cal.ics" + ics.write_bytes(_sample_ics_bytes()) + monkeypatch.setenv("CAL_ICS_PATH", str(ics)) + busy = blocks._load_busy(None) + assert len(busy) == 1 + assert busy[0].title == "Dentist" + + +def test_load_busy_upload_overrides_env(tmp_path, monkeypatch): + env_ics = tmp_path / "env.ics" + env_ics.write_bytes(_sample_ics_bytes()) + upload = tmp_path / "upload.ics" + upload.write_bytes(events_to_ics([ + Event(title="Soccer", start="2026-06-19T17:00:00", end="2026-06-19T18:00:00"), + ])) + monkeypatch.setenv("CAL_ICS_PATH", str(env_ics)) + busy = blocks._load_busy(str(upload)) + assert [b.title for b in busy] == ["Soccer"] + + +def test_load_busy_unset_env_is_empty(monkeypatch): + monkeypatch.delenv("CAL_ICS_PATH", raising=False) + assert blocks._load_busy(None) == [] diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..82ef156f4e9d8f178e342e867fdd6c4f0aaa2eb3 --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,31 @@ +import base64 +import os +import tempfile + +from calendar_out.ics import events_to_ics +from server.imageutil import to_data_uri +from server.schema import Event + +_TINY_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC" +) + + +def test_ics_has_vevent(): + ics = events_to_ics([Event(title="X", start="2026-06-10T13:00:00", end="2026-06-10T14:00:00")]) + assert b"BEGIN:VCALENDAR" in ics + assert b"BEGIN:VEVENT" in ics + + +def test_to_data_uri_png(): + p = os.path.join(tempfile.gettempdir(), "ci_tiny.png") + with open(p, "wb") as f: + f.write(_TINY_PNG) + uri = to_data_uri(p) + assert uri and uri.startswith("data:image/png;base64,") + + +def test_to_data_uri_rejects_non_image(tmp_path): + p = tmp_path / "note.txt" + p.write_text("hello") + assert to_data_uri(str(p)) is None diff --git a/tests/test_conflicts.py b/tests/test_conflicts.py new file mode 100644 index 0000000000000000000000000000000000000000..d2c4b3e573fb807b63c2835b044fc3c187fd0283 --- /dev/null +++ b/tests/test_conflicts.py @@ -0,0 +1,49 @@ +from datetime import datetime, timedelta + +from calendar_out.freebusy import ( + annotate_conflicts, + check_conflicts, + load_ics_busy, + propose_times, +) +from calendar_out.ics import events_to_ics +from server.schema import ActionPlan, Event + + +def _event_at(hour, minutes=60): + start = (datetime.now() + timedelta(days=1)).replace( + hour=hour, minute=0, second=0, microsecond=0 + ) + return Event(title="Lunch", start=start.isoformat(), + end=(start + timedelta(minutes=minutes)).isoformat()) + + +def _busy_overlapping(ev): + start = datetime.fromisoformat(ev.start) + clash = Event(title="Standup", start=start.isoformat(), + end=(start + timedelta(minutes=30)).isoformat()) + return load_ics_busy(events_to_ics([clash])) + + +def test_overlap_detected_and_alternatives_proposed(): + ev = _event_at(13) + busy = _busy_overlapping(ev) + conflicts = check_conflicts([ev], busy) + assert conflicts and conflicts[0].severity == "overlap" + alts = propose_times(ev, busy) + assert alts # non-empty alternatives + + +def test_clear_calendar_has_no_conflicts(): + ev = _event_at(13) + far = Event(title="Faraway", + start=(datetime.fromisoformat(ev.start) + timedelta(days=10)).isoformat()) + busy = load_ics_busy(events_to_ics([far])) + assert check_conflicts([ev], busy) == [] + + +def test_annotate_fills_plan(): + ev = _event_at(13) + plan = ActionPlan(events=[ev]) + plan = annotate_conflicts(plan, _busy_overlapping(ev)) + assert plan.conflicts and plan.proposed_times diff --git a/tests/test_dataset.py b/tests/test_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..7395eb22e97c4645f6a9194e053b2dae9b274cd9 --- /dev/null +++ b/tests/test_dataset.py @@ -0,0 +1,32 @@ +"""The synthetic training dataset (training/make_dataset.py) stays well-formed.""" +from server.schema import ActionPlan +from training.make_dataset import SEEDS, to_record + + +def test_enough_seeds(): + assert len(SEEDS) >= 20 + + +def test_seed_shape_and_validity(): + for now, thread, plan in SEEDS: + assert isinstance(now, str) and isinstance(thread, str) + # every target plan must validate against the serving schema + ActionPlan(**plan) + + +def test_to_record_builds_chat_triple(): + now, thread, plan = SEEDS[0] + rec = to_record(now, thread, plan) + roles = [m["role"] for m in rec["messages"]] + assert roles == ["system", "user", "assistant"] + + +def test_covers_hard_cases(): + """Sanity that the expansion includes empties, conflicts, clarifications, images.""" + plans = [p for _, _, p in SEEDS] + assert any(p["events"] == [] for p in plans) # no-event chitchat + assert any(p["conflicts"] for p in plans) # a conflict case + assert any(p["proposed_times"] for p in plans) # alternatives offered + assert any(p.get("needs_clarification") for p in plans) # ambiguity + threads = [t for _, t, _ in SEEDS] + assert any("[image:" in t for t in threads) # image-described diff --git a/tests/test_dedup.py b/tests/test_dedup.py new file mode 100644 index 0000000000000000000000000000000000000000..cd06c2306640cb124496f73f9256a2028faf5df2 --- /dev/null +++ b/tests/test_dedup.py @@ -0,0 +1,41 @@ +"""Autonomous idempotency (server/dedup.py).""" +import pytest + +from server import dedup +from server.schema import Event + + +@pytest.fixture(autouse=True) +def _clean(): + dedup.reset() + yield + dedup.reset() + + +def _ev(title="Dinner", start="2026-06-06T18:00:00"): + return Event(title=title, start=start) + + +def test_filter_new_drops_repeats_across_calls(): + first = dedup.filter_new([_ev()]) + assert len(first) == 1 + second = dedup.filter_new([_ev()]) + assert second == [] # same event, already seen + + +def test_filter_new_keeps_distinct(): + dedup.filter_new([_ev()]) + out = dedup.filter_new([_ev(title="Lunch", start="2026-06-07T12:00:00")]) + assert len(out) == 1 + + +def test_event_key_normalizes_title_and_minute(): + a = dedup.event_key(_ev(title=" Dinner ", start="2026-06-06T18:00:00")) + b = dedup.event_key(_ev(title="dinner", start="2026-06-06T18:00:30")) + assert a == b # case/space-insensitive, start rounded to the minute + + +def test_mixed_batch_returns_only_new(): + dedup.filter_new([_ev()]) + out = dedup.filter_new([_ev(), _ev(title="New", start="2026-06-08T09:00:00")]) + assert [e.title for e in out] == ["New"] diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000000000000000000000000000000000000..a58260ecca56e7bec8907f35af7da7d8173b561b --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,35 @@ +from server import events as bus +from server.agent import run_agent_stream + + +def test_emit_and_recent(): + before = len(bus.recent(999)) + bus.emit("model", "unit test", latency_ms=12) + after = bus.recent(999) + assert len(after) >= before + 1 + assert after[0]["stage"] == "model" and after[0]["latency_ms"] == 12 + + +def test_run_scope_groups_events(): + with bus.run_scope("unit") as rid: + bus.emit("model", "a") + bus.emit("decision", "b") + runs = dict(bus.recent_runs(50)) + assert rid in runs + assert len(runs[rid]) == 2 + + +def test_streamed_run_produces_trace(): + for _ in run_agent_stream("lunch 1pm tomorrow", images=["data:image/png;base64,xx"]): + pass + # last run should include vision + model + decision stages + _, evs = bus.recent_runs(1)[0] + stages = {e["stage"] for e in evs} + assert {"vision", "model", "decision"} <= stages + + +def test_metrics_and_stage_counts_shapes(): + m = bus.metrics() + assert {"messages", "events_created", "conflicts", "model_calls"} <= set(m) + counts = bus.stage_counts() + assert {d["stage"] for d in counts} == set(bus.STAGES) diff --git a/tests/test_gcal_oauth.py b/tests/test_gcal_oauth.py new file mode 100644 index 0000000000000000000000000000000000000000..981f93d0d5a999f4171d3f352d99976b8d42c6a0 --- /dev/null +++ b/tests/test_gcal_oauth.py @@ -0,0 +1,44 @@ +"""Per-user Google Calendar OAuth helpers + push handler. Network-free; does NOT +import the google libs (they're excluded from requirements-ci).""" +import os + +os.environ.setdefault("USE_STUB_EXTRACTOR", "1") + +import pytest # noqa: E402 + +from calendar_out import gcal # safe: google libs are imported lazily, inside functions +from ui import blocks # noqa: E402 + + +def test_client_config_requires_env(monkeypatch): + monkeypatch.delenv("GOOGLE_OAUTH_CLIENT_ID", raising=False) + monkeypatch.delenv("GOOGLE_OAUTH_CLIENT_SECRET", raising=False) + with pytest.raises(RuntimeError): + gcal._client_config() + + +def test_client_config_with_env(monkeypatch): + monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "cid.apps.googleusercontent.com") + monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "secret") + cfg = gcal._client_config() + assert cfg["web"]["client_id"] == "cid.apps.googleusercontent.com" + assert cfg["web"]["token_uri"].startswith("https://") + + +def test_push_handler_without_token_prompts_connect(): + msg = blocks._on_push_gcal([["Meet", "2026-06-12T09:00", "", "", 30]], "", 0) + assert "Connect Google Calendar" in msg + + +def test_push_handler_with_token_pushes(monkeypatch): + calls = {} + + def fake_push(tok, events): + calls["n"] = len(events) + return ["https://cal/evt"] + + monkeypatch.setattr(gcal, "push_events_with_token", fake_push) + monkeypatch.setattr(blocks.impact, "record_capture", lambda *a, **k: calls.setdefault("rec", True)) + msg = blocks._on_push_gcal([["Meet", "2026-06-12T09:00", "", "", 30]], "TOKEN_JSON", 0) + assert calls["n"] == 1 and calls.get("rec") is True + assert "Google Calendar" in msg and "cal/evt" in msg diff --git a/tests/test_impact.py b/tests/test_impact.py new file mode 100644 index 0000000000000000000000000000000000000000..16f23ed4c3d90d456950115a2221f0476aea9ffe --- /dev/null +++ b/tests/test_impact.py @@ -0,0 +1,61 @@ +"""Durable weekly impact metrics (server/impact.py).""" +import importlib + +import pytest + +from server import impact + + +@pytest.fixture(autouse=True) +def _clean(): + impact.reset() + yield + impact.reset() + + +def test_record_and_read_roundtrip(): + impact.record_capture(3, conflicts_caught=1) + w = impact.this_week() + assert w["events_captured"] == 3 + assert w["conflicts_caught"] == 1 + # default estimate: 3*8 + 1*15 + assert w["minutes_saved"] == 3 * 8 + 1 * 15 + + +def test_accumulates_across_calls(): + impact.record_capture(2) + impact.record_capture(1, conflicts_caught=2) + w = impact.this_week() + assert w["events_captured"] == 3 + assert w["conflicts_caught"] == 2 + + +def test_persists_across_restart(): + """this_week() reads from disk each call, so a fresh import sees prior data.""" + impact.record_capture(5) + reloaded = importlib.reload(impact) + assert reloaded.this_week()["events_captured"] == 5 + + +def test_week_rollover_keeps_weeks_separate(monkeypatch): + # one _week_key() call per record_capture / this_week + weeks = iter(["2026-W23", "2026-W24"]) + monkeypatch.setattr(impact, "_week_key", lambda when=None: next(weeks)) + impact.record_capture(2) # writes W23 + this = impact.this_week() # reads W24 -> empty + assert this["events_captured"] == 0 + # both weeks coexist in the file + data = impact._load() + assert data["2026-W23"]["events_captured"] == 2 + + +def test_minutes_saved_env_override(monkeypatch): + monkeypatch.setenv("IMPACT_MIN_PER_EVENT", "12") + monkeypatch.setenv("IMPACT_MIN_PER_CONFLICT", "20") + impact.record_capture(2, conflicts_caught=1) + assert impact.this_week()["minutes_saved"] == 2 * 12 + 1 * 20 + + +def test_this_week_defaults_to_zero(): + w = impact.this_week() + assert w == {"events_captured": 0, "conflicts_caught": 0, "minutes_saved": 0} diff --git a/tests/test_inference_seam.py b/tests/test_inference_seam.py new file mode 100644 index 0000000000000000000000000000000000000000..31dc903459948a107c079ba87524f00c58f1ffe5 --- /dev/null +++ b/tests/test_inference_seam.py @@ -0,0 +1,49 @@ +"""Configurable inference seam (server/model.py) — env-selected, network-free.""" +from server import model + + +def _fake_resp(content): + class R: + def raise_for_status(self): + pass + + def json(self): + return {"choices": [{"message": {"content": content}}], "usage": {}} + + return R() + + +def test_remote_branch_used_when_base_url_set(monkeypatch): + monkeypatch.setattr(model, "INFERENCE_BASE_URL", "http://localhost:9/v1") + # if the local llama.cpp path were taken, get_llm would be called -> blow up + monkeypatch.setattr(model, "get_llm", lambda: (_ for _ in ()).throw(AssertionError("local used"))) + + calls = {} + + def fake_post(url, json, headers, timeout): + calls["url"] = url + calls["body"] = json + return _fake_resp('{"events": []}') + + monkeypatch.setattr("requests.post", fake_post) + + out = model.complete_json([{"role": "user", "content": "hi"}], {"type": "object"}) + assert out == '{"events": []}' + assert calls["url"].endswith("/chat/completions") + assert calls["body"]["response_format"]["type"] == "json_schema" + + +def test_local_branch_when_base_url_unset(monkeypatch): + monkeypatch.setattr(model, "INFERENCE_BASE_URL", "") + + def no_network(*a, **k): + raise AssertionError("remote must not be called when INFERENCE_BASE_URL is unset") + + monkeypatch.setattr("requests.post", no_network) + + class FakeLLM: + def create_chat_completion(self, **kw): + return {"choices": [{"message": {"content": "{}"}}], "usage": {}} + + monkeypatch.setattr(model, "get_llm", lambda: FakeLLM()) + assert model.complete_json([{"role": "user", "content": "hi"}], {"type": "object"}) == "{}" diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000000000000000000000000000000000000..8f0674af89f59dd925dbe58019d466e7f13307a3 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,48 @@ +from datetime import datetime + +import pytest + +from server import memory +from server.agent import build_messages +from server.schema import ActionPlan, Event + + +@pytest.fixture(autouse=True) +def _clean_mem(): + memory.reset() + yield + memory.reset() + + +def test_remember_recall_forget(): + f = memory.remember("Dana is the soccer coach", "contact") + assert f and f["id"] >= 1 + assert "Dana is the soccer coach" in memory.recall() + assert memory.forget(f["id"]) + assert memory.recall() == "" + + +def test_remember_dedupes_and_bumps_weight(): + a = memory.remember("you decline Mondays", "preference") + b = memory.remember("You decline Mondays", "preference") # same after normalize + assert a["id"] == b["id"] + facts = memory.list_facts() + assert len(facts) == 1 and facts[0]["weight"] == 2 + + +def test_recall_injected_into_prompt(): + memory.remember("you decline Mondays", "preference") + msgs = build_messages("lunch?", datetime(2026, 6, 7, 9, 0), []) + assert "you decline Mondays" in msgs[-1]["content"] + + +def test_recall_empty_when_no_facts(): + assert memory.recall() == "" + + +def test_observe_plan_learns_contacts(): + plan = ActionPlan( + events=[Event(title="Lunch", start="2026-06-09T13:00:00", attendees=["Alex"])] + ) + memory.observe_plan(plan) + assert any("Alex" in f["text"] for f in memory.list_facts()) diff --git a/tests/test_memory_client.py b/tests/test_memory_client.py new file mode 100644 index 0000000000000000000000000000000000000000..852365e070bb65c40b0046942347fe1bed37262e --- /dev/null +++ b/tests/test_memory_client.py @@ -0,0 +1,89 @@ +"""Per-user (client/localStorage) memory: pure helpers, parsers, UI handlers, +and per-request injection into the agent prompt. Stub mode; no network.""" +import os +from datetime import datetime + +os.environ.setdefault("USE_STUB_EXTRACTOR", "1") + +from server import agent, memory # noqa: E402 +from server.schema import ActionPlan, Event # noqa: E402 +from ui import blocks # noqa: E402 + + +# --- pure helpers ----------------------------------------------------------- +def test_facts_to_recall_and_merge(): + facts = memory.merge_facts([], [("Dana is the coach", "contact"), "you decline Mondays"]) + assert len(facts) == 2 + # dedup + weight bump + facts = memory.merge_facts(facts, ["Dana is the coach"]) + dana = [f for f in facts if "Dana" in f["text"]][0] + assert dana["weight"] == 2 + block = memory.facts_to_recall(facts) + assert "What I know about you" in block and "Dana is the coach" in block + assert memory.facts_to_recall([]) == "" + + +def test_learn_from_plan_returns_contacts(): + plan = ActionPlan(reasoning="x", events=[Event(title="Game", start="2026-06-12T10:00", attendees=["Coach Dana"])]) + learned = memory.learn_from_plan(plan) + assert any("Coach Dana" in t for t in learned) + + +# --- per-request injection (the whole point) -------------------------------- +def test_build_messages_uses_passed_memory(): + msgs = agent.build_messages("hi", datetime(2026, 6, 12, 9, 0), [], None, + memory_block="What I know about you (memory):\n- Dana is the coach") + blob = str(msgs) + assert "Dana is the coach" in blob + + +# --- UI client handlers ----------------------------------------------------- +def test_mem_remember_and_forget_roundtrip(): + mem, status = blocks._mem_remember("you decline Mondays", "preference", "[]") + facts = blocks._facts_load(mem) + assert facts and facts[0]["text"] == "you decline Mondays" + fid = facts[0]["id"] + mem2, status2 = blocks._mem_forget(fid, mem) + assert blocks._facts_load(mem2) == [] + assert "Forgotten" in status2 + + +def test_onboard_writes_facts(): + mem, vis, msg = blocks._on_onboard("Dana = soccer coach\nMr. Lee = teacher", 30, "Mondays", "[]") + texts = [f["text"] for f in blocks._facts_load(mem)] + assert any("Dana is the soccer coach" in t for t in texts) + assert any("teacher" in t for t in texts) + assert any("30 minutes" in t for t in texts) + assert any("Mondays" in t for t in texts) + + +# --- file import parsers ---------------------------------------------------- +def test_parse_vcf_csv_ics(tmp_path): + vcf = tmp_path / "c.vcf" + vcf.write_text("BEGIN:VCARD\nFN:Coach Dana\nEND:VCARD\nBEGIN:VCARD\nFN:Mr. Lee\nEND:VCARD\n") + names, _ = blocks._parse_memory_file(str(vcf)) + assert "Coach Dana" in names and "Mr. Lee" in names + + csv = tmp_path / "c.csv" + csv.write_text("name,phone\nAlex Kim,123\nJamie Fox,456\n") + names, _ = blocks._parse_memory_file(str(csv)) + assert "Alex Kim" in names and "Jamie Fox" in names + + ics = tmp_path / "cal.ics" + ics.write_text("BEGIN:VEVENT\nSUMMARY:Soccer practice\nATTENDEE;CN=Coach Dana:mailto:d@x.com\nEND:VEVENT\n") + names, prefs = blocks._parse_memory_file(str(ics)) + assert "Coach Dana" in names + assert any("Soccer practice" in p[0] for p in prefs) + + +def test_import_file_merges_into_memory(tmp_path): + vcf = tmp_path / "c.vcf" + vcf.write_text("BEGIN:VCARD\nFN:Coach Dana\nEND:VCARD\n") + mem, status = blocks._import_file(str(vcf), "[]") + assert any("Coach Dana" in f["text"] for f in blocks._facts_load(mem)) + assert "Imported" in status + + +def test_import_gcal_unchecked_is_graceful(): + mem, status = blocks._import_gcal(False, "[]") + assert "Tick the box" in status diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..c24278aa1437e4dad9fcd324700b5ca97118dafb --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,75 @@ +"""Agent orchestrator (stub/scripted path — CI never needs a model).""" +import base64 +import json + +from calendar_out.ics import events_to_ics +from server.orchestrator import run_orchestrator +from server.schema import Event + +THREAD = ( + "Room parent: picture day is Thursday June 18 at 9am, wear green!\n" + "Me: adding it now" +) + + +def _cal_b64() -> str: + ics = events_to_ics([ + Event(title="Standup", start="2026-06-18T09:00:00", end="2026-06-18T09:30:00"), + ]) + return base64.b64encode(ics).decode("ascii") + + +def test_orchestrator_scripted_end_to_end(): + steps = list(run_orchestrator(THREAD)) + kinds = [s["kind"] for s in steps] + assert kinds[0] == "plan" + assert "tool_call" in kinds and "tool_result" in kinds + assert kinds[-1] == "final" + final = steps[-1] + assert final["summary"]["events"] >= 1 + assert final["plan"]["events"], "extracted at least one event" + # the agent rendered a real .ics + assert final["ics_base64"] + assert b"BEGIN:VCALENDAR" in base64.b64decode(final["ics_base64"]) + # every step must survive the JSON boundary (UI/trace serialisation) + json.dumps(steps) + + +def test_orchestrator_checks_conflicts_when_calendar_given(): + steps = list(run_orchestrator(THREAD, ics_b64=_cal_b64())) + tools = [s.get("tool") for s in steps if s["kind"] == "tool_call"] + assert "check_conflicts" in tools + assert steps[-1]["kind"] == "final" + + +def test_orchestrator_skips_conflicts_without_calendar(): + steps = list(run_orchestrator(THREAD)) + tools = [s.get("tool") for s in steps if s["kind"] == "tool_call"] + assert "check_conflicts" not in tools + + +def test_orchestrator_handles_eventless_thread(): + steps = list(run_orchestrator("ok thanks!! see you")) + final = steps[-1] + assert final["kind"] == "final" + assert final["summary"]["events"] == 0 + assert final["ics_base64"] is None + + +def test_extract_events_tool_accepts_memory(): + from server.mcp_tools import extract_events + + plan = extract_events(THREAD, memory="Dana is the soccer coach") + assert isinstance(plan, dict) and "events" in plan + + +def test_homepage_analyze_runs_the_agentic_workflow(): + """The homepage's _on_analyze is orchestrator-driven: the thinking panel + carries the agent's step trace, results keep the legacy 5-tuple contract.""" + from ui.blocks import _on_analyze + + *_, last = list(_on_analyze(THREAD, None, None)) + trace, rows, plan_md, reply, status = last + assert "ag-final" in trace and "ag-tool_call" in trace + assert rows and rows[0][0] + assert status.startswith("Found ") diff --git a/tests/test_publish_trace.py b/tests/test_publish_trace.py new file mode 100644 index 0000000000000000000000000000000000000000..a8e9027e75e9bb8a6d5b342b4ca8bf12cb888e5d --- /dev/null +++ b/tests/test_publish_trace.py @@ -0,0 +1,57 @@ +"""One-click 'Publish my trace to the Hub' (Sharing is Caring). Network-free.""" +import os + +os.environ.setdefault("USE_STUB_EXTRACTOR", "1") + +import huggingface_hub # noqa: E402 + +from server import events as bus # noqa: E402 +from ui import blocks # noqa: E402 + + +def test_publish_without_token_is_graceful_and_offline(monkeypatch): + monkeypatch.delenv("HF_WRITE_TOKEN", raising=False) + + def _boom(*a, **k): # must NOT touch the network without a token + raise AssertionError("HfApi must not be constructed without a token") + + monkeypatch.setattr(huggingface_hub, "HfApi", _boom) + msg = blocks._on_publish_trace() + assert "HF_WRITE_TOKEN" in msg and "isn't configured" in msg + + +def test_publish_empty_run(monkeypatch): + monkeypatch.setenv("HF_WRITE_TOKEN", "hf_dummy") + bus.reset() if hasattr(bus, "reset") else None + # no run recorded → export_run has no steps + import server.trace as trace + monkeypatch.setattr(trace, "export_run", lambda redact=True: {"steps": [], "summary": {}}) + msg = blocks._on_publish_trace() + assert "Nothing to publish" in msg + + +def test_publish_with_token_uploads_public(monkeypatch): + monkeypatch.setenv("HF_WRITE_TOKEN", "hf_dummy") + monkeypatch.setenv("TRACE_DATASET", "me/traces") + import server.trace as trace + monkeypatch.setattr(trace, "export_run", + lambda redact=True: {"steps": [{"stage": "model"}], "summary": {"steps": 1}}) + + calls = {} + + class FakeApi: + def __init__(self, token=None): + calls["token"] = token + + def create_repo(self, repo_id, repo_type=None, private=None, exist_ok=None): + calls["create"] = {"repo_id": repo_id, "repo_type": repo_type, "private": private} + + def upload_file(self, **k): + calls["upload"] = k + + monkeypatch.setattr(huggingface_hub, "HfApi", FakeApi) + msg = blocks._on_publish_trace() + assert calls["token"] == "hf_dummy" + assert calls["create"] == {"repo_id": "me/traces", "repo_type": "dataset", "private": False} + assert calls["upload"]["repo_id"] == "me/traces" and calls["upload"]["repo_type"] == "dataset" + assert "me/traces" in msg and "huggingface.co/datasets/me/traces" in msg diff --git a/tests/test_share_trace.py b/tests/test_share_trace.py new file mode 100644 index 0000000000000000000000000000000000000000..7777d6e6a31bc365baba8607c89543d469b3fc62 --- /dev/null +++ b/tests/test_share_trace.py @@ -0,0 +1,45 @@ +"""The local trace-upload CLI (training/share_trace.py). Network-free.""" +import json + +import pytest + +from server import trace +from training import share_trace + + +def _write_trace(tmp_path): + t = trace.export_run() # empty-but-valid envelope is fine for these tests + p = tmp_path / "trace.json" + return trace.write_trace(t, path=str(p)) + + +def test_dry_run_makes_no_network_call(tmp_path, monkeypatch, capsys): + path = _write_trace(tmp_path) + + # If the CLI tried to use the Hub in --dry-run, this would raise. + import huggingface_hub + + def _boom(*a, **k): + raise AssertionError("HfApi must not be constructed during --dry-run") + + monkeypatch.setattr(huggingface_hub, "HfApi", _boom) + + share_trace.main([path, "--repo", "me/traces", "--dry-run"]) + out = capsys.readouterr().out + assert "https://huggingface.co/datasets/me/traces" in out + assert "dry-run" in out + + +def test_rejects_non_trace_json(tmp_path): + bad = tmp_path / "bad.json" + bad.write_text(json.dumps({"hello": "world"})) + with pytest.raises(SystemExit): + share_trace.main([str(bad), "--dry-run"]) + + +def test_public_flag_parses(tmp_path): + path = _write_trace(tmp_path) + args = share_trace._parse_args([path, "--public"]) + assert args.private is False + args = share_trace._parse_args([path]) + assert args.private is True # private by default diff --git a/tests/test_threads.py b/tests/test_threads.py new file mode 100644 index 0000000000000000000000000000000000000000..fa3da50c629db9c8722b37ebc2daf1527cb98155 --- /dev/null +++ b/tests/test_threads.py @@ -0,0 +1,43 @@ +"""Thread assembly (server/threads.py) — pure, stub-safe.""" +from server.threads import format_thread, rolling_thread + + +def test_format_thread_skips_empty(): + msgs = [ + {"sender": "Alex", "text": "dinner 7pm?"}, + {"sender": "Me", "text": ""}, + {"sender": "Me", "text": "works"}, + ] + assert format_thread(msgs) == "Alex: dinner 7pm?\nMe: works" + + +def test_rolling_thread_filters_by_chat(): + feed = [ + {"chat": "Mom", "sender": "Mom", "text": "a", "timestamp": "2026-06-05T10:00:00"}, + {"chat": "Work", "sender": "Boss", "text": "b", "timestamp": "2026-06-05T10:01:00"}, + {"chat": "Mom", "sender": "Mom", "text": "c", "timestamp": "2026-06-05T10:02:00"}, + ] + out = rolling_thread(feed, "Mom") + assert "a" in out and "c" in out and "b" not in out + + +def test_rolling_thread_window_limit(): + feed = [ + {"chat": "Mom", "sender": "Mom", "text": str(i), "timestamp": f"2026-06-05T10:{i:02d}:00"} + for i in range(10) + ] + out = rolling_thread(feed, "Mom", window=3) + assert out.splitlines() == ["Mom: 7", "Mom: 8", "Mom: 9"] + + +def test_rolling_thread_drops_old_messages(): + feed = [ + {"chat": "Mom", "sender": "Mom", "text": "old", "timestamp": "2026-06-05T00:00:00"}, + {"chat": "Mom", "sender": "Mom", "text": "new", "timestamp": "2026-06-05T23:00:00"}, + ] + out = rolling_thread(feed, "Mom", minutes=60) + assert "new" in out and "old" not in out + + +def test_rolling_thread_empty_for_unknown_chat(): + assert rolling_thread([], "Nobody") == "" diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..f69a0a4993516a8aa7d5016132413cac0cbc40ec --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,53 @@ +"""Hermes tool-calling memory round-trip (no live server needed).""" +import pytest + +from server import memory, tools + + +@pytest.fixture(autouse=True) +def _clean(): + memory.reset() + yield + memory.reset() + + +def test_dispatch_remembers(): + out = tools.dispatch("remember", '{"text":"Dana is the soccer coach","kind":"contact"}') + assert "remembered" in out + assert any("Dana" in f["text"] for f in memory.list_facts()) + + +def test_dispatch_unknown_tool(): + assert "unknown tool" in tools.dispatch("nope", "{}") + + +def test_dispatch_handles_dict_args(): + tools.dispatch("remember", {"text": "default location is Lincoln Elementary", "kind": "location"}) + assert any("Lincoln" in f["text"] for f in memory.list_facts()) + + +def test_run_with_tools_round_trip(): + # Fake server: first turn calls `remember`, second returns the final JSON. + calls = {"n": 0} + + def fake_post(msgs): + calls["n"] += 1 + if calls["n"] == 1: + return {"choices": [{"message": {"role": "assistant", "tool_calls": [ + {"id": "t1", "function": { + "name": "remember", + "arguments": '{"text":"you decline Mondays","kind":"preference"}'}}]}}]} + return {"choices": [{"message": {"role": "assistant", "content": '{"events":[]}'}}]} + + content, _ = tools.run_with_tools([{"role": "user", "content": "hi"}], fake_post) + assert content == '{"events":[]}' + assert calls["n"] == 2 # one tool round, then the final answer + assert any("Mondays" in f["text"] for f in memory.list_facts()) + + +def test_run_with_tools_no_tool_calls_returns_immediately(): + def fake_post(msgs): + return {"choices": [{"message": {"content": "{}"}}]} + + content, _ = tools.run_with_tools([{"role": "user", "content": "hi"}], fake_post) + assert content == "{}" diff --git a/tests/test_trace.py b/tests/test_trace.py new file mode 100644 index 0000000000000000000000000000000000000000..489c8b583c755b2adc9f4944246f63b0821e8c72 --- /dev/null +++ b/tests/test_trace.py @@ -0,0 +1,66 @@ +"""Agent-trace export + redaction (server/trace.py) and the UI download handler.""" +import json + +from server import events as bus +from server import trace +from ui.blocks import _on_analyze, _on_download_trace + + +def _drive_a_run(): + # stub mode is forced by conftest -> this produces a real run on the bus + list(_on_analyze("Mom: dinner Sunday 6pm", None, None)) + + +def test_export_run_shape_after_analyze(): + _drive_a_run() + t = trace.export_run() + assert t["schema"] == "imessage-cal-trace" + assert t["version"] == 1 + assert t["run_id"] + assert t["steps"], "expected at least one step from the run" + assert t["summary"]["events"] >= 1 + allowed = { + "stage", "level", "ts", "latency_ms", "events", + "conflicts", "images", "tokens", "message", + } + for step in t["steps"]: + assert set(step).issubset(allowed), f"unexpected keys: {set(step) - allowed}" + + +def test_redaction_strips_chat_names(): + with bus.run_scope("analyze"): + bus.emit("ingest", "2 msg(s) from 3rd grade chat", images=0) + bus.emit("decision", "1 event(s) detected", events=1) + rid = trace.export_run()["run_id"] + + redacted = trace.export_run(run_id=rid, redact=True) + assert all("3rd grade chat" not in s["message"] for s in redacted["steps"]) + assert redacted["redacted"] is True + + full = trace.export_run(run_id=rid, redact=False) + assert any("3rd grade chat" in s["message"] for s in full["steps"]) + + +def test_empty_envelope_when_no_run(): + # a run id that doesn't exist -> valid but empty + t = trace.export_run(run_id="nonexistent") + assert t["steps"] == [] + assert t["summary"]["events"] == 0 + + +def test_write_trace_roundtrips(tmp_path): + _drive_a_run() + t = trace.export_run() + path = trace.write_trace(t, path=str(tmp_path / "t.json")) + with open(path, encoding="utf-8") as f: + assert json.load(f) == t + + +def test_download_handler_paths(): + _drive_a_run() + path, msg = _on_download_trace(True) + assert path and "step" in msg + + bus._BUF.clear() # simulate a fresh process: no runs to export + none_path, none_msg = _on_download_trace(True) + assert none_path is None and "No agent run" in none_msg diff --git a/tests/test_trigger_direction.py b/tests/test_trigger_direction.py new file mode 100644 index 0000000000000000000000000000000000000000..3c79c2288e7bb0f98f3c01ccc9909e2413ad2331 --- /dev/null +++ b/tests/test_trigger_direction.py @@ -0,0 +1,53 @@ +"""TRIGGER_ON: autonomous mode fires on YOUR sent/accepted messages by default.""" +import pytest + +import app as app_module +from app import IngestBatch, IngestMessage, ingest + +TOKEN = "Bearer test-token" # matches conftest INGEST_TOKEN + + +def _m(text, is_from_me, chat="Fam"): + return IngestMessage( + chat=chat, + sender=("me" if is_from_me else "Mom"), + text=text, + timestamp="2026-06-07T10:00:00", + is_from_me=is_from_me, + ) + + +@pytest.fixture +def captured(monkeypatch): + calls = [] + monkeypatch.setattr(app_module, "AUTONOMOUS", True) + monkeypatch.setattr(app_module, "_run_autonomous", lambda chats: calls.append(set(chats))) + return calls + + +def _ingest_and_flush(batch, authorization): + """Direct route call + run queued background tasks (autonomous work moved + off the request so the collector's 30s POST never times out).""" + from fastapi import BackgroundTasks + + bt = BackgroundTasks() + out = ingest(batch, bt, authorization=authorization) + for task in bt.tasks: + task.func(*task.args, **task.kwargs) + return out + + +def test_outgoing_only_is_the_default(monkeypatch, captured): + monkeypatch.setattr(app_module, "TRIGGER_ON", "outgoing") + # Incoming message from someone else: no autonomous action. + _ingest_and_flush(IngestBatch(messages=[_m("dinner 6pm?", is_from_me=False)]), authorization=TOKEN) + assert captured == [] + # You reply/accept (outgoing): fires for that chat. + _ingest_and_flush(IngestBatch(messages=[_m("yes, 6pm works", is_from_me=True)]), authorization=TOKEN) + assert captured[-1] == {"Fam"} + + +def test_any_mode_also_triggers_on_incoming(monkeypatch, captured): + monkeypatch.setattr(app_module, "TRIGGER_ON", "any") + _ingest_and_flush(IngestBatch(messages=[_m("dinner 6pm?", is_from_me=False)]), authorization=TOKEN) + assert captured[-1] == {"Fam"} diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..e965e20adb3c359c36cbcb22cb5c0c4a55306b80 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,66 @@ +import pandas as pd +import pytest + +from server import impact +from ui.blocks import ( + _count_conflicts, + _load_sample, + _on_analyze, + _on_make_ics, + _refresh, + build_demo, +) + + +@pytest.fixture(autouse=True) +def _clean_impact(): + impact.reset() + yield + impact.reset() + + +def test_build_demo_constructs(): + demo = build_demo() + assert demo.__class__.__name__ == "Blocks" + + +def test_analyze_generator_shape_and_export(): + last = None + for out in _on_analyze("Mom: dinner Sunday 6pm", None, None): + assert len(out) == 5 # thinking, table, plan_md, reply, status + last = out + _, rows, _, reply, _ = last + assert rows and rows[0][0] + assert reply + path, msg = _on_make_ics(rows) + assert path and "Wrote" in msg + + +def test_export_records_capture(): + *_, last = list(_on_analyze("Mom: dinner Sunday 6pm", None, None)) + rows = last[1] + before = impact.this_week()["events_captured"] + _on_make_ics(rows) + assert impact.this_week()["events_captured"] == before + len(rows) + + +def test_count_conflicts_reads_markdown(): + md = "**Conflicts:**\n- ⚠️ event #0 **overlap** vs Dentist\n- ⚠️ event #1 **tight** vs Standup" + assert _count_conflicts(md) == 2 + assert _count_conflicts("_No conflicts or clarifications._") == 0 + + +def test_sample_button_yields_events(): + thread = _load_sample() + assert thread and isinstance(thread, str) + *_, last = list(_on_analyze(thread, None, None)) + rows = last[1] + assert rows and rows[0][0] # the sample produces at least one event in stub mode + + +def test_refresh_returns_chart_and_impact(): + out = _refresh() + assert len(out) == 6 + assert isinstance(out[3], pd.DataFrame) + assert list(out[3].columns) == ["stage", "count"] + assert "captured" in out[5] # the This-week impact panel HTML diff --git a/tests/test_ui_handlers.py b/tests/test_ui_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..9739bc0cea1a9cb15b3433de235666a43f6366f2 --- /dev/null +++ b/tests/test_ui_handlers.py @@ -0,0 +1,41 @@ +"""Unit tests for the new Schedule-tab UI handlers (start over, share trace, +screenshot status). Run in stub mode; no network/model needed.""" +import os + +os.environ.setdefault("USE_STUB_EXTRACTOR", "1") + +from ui import blocks # noqa: E402 + + +def test_start_over_clears_everything(): + out = blocks._on_start_over() + # conversation, images_in, existing_cal, results(update), status, shot, trace + assert len(out) == 7 + assert out[0] == "" # conversation cleared + assert out[1] is None # images cleared + assert out[2] is None # ics cleared + assert out[4] == "" # status cleared + assert out[5] == "" and out[6] == "" # shot + trace cleared + + +def test_screenshot_status_none_and_some(): + assert blocks._screenshot_status(None) == "" + assert blocks._screenshot_status([]) == "" + one = blocks._screenshot_status(["/tmp/a.png"]) + assert "1 screenshot" in one and "attached" in one + two = blocks._screenshot_status(["/tmp/a.png", "/tmp/b.png"]) + assert "2 screenshots" in two + + +def test_share_trace_unchecked_is_noop(): + assert blocks._on_share_trace(False, [["Picture Day", "2026-06-12T09:00", "", "", 30]]) == "" + + +def test_share_trace_checked_records_and_reports(tmp_path, monkeypatch): + monkeypatch.setenv("TRACE_PATH", str(tmp_path / "traces.jsonl")) + rows = [["Picture Day", "2026-06-12T09:00", "", "Lincoln", 30]] + msg = blocks._on_share_trace(True, rows) + assert "Privacy-safe trace recorded" in msg + # a redacted line was written (events only) + written = (tmp_path / "traces.jsonl").read_text(encoding="utf-8") + assert "Picture Day" in written and "n_events" in written diff --git a/training/data/.smcalflow_cache/train.dataflow_dialogues.jsonl b/training/data/.smcalflow_cache/train.dataflow_dialogues.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..b9a3a7bbd5f80976da61f5b78b335e13f572c835 --- /dev/null +++ b/training/data/.smcalflow_cache/train.dataflow_dialogues.jsonl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3580b8eacc2e62d2f2e0600e37c0f269d927d642460988e69c798bd41e8f4e7b +size 186555388 diff --git a/training/data/.smcalflow_cache/valid.dataflow_dialogues.jsonl b/training/data/.smcalflow_cache/valid.dataflow_dialogues.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..ee7dab4e93af2b460ef7dab9f02ff581976fc578 --- /dev/null +++ b/training/data/.smcalflow_cache/valid.dataflow_dialogues.jsonl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1a0694e911069ada82b4a389b843a82c78077612adbd9dbdae08a44a667f311 +size 20523737 diff --git a/training/data/dataset.jsonl b/training/data/dataset.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..a47233d99853ae328dca1831302e5659c8c14928 --- /dev/null +++ b/training/data/dataset.jsonl @@ -0,0 +1,139 @@ +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nAlex: lunch tomorrow at 1pm?\nMe: works, the usual place\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Alex proposed lunch tomorrow at 1pm and it was accepted.\", \"events\": [{\"title\": \"Lunch with Alex\", \"start\": \"2026-06-09T13:00:00\", \"end\": \"2026-06-09T14:00:00\", \"location\": \"the usual place\", \"attendees\": [\"Alex\"], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Perfect, see you at 1 at the usual place!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSam: dentist moved to next Thursday 3:30\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Sam rescheduled the dentist to next Thursday at 3:30pm.\", \"events\": [{\"title\": \"Dentist\", \"start\": \"2026-06-11T15:30:00\", \"end\": \"2026-06-11T16:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it, I'll be there Thursday at 3:30.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nJo: haha that meme killed me\nMe: lol\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Casual chat, no plan to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"😂\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPat: you free sometime this week to grab coffee?\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A meetup is proposed but no concrete day/time was given.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Yeah! What day works for you?\", \"needs_clarification\": \"Which day/time this week works for coffee?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nRiley: [image: screenshot of a concert ticket — 'The Lumens, Fri Jun 12, doors 7:30pm, The Fillmore']\nMe: can't wait!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"The screenshot is a concert ticket with a concrete date, time, and venue.\", \"events\": [{\"title\": \"The Lumens concert\", \"start\": \"2026-06-12T19:30:00\", \"end\": \"2026-06-12T22:30:00\", \"location\": \"The Fillmore\", \"attendees\": [\"Riley\"], \"reminder_minutes\": 120, \"notes\": \"From ticket screenshot\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added it — doors at 7:30, see you Friday!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T08:00:00\nExisting calendar: (none provided)\n\nConversation:\nRoom parent: picture day is this Thursday at 9am, green shirts!\nCoach Dana: heads up, soccer moves to Tuesday 5pm this week\nMe: thanks, adding both\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two concrete events: picture day Thursday 9am and the rescheduled soccer Tuesday 5pm.\", \"events\": [{\"title\": \"Picture day (green shirt)\", \"start\": \"2026-06-11T09:00:00\", \"end\": \"2026-06-11T09:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 720, \"notes\": \"Wear the green class shirt\"}, {\"title\": \"Soccer practice\", \"start\": \"2026-06-09T17:00:00\", \"end\": \"2026-06-09T18:00:00\", \"location\": null, \"attendees\": [\"Coach Dana\"], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got both down — picture day Thursday 9am and soccer Tuesday 5pm!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTeacher: our class field trip permission slips are due next Friday\nMe: ok!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A due date stated as 'next Friday' relative to Monday Jun 8 resolves to Jun 19.\", \"events\": [{\"title\": \"Permission slips due\", \"start\": \"2026-06-19T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": \"Class field trip\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks — I'll have them signed before Friday.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nRoom parent: field trip to the science museum is Wed 9am–3pm, bring a bagged lunch\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A single all-day-ish event with an explicit start and end on Wednesday.\", \"events\": [{\"title\": \"Science museum field trip\", \"start\": \"2026-06-10T09:00:00\", \"end\": \"2026-06-10T15:00:00\", \"location\": \"Science museum\", \"attendees\": [], \"reminder_minutes\": 720, \"notes\": \"Bring a bagged lunch\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — bagged lunch, 9 to 3 Wednesday.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPTA: the book fair runs Mon–Wed next week in the library, open during pickup\nMe: 👍\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A multi-day event spanning Monday to Wednesday of next week.\", \"events\": [{\"title\": \"Book fair\", \"start\": \"2026-06-15T08:00:00\", \"end\": \"2026-06-17T16:00:00\", \"location\": \"School library\", \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": \"Open during pickup\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks — we'll stop by after pickup.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nJess: did you see the class photo album?? so cute\nMe: omg yes 😍\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Friendly chatter, nothing to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"They grow up so fast 🥹\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach Dana: great hustle from the kids today, proud of them\nMe: such a fun season\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Encouragement, no plan or date mentioned.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks Coach — they love it!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nExisting calendar:\n- Dentist (Mia): 2026-06-10T14:00..2026-06-10T15:00\n\nRoom parent: makeup class photo is Wednesday at 2pm\nMe: hmm Mia has the dentist then\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"The makeup photo at Wed 2pm overlaps Mia's existing dentist appointment.\", \"events\": [{\"title\": \"Makeup class photo\", \"start\": \"2026-06-10T14:00:00\", \"end\": \"2026-06-10T14:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [{\"event_index\": 0, \"clashes_with\": \"Dentist (Mia) 2:00-3:00pm\", \"severity\": \"overlap\"}], \"proposed_times\": [\"2026-06-10T15:00:00\", \"2026-06-11T14:00:00\"], \"reply_draft\": \"Mia has the dentist at 2 — could she do the makeup photo at 3, or Thursday?\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nExisting calendar:\n- Work standup: 2026-06-09T09:00..2026-06-09T09:30\n\nTeacher: parent volunteers needed in class Tue 9–10am\nMe: I'll try\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Volunteering Tue 9-10am butts against the 9:00-9:30 standup (overlap).\", \"events\": [{\"title\": \"Classroom volunteering\", \"start\": \"2026-06-09T09:00:00\", \"end\": \"2026-06-09T10:00:00\", \"location\": \"Classroom\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [{\"event_index\": 0, \"clashes_with\": \"Work standup 9:00-9:30am\", \"severity\": \"overlap\"}], \"proposed_times\": [\"2026-06-09T10:00:00\", \"2026-06-11T09:00:00\"], \"reply_draft\": \"I have a 9am call — could I come in at 10 instead?\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nRoom parent: [image: flyer — 'Spring Family Picnic, Sat Jun 13, 11am, Lincoln Park Shelter B, bring a dish to share']\nMe: we'll be there\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"The flyer gives a concrete date, time, and location for the family picnic.\", \"events\": [{\"title\": \"Spring Family Picnic\", \"start\": \"2026-06-13T11:00:00\", \"end\": \"2026-06-13T14:00:00\", \"location\": \"Lincoln Park, Shelter B\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": \"Bring a dish to share (from flyer)\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added it — we'll bring a salad!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMom: [image: photo of a permission slip — 'Zoo trip Thursday June 11, depart 8:30am, return ~2:30pm, $12 due Mon']\nMe: signing tonight\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Permission slip photo encodes the trip date/time and a separate payment deadline.\", \"events\": [{\"title\": \"Zoo trip\", \"start\": \"2026-06-11T08:30:00\", \"end\": \"2026-06-11T14:30:00\", \"location\": \"Zoo\", \"attendees\": [], \"reminder_minutes\": 720, \"notes\": \"$12 due Monday; from permission slip\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Signed and the $12 is ready for Monday!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTeacher: can a couple of parents help with the class party sometime next week?\nMe: maybe!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A request without a fixed day or time — ask before scheduling anything.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Happy to help — what day and time is the party?\", \"needs_clarification\": \"Which day/time next week is the class party?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach Dana: end-of-season pizza party is the 14th at 6pm at Tony's\nMe: yum, count us in\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'The 14th' resolves to Jun 14; concrete time and venue given.\", \"events\": [{\"title\": \"End-of-season pizza party\", \"start\": \"2026-06-14T18:00:00\", \"end\": \"2026-06-14T20:00:00\", \"location\": \"Tony's\", \"attendees\": [\"Coach Dana\"], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Count us in — see you at Tony's at 6!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTeacher: the spring recital is in two weeks, Friday at 6pm in the gym\nMe: 📅\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Friday two weeks out is Jun 19? The teacher pins it to Friday 6pm — Jun 19.\", \"events\": [{\"title\": \"Spring recital\", \"start\": \"2026-06-19T18:00:00\", \"end\": \"2026-06-19T19:30:00\", \"location\": \"School gym\", \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Can't wait — we'll be there Friday at 6!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool office: reminder, early dismissal this Wednesday at 12:30pm\nMe: thanks for the heads up\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A one-off schedule change parents need to plan pickup around.\", \"events\": [{\"title\": \"Early dismissal\", \"start\": \"2026-06-10T12:30:00\", \"end\": null, \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": \"Pickup at 12:30 instead of usual\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Noted — I'll be there at 12:30 Wednesday.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTeacher: your parent–teacher conference slot is Thursday 4:15pm, ~15 min\nMe: works for me\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A confirmed 15-minute conference slot on Thursday.\", \"events\": [{\"title\": \"Parent–teacher conference\", \"start\": \"2026-06-11T16:15:00\", \"end\": \"2026-06-11T16:30:00\", \"location\": \"Classroom\", \"attendees\": [\"Teacher\"], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"4:15 Thursday works — see you then!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nOther parent: Leo's bday party Sat 2–4pm at Jump Zone, can Mia come?\nMe: she'd love to!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A party invite with a clear window and venue, accepted.\", \"events\": [{\"title\": \"Leo's birthday party\", \"start\": \"2026-06-13T14:00:00\", \"end\": \"2026-06-13T16:00:00\", \"location\": \"Jump Zone\", \"attendees\": [\"Leo\"], \"reminder_minutes\": 180, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"She'd love to — see you Saturday at Jump Zone!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nNeighbor: I've got carpool drop-off Friday at 7:45am this week\nMe: perfect, thank you!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A specific carpool commitment on Friday morning.\", \"events\": [{\"title\": \"Carpool drop-off (neighbor driving)\", \"start\": \"2026-06-12T07:45:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thank you! I've got next Friday.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach Dana: practice this Tue is now 6pm, not 5\nMe: ok updating\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A reschedule: practice Tuesday moves to 6pm.\", \"events\": [{\"title\": \"Soccer practice\", \"start\": \"2026-06-09T18:00:00\", \"end\": \"2026-06-09T19:00:00\", \"location\": null, \"attendees\": [\"Coach Dana\"], \"reminder_minutes\": 60, \"notes\": \"Moved from 5pm to 6pm\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Updated — 6pm Tuesday it is.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPTA: bake sale items drop off Thursday by 8am at the front office\nMe: I'll bring cookies\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A drop-off deadline Thursday morning.\", \"events\": [{\"title\": \"Bake sale drop-off\", \"start\": \"2026-06-11T08:00:00\", \"end\": null, \"location\": \"Front office\", \"attendees\": [], \"reminder_minutes\": 720, \"notes\": \"Bringing cookies\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Cookies will be there by 8 Thursday!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nOther parent: we should set up a playdate soon!\nMe: yes definitely\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Mutual interest but no concrete day, time, or place.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Let's do it — what day works for you two?\", \"needs_clarification\": \"Which day/time and where for the playdate?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMorgan: can we do a 30-min project sync next Monday at 10am?\nMe: that works for me\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Monday' from Monday Jun 8 resolves to Jun 15.\", \"events\": [{\"title\": \"Project sync with Morgan\", \"start\": \"2026-06-15T10:00:00\", \"end\": \"2026-06-15T10:30:00\", \"location\": null, \"attendees\": [\"Morgan\"], \"reminder_minutes\": 15, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Monday the 15th at 10 works — talk soon!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nChris: [image: screenshot of OpenTable confirmation — 'Reservation at Osteria Mia, Sat Jun 13 at 7:30pm, party of 4, confirmation #OT98271']\nMe: perfect!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"The OpenTable screenshot confirms a restaurant reservation Saturday at 7:30pm.\", \"events\": [{\"title\": \"Dinner at Osteria Mia\", \"start\": \"2026-06-13T19:30:00\", \"end\": \"2026-06-13T21:30:00\", \"location\": \"Osteria Mia\", \"attendees\": [\"Chris\"], \"reminder_minutes\": 120, \"notes\": \"Party of 4; confirmation #OT98271\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added it — see you Saturday at 7:30!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSam: thanks so much for watching the dog last weekend!\nMe: any time, she's the best\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A thank-you exchange, nothing to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Anytime — she was a joy!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nManager: sprint review is Tuesday at 3pm, also block 4-4:30 for retro right after\nMe: done\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two back-to-back work meetings on Tuesday: sprint review 3pm and retro 4pm.\", \"events\": [{\"title\": \"Sprint review\", \"start\": \"2026-06-09T15:00:00\", \"end\": \"2026-06-09T16:00:00\", \"location\": null, \"attendees\": [\"Manager\"], \"reminder_minutes\": 15, \"notes\": null}, {\"title\": \"Sprint retro\", \"start\": \"2026-06-09T16:00:00\", \"end\": \"2026-06-09T16:30:00\", \"location\": null, \"attendees\": [\"Manager\"], \"reminder_minutes\": 15, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Blocked both — review at 3, retro at 4.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nExisting calendar:\n- Team lunch: 2026-06-10T12:00..2026-06-10T13:00\n\nDoctor: your 1pm appointment is confirmed for Wednesday\nMe: see you then\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"The 1pm doctor appointment starts immediately after team lunch ends — adjacent.\", \"events\": [{\"title\": \"Doctor appointment\", \"start\": \"2026-06-10T13:00:00\", \"end\": \"2026-06-10T14:00:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [{\"event_index\": 0, \"clashes_with\": \"Team lunch 12:00-1:00pm\", \"severity\": \"adjacent\"}], \"proposed_times\": [\"2026-06-10T14:00:00\"], \"reply_draft\": \"Confirmed — just note it is right after team lunch so I may be a couple minutes late.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T18:00:00\nExisting calendar: (none provided)\n\nConversation:\nZoe: game night at our place tonight, 8pm — you coming?\nMe: wouldn't miss it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Tonight' is Jun 8; explicit time is 8pm.\", \"events\": [{\"title\": \"Game night at Zoe's\", \"start\": \"2026-06-08T20:00:00\", \"end\": \"2026-06-08T22:30:00\", \"location\": \"Zoe's place\", \"attendees\": [\"Zoe\"], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you at 8!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nOlivia: just made that lemon pasta recipe you sent — total hit!\nMe: so glad! it's a staple\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Friendly recipe chat, no scheduling.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Right? It never gets old!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nRoom parent: [image: flyer — 'Swim Club Sign-Ups, Tuesdays & Thursdays 3:30-4:30pm starting Jun 10, Lake Park Pool, $40/month']\nMe: signing Lily up!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Flyer shows a recurring swim club starting Tuesday Jun 10 on Tues/Thurs 3:30-4:30pm.\", \"events\": [{\"title\": \"Swim club — Lily\", \"start\": \"2026-06-10T15:30:00\", \"end\": \"2026-06-10T16:30:00\", \"location\": \"Lake Park Pool\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": \"Recurring Tues & Thurs; $40/month. From flyer.\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Lily's signed up — first session Tuesday the 10th!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nStylist: confirming your haircut Fri Jun 12 at 11am\nSchool: reminder, Fri Jun 12 early release at 1pm\nMe: got both!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two distinct items on Friday: haircut at 11am and early school release at 1pm.\", \"events\": [{\"title\": \"Haircut\", \"start\": \"2026-06-12T11:00:00\", \"end\": \"2026-06-12T12:00:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}, {\"title\": \"Early school release\", \"start\": \"2026-06-12T13:00:00\", \"end\": null, \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": \"Pickup at 1pm instead of usual time\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — haircut at 11, school pickup at 1.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTeam lead: heads up — design review deck due in three days\nMe: will have it ready\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'In three days' from Jun 8 is Jun 11 (Thursday).\", \"events\": [{\"title\": \"Design review deck due\", \"start\": \"2026-06-11T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": \"Deadline: three days from Jun 8\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"It'll be ready by Thursday!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nExisting calendar:\n- All-hands meeting: 2026-06-09T10:00..2026-06-09T11:00\n\nDoctor office: we have a 10:30am slot Tuesday for your annual physical\nMe: I'll take it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"The 10:30am appointment overlaps the 10:00-11:00 all-hands meeting.\", \"events\": [{\"title\": \"Annual physical\", \"start\": \"2026-06-09T10:30:00\", \"end\": \"2026-06-09T11:30:00\", \"location\": \"Doctor office\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [{\"event_index\": 0, \"clashes_with\": \"All-hands meeting 10:00-11:00am\", \"severity\": \"overlap\"}], \"proposed_times\": [\"2026-06-09T14:00:00\", \"2026-06-10T10:30:00\"], \"reply_draft\": \"That slot overlaps my all-hands — could you do 2pm Tuesday or Wednesday morning instead?\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nNina: just checking in — hope you're feeling better!\nMe: much better, thanks for asking\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A wellness check-in, no event to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"All good now, thanks! Really appreciate you checking in.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMarcus: [image: screenshot of flight confirmation — 'AA 412 departs SFO Mon Jun 22 6:00am, arrives JFK 2:30pm; return AA 721 Thu Jun 25 5:45pm JFK -> SFO']\nMe: booked!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Flight confirmation shows two legs: outbound Jun 22 and return Jun 25.\", \"events\": [{\"title\": \"Flight SFO to JFK (AA 412)\", \"start\": \"2026-06-22T06:00:00\", \"end\": \"2026-06-22T14:30:00\", \"location\": \"SFO -> JFK\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": \"AA 412; from flight confirmation screenshot\"}, {\"title\": \"Flight JFK to SFO (AA 721)\", \"start\": \"2026-06-25T17:45:00\", \"end\": \"2026-06-25T21:00:00\", \"location\": \"JFK -> SFO\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": \"AA 721; from flight confirmation screenshot\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Both legs added — safe travels!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDan: hiking trip this Saturday, meet at the trailhead at 7am, bring layers\nMe: I'm in!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'This Saturday' from Monday Jun 8 is Jun 13.\", \"events\": [{\"title\": \"Hiking trip with Dan\", \"start\": \"2026-06-13T07:00:00\", \"end\": \"2026-06-13T13:00:00\", \"location\": \"Trailhead\", \"attendees\": [\"Dan\"], \"reminder_minutes\": 60, \"notes\": \"Bring layers\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you at the trailhead Saturday at 7!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPriya: we should catch up soon, it's been forever\nMe: yes! let's make it happen\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Mutual intent to meet but no date, time, or location mentioned.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"So overdue! What does your week look like?\", \"needs_clarification\": \"When and where works for a catch-up?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCarpool group: I can drive the Tuesday 3:15 pickup if someone takes Thursday\nMe: I'll grab Thursday 3:15\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"I volunteered for the Thursday 3:15pm school pickup (Jun 11).\", \"events\": [{\"title\": \"Carpool — Thursday school pickup\", \"start\": \"2026-06-11T15:15:00\", \"end\": \"2026-06-11T15:45:00\", \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 30, \"notes\": \"My week to drive Thursday\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got Thursday's 3:15 pickup covered!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach Dana: carpool to Saturday's away game leaves the school lot at 8am sharp\nMe: we'll be there\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Carpool to the away game departs Saturday Jun 13 at 8am from the school lot.\", \"events\": [{\"title\": \"Carpool to away game\", \"start\": \"2026-06-13T08:00:00\", \"end\": null, \"location\": \"School lot\", \"attendees\": [\"Coach Dana\"], \"reminder_minutes\": 45, \"notes\": \"Leaves 8am sharp\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"We'll be at the lot by 8 — thanks for organizing!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDr. Lee's office: reminder, Maya's dental cleaning is Wed Jun 17 at 4:00pm\nMe: thanks, we'll be there\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Dental cleaning confirmed for Wednesday Jun 17 at 4pm.\", \"events\": [{\"title\": \"Maya — dental cleaning\", \"start\": \"2026-06-17T16:00:00\", \"end\": \"2026-06-17T17:00:00\", \"location\": \"Dr. Lee's office\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you Wednesday at 4!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nVet: Bella's vaccination appt is booked for Fri Jun 19, 10:30am\nMe: perfect\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Vet vaccination appointment is Friday Jun 19 at 10:30am.\", \"events\": [{\"title\": \"Bella — vet vaccination\", \"start\": \"2026-06-19T10:30:00\", \"end\": \"2026-06-19T11:00:00\", \"location\": \"Vet\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Booked — thanks!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nClinic: Sam's check-up is next Thursday at 9:15am\nMe: see you then\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Thursday' from Monday Jun 8 is Jun 18.\", \"events\": [{\"title\": \"Sam — check-up\", \"start\": \"2026-06-18T09:15:00\", \"end\": \"2026-06-18T10:00:00\", \"location\": \"Clinic\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — Thursday the 18th at 9:15.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMe: [image: appointment card — 'Orthodontist, Dr. Patel, Tue Jun 16, 8:00am']\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Appointment card photo shows an orthodontist visit Tuesday Jun 16 at 8am.\", \"events\": [{\"title\": \"Orthodontist — Dr. Patel\", \"start\": \"2026-06-16T08:00:00\", \"end\": \"2026-06-16T08:45:00\", \"location\": \"Dr. Patel\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": \"From appointment card\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added the orthodontist appointment for Tuesday at 8.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nJenna: Leo's 7th birthday is Sat Jun 20, 2pm at Pump It Up — please RSVP by Thursday!\nMe: we're in!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Birthday party Saturday Jun 20 2pm; RSVP requested by Thursday Jun 18.\", \"events\": [{\"title\": \"Leo's 7th birthday party\", \"start\": \"2026-06-20T14:00:00\", \"end\": \"2026-06-20T16:00:00\", \"location\": \"Pump It Up\", \"attendees\": [\"Jenna\"], \"reminder_minutes\": 120, \"notes\": \"RSVP by Thursday Jun 18\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"We'll be there — count us in, thanks for the invite!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSara: [image: birthday invitation — \"You're invited! Emma turns 5 — Sun Jun 14, 3-5pm, 12 Oak St\"]\nMe: aww, we'll come\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Invitation card shows Emma's birthday Sunday Jun 14, 3-5pm at 12 Oak St.\", \"events\": [{\"title\": \"Emma's 5th birthday party\", \"start\": \"2026-06-14T15:00:00\", \"end\": \"2026-06-14T17:00:00\", \"location\": \"12 Oak St\", \"attendees\": [\"Sara\"], \"reminder_minutes\": 120, \"notes\": \"From invitation card\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"We'll be there Sunday — can't wait!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nExisting calendar:\n- Piano lesson: 2026-06-09T17:00..2026-06-09T17:45\n\nCoach: practice moves to Tuesday 5:30-6:30 this week\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Practice now Tuesday 5:30-6:30pm overlaps the 5:00-5:45 piano lesson.\", \"events\": [{\"title\": \"Soccer practice\", \"start\": \"2026-06-09T17:30:00\", \"end\": \"2026-06-09T18:30:00\", \"location\": null, \"attendees\": [\"Coach\"], \"reminder_minutes\": 30, \"notes\": \"Rescheduled from usual time\"}], \"conflicts\": [{\"event_index\": 0, \"clashes_with\": \"Piano lesson 5:00-5:45pm\", \"severity\": \"overlap\"}], \"proposed_times\": [], \"reply_draft\": \"Noted — heads up it overlaps piano, so we may be a few minutes late.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: Saturday's game is at 11am, arrive 30 minutes early at Field 4\nMe: got it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Game Saturday Jun 13 at 11am at Field 4; arrive by 10:30.\", \"events\": [{\"title\": \"Soccer game\", \"start\": \"2026-06-13T11:00:00\", \"end\": \"2026-06-13T12:30:00\", \"location\": \"Field 4\", \"attendees\": [\"Coach\"], \"reminder_minutes\": 60, \"notes\": \"Arrive 30 min early (10:30)\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"We'll be at Field 4 by 10:30!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nRoom parent: Picture day is Thursday — photos at 9am, kids wear the green class shirt\nMe: thanks!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"School picture day Thursday Jun 11 at 9am; wear green shirt.\", \"events\": [{\"title\": \"School picture day\", \"start\": \"2026-06-11T09:00:00\", \"end\": null, \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 720, \"notes\": \"Wear green class shirt\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — green shirt Thursday!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTeacher: your parent-teacher conference is Wed Jun 17 at 3:45pm\nMe: confirmed\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Parent-teacher conference Wednesday Jun 17 at 3:45pm.\", \"events\": [{\"title\": \"Parent-teacher conference\", \"start\": \"2026-06-17T15:45:00\", \"end\": \"2026-06-17T16:15:00\", \"location\": \"School\", \"attendees\": [\"Teacher\"], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Confirmed for Wednesday at 3:45.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: permission slip for the aquarium field trip (Jun 24) is due this Friday\nMe: will send it in\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two items: the slip is due Friday Jun 12, and the field trip is Jun 24.\", \"events\": [{\"title\": \"Permission slip due — aquarium trip\", \"start\": \"2026-06-12T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": \"Due Friday\"}, {\"title\": \"Aquarium field trip\", \"start\": \"2026-06-24T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 720, \"notes\": \"School field trip\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Slip going in Friday — trip's on the 24th, noted!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPTA: [image: newsletter — 'Spring concert Thu Jun 18 at 6:30pm in the gym']\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Newsletter screenshot announces the spring concert Thursday Jun 18 at 6:30pm.\", \"events\": [{\"title\": \"Spring concert\", \"start\": \"2026-06-18T18:30:00\", \"end\": \"2026-06-18T20:00:00\", \"location\": \"School gym\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": \"From PTA newsletter\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added the spring concert — Thursday at 6:30!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMaria: book club meets tomorrow at 7pm at my place\nMe: see you there\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Tomorrow' from Jun 8 is Jun 9; book club at 7pm.\", \"events\": [{\"title\": \"Book club at Maria's\", \"start\": \"2026-06-09T19:00:00\", \"end\": \"2026-06-09T20:30:00\", \"location\": \"Maria's place\", \"attendees\": [\"Maria\"], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you tomorrow at 7!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nLandlord: friendly reminder, rent is due by the end of the month\nMe: noted, thanks\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'End of the month' is Jun 30; rent deadline.\", \"events\": [{\"title\": \"Rent due\", \"start\": \"2026-06-30T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": \"Due by end of month\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks — I'll have it in by the 30th.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nAlex: let's grab dinner a week from Friday, 7pm?\nMe: works!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Friday is Jun 12; a week from Friday is Jun 19.\", \"events\": [{\"title\": \"Dinner with Alex\", \"start\": \"2026-06-19T19:00:00\", \"end\": \"2026-06-19T21:00:00\", \"location\": null, \"attendees\": [\"Alex\"], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Friday the 19th at 7 — perfect!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nHR: reminder that open enrollment closes the 25th\nMe: thanks for the heads up\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'The 25th' is Jun 25; benefits deadline.\", \"events\": [{\"title\": \"Open enrollment closes\", \"start\": \"2026-06-25T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": \"Benefits deadline\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks — I'll finish enrollment before the 25th.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nExisting calendar:\n- Dentist: 2026-06-09T09:00..2026-06-09T09:45\n\nVendor: can we do coffee tomorrow at 9:55?\nMe: sure\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Coffee at 9:55 leaves only 10 minutes after the 9:45 dentist finish — tight.\", \"events\": [{\"title\": \"Coffee with vendor\", \"start\": \"2026-06-09T09:55:00\", \"end\": \"2026-06-09T10:30:00\", \"location\": null, \"attendees\": [\"Vendor\"], \"reminder_minutes\": 15, \"notes\": null}], \"conflicts\": [{\"event_index\": 0, \"clashes_with\": \"Dentist 9:00-9:45am\", \"severity\": \"tight\"}], \"proposed_times\": [\"2026-06-09T11:00:00\"], \"reply_draft\": \"9:55 is tight right after the dentist — could we make it 11 to be safe?\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T17:00:00\nExisting calendar: (none provided)\n\nConversation:\nExisting calendar:\n- Yoga: 2026-06-08T18:00..2026-06-08T19:00\n\nFriend: dinner tonight at 6:30?\nMe: let me check\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Dinner at 6:30 overlaps the 6-7pm yoga class.\", \"events\": [{\"title\": \"Dinner with friend\", \"start\": \"2026-06-08T18:30:00\", \"end\": \"2026-06-08T20:00:00\", \"location\": null, \"attendees\": [\"Friend\"], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [{\"event_index\": 0, \"clashes_with\": \"Yoga 6:00-7:00pm\", \"severity\": \"overlap\"}], \"proposed_times\": [\"2026-06-08T19:30:00\"], \"reply_draft\": \"6:30 clashes with my yoga — could we push to 7:30?\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSpouse: Lily has ballet Wed 4pm and Noah has karate Wed 5:30pm\nMe: adding both\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two kids' activities Wednesday Jun 10: ballet 4pm and karate 5:30pm.\", \"events\": [{\"title\": \"Lily — ballet\", \"start\": \"2026-06-10T16:00:00\", \"end\": \"2026-06-10T17:00:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}, {\"title\": \"Noah — karate\", \"start\": \"2026-06-10T17:30:00\", \"end\": \"2026-06-10T18:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Both on the calendar — ballet at 4, karate at 5:30.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nFamily chat: Saturday plan — farmers market 9am, Mia's recital 2pm, dinner at grandma's 6pm\nMe: busy day!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Three Saturday Jun 13 events: market 9am, recital 2pm, dinner 6pm.\", \"events\": [{\"title\": \"Farmers market\", \"start\": \"2026-06-13T09:00:00\", \"end\": \"2026-06-13T10:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}, {\"title\": \"Mia's recital\", \"start\": \"2026-06-13T14:00:00\", \"end\": \"2026-06-13T15:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}, {\"title\": \"Dinner at grandma's\", \"start\": \"2026-06-13T18:00:00\", \"end\": \"2026-06-13T20:00:00\", \"location\": \"Grandma's\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added all three — market, recital, then dinner. Busy day indeed!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nBen: did you catch the game last night? what a finish\nMe: unreal, still buzzing\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Sports chat, nothing to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Best finish all season — still can't believe it!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nAunt Carol: happy Monday! hope the week's off to a good start\nMe: you too, thanks!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A friendly greeting, no event.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Appreciate it — hope yours is great too!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nNeighbor: thanks again for the plumber recommendation, he was great\nMe: so glad it worked out\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A thank-you exchange, nothing to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Happy to help — he's saved us a few times!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: extra practice this week, I'll confirm the day soon\nMe: ok, keep me posted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"An extra practice is implied but no day or time is given yet.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Sounds good — just let me know the day and time.\", \"needs_clarification\": \"Which day and time is the extra practice?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoworker: lunch sometime next week?\nMe: yes, definitely!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Lunch intent for 'next week' but no specific day or time.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"I'm in — what day next week works for you?\", \"needs_clarification\": \"What day and time next week works for lunch?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCamp: drop-off is 8:30am daily, Jun 22-26, at Riverside Center\nMe: thanks!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Summer camp drop-off 8:30am each weekday Jun 22-26; anchor the first day.\", \"events\": [{\"title\": \"Summer camp drop-off\", \"start\": \"2026-06-22T08:30:00\", \"end\": null, \"location\": \"Riverside Center\", \"attendees\": [], \"reminder_minutes\": 30, \"notes\": \"Daily Jun 22-26\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — drop-off at 8:30 starting Monday the 22nd.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-06-08T16:00:00\nExisting calendar: (none provided)\n\nConversation:\nPharmacy: your prescription is ready for pickup; we close at 8pm today\nMe: I'll swing by after work\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Prescription pickup today before the 8pm close; plan an after-work pickup.\", \"events\": [{\"title\": \"Pick up prescription\", \"start\": \"2026-06-08T18:00:00\", \"end\": null, \"location\": \"Pharmacy\", \"attendees\": [], \"reminder_minutes\": 30, \"notes\": \"Pharmacy closes at 8pm\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks — I'll grab it on my way home.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDr. Reyes: your annual physical is November 10 at 2:45pm\nMe: thanks\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Physical on Nov 10, 2026 at 2:45pm.\", \"events\": [{\"title\": \"Annual physical\", \"start\": \"2026-11-10T14:45:00\", \"end\": null, \"location\": \"Dr. Reyes\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you November 10 at 2:45.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nStudio: pottery class starts October 13 at 6pm\nMe: signed up!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Pottery class begins Oct 13, 2026 at 6pm.\", \"events\": [{\"title\": \"Pottery class\", \"start\": \"2026-10-13T18:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Can't wait — see you the 13th!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: playoffs are November 14 at 10am at Field 3\nMe: we'll be there\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Playoff game Nov 14, 2026 at 10am.\", \"events\": [{\"title\": \"Playoffs\", \"start\": \"2026-11-14T10:00:00\", \"end\": null, \"location\": \"Field 3\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you at Field 3 on the 14th!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nHR: open enrollment briefing is November 3 at 1pm\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Briefing Nov 3, 2026 at 1pm.\", \"events\": [{\"title\": \"Open enrollment briefing\", \"start\": \"2026-11-03T13:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — November 3 at 1.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nVet: Cooper's dental cleaning is October 20 at 11:45am\nMe: perfect\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Vet dental Oct 20, 2026 at 11:45am.\", \"events\": [{\"title\": \"Cooper — dental cleaning\", \"start\": \"2026-10-20T11:45:00\", \"end\": null, \"location\": \"Vet\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Booked — the 20th at 11:45.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPTA: fall festival is October 24, 5 to 8pm at the school\nMe: we'll come\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Festival Oct 24, 2026, 5-8pm at school.\", \"events\": [{\"title\": \"Fall festival\", \"start\": \"2026-10-24T17:00:00\", \"end\": \"2026-10-24T20:00:00\", \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you at the festival on the 24th!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCivic Center: the holiday recital is December 12 at 2pm\nMe: adding it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Recital Dec 12, 2026 at 2pm.\", \"events\": [{\"title\": \"Holiday recital\", \"start\": \"2026-12-12T14:00:00\", \"end\": null, \"location\": \"Civic Center\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added — December 12 at 2.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nManager: Q4 planning offsite is December 1 at 9am\nMe: blocked\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Offsite Dec 1, 2026 at 9am.\", \"events\": [{\"title\": \"Q4 planning offsite\", \"start\": \"2026-12-01T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Blocked December 1 at 9.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: parent conferences are November 5 at 4:15pm\nMe: confirmed\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Conference Nov 5, 2026 at 4:15pm.\", \"events\": [{\"title\": \"Parent conference\", \"start\": \"2026-11-05T16:15:00\", \"end\": null, \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Confirmed for the 5th at 4:15.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: the team banquet is November 21 at 6pm at Rosa's\nMe: count us in\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Banquet Nov 21, 2026 at 6pm.\", \"events\": [{\"title\": \"Team banquet\", \"start\": \"2026-11-21T18:00:00\", \"end\": null, \"location\": \"Rosa's\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"We'll be at Rosa's on the 21st!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nClinic: your lab follow-up is October 29 at 10:30am\nMe: see you\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Follow-up Oct 29, 2026 at 10:30am.\", \"events\": [{\"title\": \"Lab follow-up\", \"start\": \"2026-10-29T10:30:00\", \"end\": null, \"location\": \"Clinic\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you the 29th at 10:30.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDance: the winter showcase is December 5 at 3:30pm\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Showcase Dec 5, 2026 at 3:30pm.\", \"events\": [{\"title\": \"Winter showcase\", \"start\": \"2026-12-05T15:30:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added the showcase — Dec 5 at 3:30.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nLibrary: there's an author talk November 18 at 7pm\nMe: I'll go\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Author talk Nov 18, 2026 at 7pm.\", \"events\": [{\"title\": \"Author talk\", \"start\": \"2026-11-18T19:00:00\", \"end\": null, \"location\": \"Library\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you at the library on the 18th!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nOrthodontist: next adjustment is October 27 at 8:00am\nMe: got it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Adjustment Oct 27, 2026 at 8am.\", \"events\": [{\"title\": \"Orthodontist adjustment\", \"start\": \"2026-10-27T08:00:00\", \"end\": null, \"location\": \"Orthodontist\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — the 27th at 8.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSpouse: October 9 — Mia has the orthodontist at 9am and her piano recital at 5pm\nMe: adding both\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two events Oct 9, 2026: 9am and 5pm.\", \"events\": [{\"title\": \"Mia — orthodontist\", \"start\": \"2026-10-09T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}, {\"title\": \"Mia — piano recital\", \"start\": \"2026-10-09T17:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Both added — orthodontist 9, recital 5.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nOffice: November 12 — sprint review at 11am and retro at 3pm\nMe: blocked both\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two meetings Nov 12, 2026: 11am and 3pm.\", \"events\": [{\"title\": \"Sprint review\", \"start\": \"2026-11-12T11:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 15, \"notes\": null}, {\"title\": \"Sprint retro\", \"start\": \"2026-11-12T15:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 15, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Blocked both — review 11, retro 3.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSalon: your color appointment is November 7 at 1:45pm\nMe: thanks\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Salon Nov 7, 2026 at 1:45pm.\", \"events\": [{\"title\": \"Salon — color appointment\", \"start\": \"2026-11-07T13:45:00\", \"end\": null, \"location\": \"Salon\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you the 7th at 1:45.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: early dismissal is November 25 at 12:30pm\nMe: got it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Early dismissal Nov 25, 2026 at 12:30pm.\", \"events\": [{\"title\": \"Early dismissal\", \"start\": \"2026-11-25T12:30:00\", \"end\": null, \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": \"Pickup at 12:30 instead of usual time\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — pickup at 12:30 on the 25th.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: practice is moved from Tuesday to Thursday, still 5pm\nMe: ok\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Practice moved to Thu Sep 17, 2026 at 5pm.\", \"events\": [{\"title\": \"Soccer practice\", \"start\": \"2026-09-17T17:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": \"Moved from Tuesday\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — Thursday at 5.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDr. office: we need to move your Friday appointment to Monday Sept 21 at 10am\nMe: that works\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Appointment moved to Mon Sep 21, 2026 at 10am.\", \"events\": [{\"title\": \"Doctor appointment\", \"start\": \"2026-09-21T10:00:00\", \"end\": null, \"location\": \"Dr. office\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Monday the 21st at 10 works.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: tomorrow's assembly is cancelled\nMe: thanks for letting me know\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"An assembly was cancelled — nothing to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks for the heads up!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: this Saturday's game is called off due to weather\nMe: ok, stay dry\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Game cancelled — nothing to add.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"No problem — see everyone next time!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nStudio: yoga is every Monday at 6pm starting next week\nMe: signing up\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Weekly yoga starts Mon Sep 21, 2026 at 6pm.\", \"events\": [{\"title\": \"Yoga\", \"start\": \"2026-09-21T18:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": \"Recurring weekly on Mondays\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you Monday at 6!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: chess club meets Wednesdays 3:30 to 4:30, starting September 16\nMe: great\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Weekly chess club from Wed Sep 16, 2026, 3:30-4:30pm.\", \"events\": [{\"title\": \"Chess club\", \"start\": \"2026-09-16T15:30:00\", \"end\": \"2026-09-16T16:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": \"Recurring Wednesdays\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added — Wednesdays at 3:30.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: no school on Sept 25 (teacher in-service)\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"All-day no-school on Sep 25, 2026.\", \"events\": [{\"title\": \"No school (teacher in-service)\", \"start\": \"2026-09-25T00:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": null, \"notes\": \"All day\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Noted — no school the 25th.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nLibrary: the book sale is the 20th, all day\nMe: I'll stop by\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"All-day book sale on Sep 20, 2026.\", \"events\": [{\"title\": \"Library book sale\", \"start\": \"2026-09-20T00:00:00\", \"end\": null, \"location\": \"Library\", \"attendees\": [], \"reminder_minutes\": null, \"notes\": \"All day\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"I'll swing by on the 20th!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDentist: see you 10/15 at 2pm\nMe: confirmed\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'10/15' = Oct 15, 2026 at 2pm.\", \"events\": [{\"title\": \"Dentist\", \"start\": \"2026-10-15T14:00:00\", \"end\": null, \"location\": \"Dentist\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Confirmed — 10/15 at 2.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nJordan: party on the 3rd, 7pm at my place\nMe: I'll be there\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Next '3rd' from Sep 14 is Oct 3, 2026 at 7pm.\", \"events\": [{\"title\": \"Party at Jordan's\", \"start\": \"2026-10-03T19:00:00\", \"end\": null, \"location\": \"Jordan's place\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you on the 3rd at 7!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMom: lunch this Saturday at noon\nMe: yes!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"This Saturday = Sep 19, 2026, noon.\", \"events\": [{\"title\": \"Lunch with Mom\", \"start\": \"2026-09-19T12:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Saturday noon — see you!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: quick call at half past 7 tonight\nMe: ok\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Half past 7 tonight' = Sep 14, 2026 at 7:30pm.\", \"events\": [{\"title\": \"Call with coach\", \"start\": \"2026-09-14T19:30:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 15, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Talk at 7:30!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nClinic: your appointment is at noon on Friday\nMe: see you\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Noon Friday = Sep 18, 2026 at 12:00.\", \"events\": [{\"title\": \"Clinic appointment\", \"start\": \"2026-09-18T12:00:00\", \"end\": null, \"location\": \"Clinic\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you Friday at noon.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTeam: standup at 0900 tomorrow\nMe: got it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'0900 tomorrow' = Sep 15, 2026 at 9am.\", \"events\": [{\"title\": \"Standup\", \"start\": \"2026-09-15T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 15, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you at 9.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTrainer: session Thursday 6 to 7:30am\nMe: booked\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Thu Sep 17, 2026, 6-7:30am.\", \"events\": [{\"title\": \"Training session\", \"start\": \"2026-09-17T06:00:00\", \"end\": \"2026-09-17T07:30:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Booked — Thursday 6am.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nOffice: 2-hour workshop Monday at 1pm\nMe: blocked\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"2-hour workshop Mon Sep 21, 2026, 1-3pm.\", \"events\": [{\"title\": \"Workshop\", \"start\": \"2026-09-21T13:00:00\", \"end\": \"2026-09-21T15:00:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Blocked 1-3 Monday.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nProf: essay due Friday at midnight\nMe: on it\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Friday midnight deadline = Sep 18, 2026 23:59.\", \"events\": [{\"title\": \"Essay due\", \"start\": \"2026-09-18T23:59:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"It'll be in by Friday night!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nAccountant: I need the tax docs by the 30th\nMe: will send\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Deadline the 30th = Sep 30, 2026.\", \"events\": [{\"title\": \"Tax docs due\", \"start\": \"2026-09-30T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 1440, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Sending the docs before the 30th.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: there's a game next week, time TBD\nMe: keep me posted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Game intended but no day/time.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Sounds good — let me know the details.\", \"needs_clarification\": \"What day and time is next week's game?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nRiley: movie this weekend?\nMe: yes!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"No specific day/time given.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"I'm in — what works for you?\", \"needs_clarification\": \"Which day and time this weekend for the movie?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSitter: I can do Friday or Saturday — which do you need?\nMe: let me check the calendar\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Two options, none chosen.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Let me confirm and get back to you.\", \"needs_clarification\": \"Do you need the sitter Friday or Saturday, and what time?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nBoss: we should sync soon on the roadmap\nMe: agreed\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Intent to meet, no time.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Agreed — when works for you?\", \"needs_clarification\": \"What day and time works to sync on the roadmap?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDad: traffic was brutal on the commute today\nMe: ugh, same\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Commute venting — nothing to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Right? Hope tomorrow's better.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nNeighbor: your package was delivered to my door, grab it anytime\nMe: thanks!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A package note, no calendar event.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thank you — I'll pop over!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMom: the recital last night was just wonderful\nMe: wasn't it!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Past event being discussed; nothing to add.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"It really was — so proud!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach: great game yesterday, team!\nMe: thanks coach\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Past game; nothing to schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks, Coach — proud of the team!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nWes: we should maybe plan a trip sometime\nMe: for sure, one day\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Vague hypothetical, no commitment.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Love that idea — want to pick some dates?\", \"needs_clarification\": \"Do you want to choose dates for the trip?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMike: [image: concert ticket — 'The Lumineers, Sat Oct 17, 8pm, Red Rocks']\nMe: so hyped\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Ticket shows Sat Oct 17, 2026 8pm at Red Rocks.\", \"events\": [{\"title\": \"The Lumineers concert\", \"start\": \"2026-10-17T20:00:00\", \"end\": null, \"location\": \"Red Rocks\", \"attendees\": [], \"reminder_minutes\": 180, \"notes\": \"From ticket\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Can't wait — Oct 17!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nHR: [image: invite screenshot — 'Town Hall, Thu Sept 24, 12-1pm']\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Invite: Thu Sep 24, 2026, 12-1pm.\", \"events\": [{\"title\": \"Town Hall\", \"start\": \"2026-09-24T12:00:00\", \"end\": \"2026-09-24T13:00:00\", \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": \"From invite\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Added the Town Hall.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: [image: notice — 'Report card pickup Tuesday Sept 22, 3-6pm']\nMe: will come\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Notice: Tue Sep 22, 2026, 3-6pm.\", \"events\": [{\"title\": \"Report card pickup\", \"start\": \"2026-09-22T15:00:00\", \"end\": \"2026-09-22T18:00:00\", \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": \"From notice\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"I'll come by Tuesday.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSpouse: busy Thursday — Leo dentist 8am, my haircut 1pm, dinner with the Patels 7pm\nMe: adding all\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Three events Thu Sep 17, 2026: 8am, 1pm, 7pm.\", \"events\": [{\"title\": \"Leo — dentist\", \"start\": \"2026-09-17T08:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}, {\"title\": \"Haircut\", \"start\": \"2026-09-17T13:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}, {\"title\": \"Dinner with the Patels\", \"start\": \"2026-09-17T19:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"All three added for Thursday!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nOffice: Friday — interviews at 10am and 2pm, then happy hour at 5pm\nMe: blocked it all\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Three events Fri Sep 18, 2026: 10am, 2pm, 5pm.\", \"events\": [{\"title\": \"Interview\", \"start\": \"2026-09-18T10:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 15, \"notes\": null}, {\"title\": \"Interview\", \"start\": \"2026-09-18T14:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 15, \"notes\": null}, {\"title\": \"Happy hour\", \"start\": \"2026-09-18T17:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Blocked all three Friday.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDentist: confirming your appointment is all set for tomorrow at 3\nMe: thanks\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Confirmed tomorrow Sep 15, 2026 at 3pm.\", \"events\": [{\"title\": \"Dentist appointment\", \"start\": \"2026-09-15T15:00:00\", \"end\": null, \"location\": \"Dentist\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks — see you at 3 tomorrow.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPharmacy: flu shots walk-in this Saturday 9am to 4pm\nMe: I'll come early\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Walk-in Sat Sep 19, 2026, 9am-4pm.\", \"events\": [{\"title\": \"Flu shot (walk-in)\", \"start\": \"2026-09-19T09:00:00\", \"end\": \"2026-09-19T16:00:00\", \"location\": \"Pharmacy\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": \"Walk-in window\"}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"I'll come Saturday morning!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan.\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nWork: the deadline for expense reports is end of day Wednesday\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"EOD Wednesday = Sep 16, 2026, ~5pm.\", \"events\": [{\"title\": \"Expense reports due\", \"start\": \"2026-09-16T17:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"I'll submit by Wednesday EOD.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nBand director: we're adding an extra rehearsal, date still TBD\nMe: ok, keep me posted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A rehearsal is planned but its date is TBD — ask, don't schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Sounds good — just tell me the date when it's set.\", \"needs_clarification\": \"What date and time is the extra rehearsal?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nLia: dinner party at ours soon — date TBD, will text you details\nMe: sounds fun!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Party intent but the date is TBD.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Can't wait — let me know when you pick a date!\", \"needs_clarification\": \"What date and time is the dinner party?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nPM: kickoff call happening sometime next week, time still TBD\nMe: noted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Kickoff is next week but unconfirmed — clarify.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Noted — send the invite once the time is set.\", \"needs_clarification\": \"What day and time next week is the kickoff call?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDentist: your cleaning moved to next Wednesday at 2pm\nMe: ok\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Wednesday' from Monday Sep 14 is Sep 23 (next week), not Sep 16.\", \"events\": [{\"title\": \"Dental cleaning\", \"start\": \"2026-09-23T14:00:00\", \"end\": null, \"location\": \"Dentist\", \"attendees\": [], \"reminder_minutes\": 120, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Got it — Wednesday the 23rd at 2.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nGym: your fitness assessment is next Friday at 7am\nMe: see you then\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Friday' from Monday Sep 14 is Sep 25 (next week), not Sep 18.\", \"events\": [{\"title\": \"Fitness assessment\", \"start\": \"2026-09-25T07:00:00\", \"end\": null, \"location\": \"Gym\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"See you Friday the 25th at 7.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nTrainer: program review next Monday at 8am\nMe: works for me\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Monday' from Monday Sep 14 is Sep 21.\", \"events\": [{\"title\": \"Program review\", \"start\": \"2026-09-21T08:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Monday the 21st at 8 — works.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nMara: brunch next Saturday at 10:30?\nMe: in!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Saturday' from Monday Sep 14 is Sep 26 (next week), not Sep 19.\", \"events\": [{\"title\": \"Brunch with Mara\", \"start\": \"2026-09-26T10:30:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Saturday the 26th at 10:30 — see you!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nCoach Kim: we may add a scrimmage this week, I'll let you know the day\nMe: keep me posted\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A scrimmage is planned but the day is unannounced — ask, don't schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Will do — just send the day when you have it.\", \"needs_clarification\": \"Which day is the scrimmage?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool office: there's a schedule change coming next week, details to follow\nMe: ok thanks\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A change is announced with no specifics — clarify.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thanks — I'll watch for the details.\", \"needs_clarification\": \"What is changing in next week's schedule, and when?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nDr. Shah's office: we need to reschedule your appointment, we'll call with options\nMe: ok\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Reschedule pending with no new time — ask, don't schedule.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Sounds good — I'll wait for the options.\", \"needs_clarification\": \"What new date and time options work for the appointment?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nManager: planning a team offsite later this month, date not locked yet\nMe: sounds good\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Offsite intent without a date — clarify.\", \"events\": [], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Great — let me know once the date is locked.\", \"needs_clarification\": \"What date is the team offsite?\"}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nKarate studio: the belt test moved to next Tuesday at 4pm\nMe: he'll be ready\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Tuesday' from Monday Sep 14 is Sep 22 (next week), not Sep 15.\", \"events\": [{\"title\": \"Belt test\", \"start\": \"2026-09-22T16:00:00\", \"end\": null, \"location\": \"Karate studio\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Tuesday the 22nd at 4 — he'll be ready!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nHana: coffee next Thursday at 9?\nMe: yes!\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Thursday' from Monday Sep 14 is Sep 24 (next week), not Sep 17.\", \"events\": [{\"title\": \"Coffee with Hana\", \"start\": \"2026-09-24T09:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Thursday the 24th at 9 — see you!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nLandlord: maintenance visit next Wednesday at 10am\nMe: I'll be home\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"'Next Wednesday' from Monday Sep 14 is Sep 23 (next week), not Sep 16.\", \"events\": [{\"title\": \"Maintenance visit\", \"start\": \"2026-09-23T10:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"I'll be home Wednesday the 23rd at 10.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nBox office: ticket sales open Friday Sept 25 at 10am\nMe: ready to pounce\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"A sales-open time is a schedulable event: Fri Sep 25, 2026 at 10am.\", \"events\": [{\"title\": \"Ticket sales open\", \"start\": \"2026-09-25T10:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 30, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Alarm set for the 25th at 10!\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nSchool: kindergarten enrollment opens September 21 at 8am\nMe: calendar'd\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Enrollment opening Sep 21, 2026 at 8am is an event, not chitchat.\", \"events\": [{\"title\": \"Kindergarten enrollment opens\", \"start\": \"2026-09-21T08:00:00\", \"end\": null, \"location\": \"School\", \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"On the calendar — the 21st at 8.\", \"needs_clarification\": null}"}]} +{"messages": [{"role": "system", "content": "You are a scheduling assistant reading a chat conversation (text, and sometimes images such as screenshots, invites, or flyers). Decide what calendar action is warranted and return ONLY a JSON object matching the ActionPlan schema:\n- reasoning: one or two sentences of why.\n- events: concrete events with ISO 8601 datetimes; resolve relative dates from the current datetime. Empty if there is no real plan. List EVERY distinct event separately — one thread often holds several (e.g. a drop-off AND a pickup, or two appointments, are separate events).\n- conflicts: for any event that clashes with the provided existing calendar, the event_index, what it clashes with, and severity (overlap|adjacent|tight).\n- proposed_times: ISO 8601 alternatives when there is a conflict.\n- reply_draft: a short, natural reply the user could send back.\n- needs_clarification: a question if the plan is ambiguous, else null. If something should be scheduled but its day or time is not yet known (\"TBD\", \"I'll confirm\", \"sometime soon\"), leave events empty and ASK via needs_clarification instead of guessing.\nDo not invent events that were not discussed."}, {"role": "user", "content": "Current datetime: Monday, 2026-09-14T09:00:00\nExisting calendar: (none provided)\n\nConversation:\nFarmers co-op: winter CSA signups open October 1 at noon\nMe: in for a share\n\nReturn the ActionPlan JSON now."}, {"role": "assistant", "content": "{\"reasoning\": \"Signups opening Oct 1, 2026 at 12pm — schedule it.\", \"events\": [{\"title\": \"CSA signups open\", \"start\": \"2026-10-01T12:00:00\", \"end\": null, \"location\": null, \"attendees\": [], \"reminder_minutes\": 60, \"notes\": null}], \"conflicts\": [], \"proposed_times\": [], \"reply_draft\": \"Signing up Oct 1 at noon!\", \"needs_clarification\": null}"}]} diff --git a/training/data/eval.jsonl b/training/data/eval.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..a13c022a02ad468c99298c2967735e54d5776c48 --- /dev/null +++ b/training/data/eval.jsonl @@ -0,0 +1,60 @@ +{"id": "e01", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Teacher: Back-to-school night is Wednesday September 16 at 6:30pm in the gym\nMe: we'll be there", "gold": {"reasoning": null, "events": [{"title": "Back-to-school night", "start": "2026-09-16T18:30:00", "end": null, "location": "School gym", "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you Wednesday at 6:30!", "needs_clarification": null}} +{"id": "e02", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Dr. Okafor's office: Liam's cleaning is set for Tuesday October 6 at 3:30pm\nMe: thanks", "gold": {"reasoning": null, "events": [{"title": "Liam — dental cleaning", "start": "2026-10-06T15:30:00", "end": null, "location": "Dr. Okafor's office", "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Got it — Tuesday the 6th at 3:30.", "needs_clarification": null}} +{"id": "e03", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Sam: dinner Friday September 25, 7-9pm at Trattoria Verde?\nMe: yes!", "gold": {"reasoning": null, "events": [{"title": "Dinner with Sam", "start": "2026-09-25T19:00:00", "end": "2026-09-25T21:00:00", "location": "Trattoria Verde", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Friday at 7 — can't wait!", "needs_clarification": null}} +{"id": "e04", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Coach: makeup game is Sunday Sept 27 at 1pm at Field 2\nMe: got it", "gold": {"reasoning": null, "events": [{"title": "Makeup game", "start": "2026-09-27T13:00:00", "end": null, "location": "Field 2", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "We'll be at Field 2 by 1!", "needs_clarification": null}} +{"id": "e05", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Clinic: flu shot clinic Thursday October 1 at 8:15am\nMe: see you", "gold": {"reasoning": null, "events": [{"title": "Flu shot", "start": "2026-10-01T08:15:00", "end": null, "location": "Clinic", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you Thursday at 8:15.", "needs_clarification": null}} +{"id": "e06", "category": "events", "now": "2026-09-14T09:00:00", "thread": "PTA: book fair opens Monday Sept 28 at 9am\nMe: noted", "gold": {"reasoning": null, "events": [{"title": "Book fair", "start": "2026-09-28T09:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Thanks — added it!", "needs_clarification": null}} +{"id": "e15", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Library: your book club meets Thursday October 8 at 7pm\nMe: see you there", "gold": {"reasoning": null, "events": [{"title": "Book club", "start": "2026-10-08T19:00:00", "end": null, "location": "Library", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you on the 8th at 7!", "needs_clarification": null}} +{"id": "e07", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Maya: study group tomorrow at 4pm at the library\nMe: I'll be there", "gold": {"reasoning": null, "events": [{"title": "Study group", "start": "2026-09-15T16:00:00", "end": null, "location": "Library", "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you at 4 tomorrow!", "needs_clarification": null}} +{"id": "e08", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Dad: family dinner this Friday 6:30pm at Grandma's\nMe: yum", "gold": {"reasoning": null, "events": [{"title": "Family dinner", "start": "2026-09-18T18:30:00", "end": null, "location": "Grandma's", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you Friday at 6:30!", "needs_clarification": null}} +{"id": "e09", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Manager: our 1:1 moves to next Tuesday at 11am\nMe: ok", "gold": {"reasoning": null, "events": [{"title": "1:1 with manager", "start": "2026-09-22T11:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 15, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Got it — Tuesday at 11.", "needs_clarification": null}} +{"id": "e10", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Prof: the problem set is due in three days at noon\nMe: on it", "gold": {"reasoning": null, "events": [{"title": "Problem set due", "start": "2026-09-17T12:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 1440, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "It'll be in by Thursday noon!", "needs_clarification": null}} +{"id": "e11", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Zoe: trivia night tonight at 8pm at The Tap\nMe: in!", "gold": {"reasoning": null, "events": [{"title": "Trivia night", "start": "2026-09-14T20:00:00", "end": null, "location": "The Tap", "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you at 8!", "needs_clarification": null}} +{"id": "e12", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Neighbor: garage sale this Saturday 7am to 1pm\nMe: I'll swing by", "gold": {"reasoning": null, "events": [{"title": "Garage sale", "start": "2026-09-19T07:00:00", "end": "2026-09-19T13:00:00", "location": null, "attendees": [], "reminder_minutes": null, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "I'll stop by Saturday!", "needs_clarification": null}} +{"id": "e13", "category": "events", "now": "2026-09-14T09:00:00", "thread": "HR: onboarding session is next Monday at 10am\nMe: thanks", "gold": {"reasoning": null, "events": [{"title": "Onboarding session", "start": "2026-09-21T10:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Thanks — see you Monday at 10.", "needs_clarification": null}} +{"id": "e14", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Landlord: apartment inspection on the 30th at 2pm\nMe: noted", "gold": {"reasoning": null, "events": [{"title": "Apartment inspection", "start": "2026-09-30T14:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Noted — the 30th at 2.", "needs_clarification": null}} +{"id": "e16", "category": "events", "now": "2026-09-14T09:00:00", "thread": "Boss: all-hands is a week from today at 3pm\nMe: noted", "gold": {"reasoning": null, "events": [{"title": "All-hands", "start": "2026-09-21T15:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Got it — added the all-hands.", "needs_clarification": null}} +{"id": "c01", "category": "no_event", "now": "2026-09-14T09:00:00", "thread": "Friend: that movie was incredible, thanks for the rec!\nMe: right?? so good", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Right? One of the best this year!", "needs_clarification": null}} +{"id": "c02", "category": "no_event", "now": "2026-09-14T09:00:00", "thread": "Mom: just checking you got home safe\nMe: yep, all good!", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Home safe — thanks for checking!", "needs_clarification": null}} +{"id": "c03", "category": "no_event", "now": "2026-09-14T09:00:00", "thread": "Coworker: congrats on shipping the release!\nMe: thanks, big team effort", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Appreciate it — couldn't have done it without the team!", "needs_clarification": null}} +{"id": "c04", "category": "no_event", "now": "2026-09-14T09:00:00", "thread": "Sis: the cookies turned out amazing\nMe: yay! glad the recipe worked", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "So glad — they're a keeper!", "needs_clarification": null}} +{"id": "c05", "category": "no_event", "now": "2026-09-14T09:00:00", "thread": "Coach: great hustle at practice today, everyone\nMe: thanks coach!", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Thanks, Coach — good session today!", "needs_clarification": null}} +{"id": "q01", "category": "clarify", "now": "2026-09-14T09:00:00", "thread": "Alex: we should grab coffee soon!\nMe: yes, let's!", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Definitely! What day works for you?", "needs_clarification": "When would you like to grab coffee?"}} +{"id": "q02", "category": "clarify", "now": "2026-09-14T09:00:00", "thread": "Team: let's schedule the offsite sometime next month\nMe: sounds good", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Sounds good — which dates are we considering?", "needs_clarification": "What date next month works for the offsite?"}} +{"id": "q03", "category": "clarify", "now": "2026-09-14T09:00:00", "thread": "Jen: dinner this week? not sure which night works for you\nMe: let's find a time", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "I'm free most nights — what works for you?", "needs_clarification": "Which night this week works for dinner?"}} +{"id": "q04", "category": "clarify", "now": "2026-09-14T09:00:00", "thread": "Coach: we'll add an extra practice, date still TBD\nMe: keep me posted", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Sounds good — let me know the day and time.", "needs_clarification": "Which day and time is the extra practice?"}} +{"id": "m01", "category": "multi", "now": "2026-09-14T09:00:00", "thread": "Spouse: Friday Sept 18 — Mia has the dentist at 9am, then soccer at 4pm\nMe: adding both", "gold": {"reasoning": null, "events": [{"title": "Mia — dentist", "start": "2026-09-18T09:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 60, "notes": null}, {"title": "Mia — soccer", "start": "2026-09-18T16:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Both on the calendar — dentist at 9, soccer at 4.", "needs_clarification": null}} +{"id": "m02", "category": "multi", "now": "2026-09-14T09:00:00", "thread": "Office: standup is Wednesday Sept 16 at 9:30am and the sprint demo is Sept 16 at 2pm\nMe: blocked both", "gold": {"reasoning": null, "events": [{"title": "Standup", "start": "2026-09-16T09:30:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 15, "notes": null}, {"title": "Sprint demo", "start": "2026-09-16T14:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 15, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Blocked both — standup at 9:30, demo at 2.", "needs_clarification": null}} +{"id": "m03", "category": "multi", "now": "2026-09-14T09:00:00", "thread": "Camp: drop-off is tomorrow at 8am and pickup is tomorrow at 3pm\nMe: got it", "gold": {"reasoning": null, "events": [{"title": "Camp drop-off", "start": "2026-09-15T08:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}, {"title": "Camp pickup", "start": "2026-09-15T15:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Got it — drop-off 8am, pickup 3pm tomorrow.", "needs_clarification": null}} +{"id": "e17", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Dr. Nguyen's office: confirming Quinn's filling on October 21 at 4:45pm\nMe: thanks", "gold": {"reasoning": null, "events": [{"title": "Quinn — filling", "start": "2026-10-21T16:45:00", "end": null, "location": "Dr. Nguyen's office", "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you on the 21st at 4:45.", "needs_clarification": null}} +{"id": "e18", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Tessa: my housewarming is 11/14 at 6pm, 48 Birchwood Ln\nMe: wouldn't miss it", "gold": {"reasoning": null, "events": [{"title": "Tessa's housewarming", "start": "2026-11-14T18:00:00", "end": null, "location": "48 Birchwood Ln", "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Can't wait — see you the 14th!", "needs_clarification": null}} +{"id": "e19", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Choir: winter audition is December 3rd at 9:15am in room 12\nMe: signed up", "gold": {"reasoning": null, "events": [{"title": "Choir audition", "start": "2026-12-03T09:15:00", "end": null, "location": "Room 12", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Signed up — Dec 3rd at 9:15.", "needs_clarification": null}} +{"id": "e20", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Parent council: meeting Thursday October 15 at 7pm in the cafeteria\nMe: I'll come", "gold": {"reasoning": null, "events": [{"title": "Parent council meeting", "start": "2026-10-15T19:00:00", "end": null, "location": "Cafeteria", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you Thursday the 15th.", "needs_clarification": null}} +{"id": "e21", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Mr. Alvarez: tutoring moved to 5:30 on the 28th\nMe: ok", "gold": {"reasoning": null, "events": [{"title": "Tutoring", "start": "2026-10-28T17:30:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Got it — the 28th at 5:30.", "needs_clarification": null}} +{"id": "e22", "category": "events", "now": "2026-11-13T17:00:00", "thread": "Rec center: lifeguard recert is November 30 at 8am, bring your card\nMe: noted", "gold": {"reasoning": null, "events": [{"title": "Lifeguard recertification", "start": "2026-11-30T08:00:00", "end": null, "location": "Rec center", "attendees": [], "reminder_minutes": 60, "notes": "Bring certification card"}], "conflicts": [], "proposed_times": [], "reply_draft": "Noted — Nov 30 at 8.", "needs_clarification": null}} +{"id": "e23", "category": "events", "now": "2026-11-13T17:00:00", "thread": "Wren: brunch December 5, noon, at Petit Four?\nMe: yes please", "gold": {"reasoning": null, "events": [{"title": "Brunch with Wren", "start": "2026-12-05T12:00:00", "end": null, "location": "Petit Four", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Noon on the 5th — in!", "needs_clarification": null}} +{"id": "e24", "category": "events", "now": "2026-11-13T17:00:00", "thread": "Garage: your inspection slot is Nov 18 at 7:45am\nMe: I'll drop it off", "gold": {"reasoning": null, "events": [{"title": "Car inspection", "start": "2026-11-18T07:45:00", "end": null, "location": "Garage", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Dropping it off the 18th at 7:45.", "needs_clarification": null}} +{"id": "e25", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Library: the used book sale opens Friday October 16 at 9am\nMe: I'll be there early", "gold": {"reasoning": null, "events": [{"title": "Used book sale", "start": "2026-10-16T09:00:00", "end": null, "location": "Library", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "There at 9 on the 16th!", "needs_clarification": null}} +{"id": "e26", "category": "events", "now": "2026-10-07T08:30:00", "thread": "City pool: winter session registration opens Tuesday October 13 at 6am\nMe: setting an alarm", "gold": {"reasoning": null, "events": [{"title": "Pool registration opens", "start": "2026-10-13T06:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Alarm set for the 13th at 6.", "needs_clarification": null}} +{"id": "e27", "category": "events", "now": "2026-11-13T17:00:00", "thread": "Museum: the new dinosaur exhibit opens November 20 at 10am\nMe: kids will love that", "gold": {"reasoning": null, "events": [{"title": "Dinosaur exhibit opening", "start": "2026-11-20T10:00:00", "end": null, "location": "Museum", "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Taking the kids on the 20th!", "needs_clarification": null}} +{"id": "r01", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Sage: yoga tomorrow at 7:15am?\nMe: I'm in", "gold": {"reasoning": null, "events": [{"title": "Yoga with Sage", "start": "2026-10-08T07:15:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you at 7:15!", "needs_clarification": null}} +{"id": "r02", "category": "events", "now": "2026-11-13T17:00:00", "thread": "Theo: pancakes at ours tomorrow, 9am\nMe: yum, in", "gold": {"reasoning": null, "events": [{"title": "Pancakes at Theo's", "start": "2026-11-14T09:00:00", "end": null, "location": "Theo's", "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See you at 9!", "needs_clarification": null}} +{"id": "r03", "category": "events", "now": "2026-11-13T17:00:00", "thread": "Bowling league: lanes tonight at 8\nMe: rolling in", "gold": {"reasoning": null, "events": [{"title": "Bowling league", "start": "2026-11-13T20:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "See everyone at 8!", "needs_clarification": null}} +{"id": "r04", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Aunt Rosa: lunch this Sunday at 1?\nMe: lovely", "gold": {"reasoning": null, "events": [{"title": "Lunch with Aunt Rosa", "start": "2026-10-11T13:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Sunday at 1 — lovely!", "needs_clarification": null}} +{"id": "r05", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Barber: I can fit you in next Wednesday at 5:45\nMe: book it", "gold": {"reasoning": null, "events": [{"title": "Haircut", "start": "2026-10-14T17:45:00", "end": null, "location": "Barber", "attendees": [], "reminder_minutes": 30, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Booked — next Wednesday at 5:45.", "needs_clarification": null}} +{"id": "r06", "category": "events", "now": "2026-11-13T17:00:00", "thread": "Ski club: first meetup is next Friday at 6:30pm\nMe: stoked", "gold": {"reasoning": null, "events": [{"title": "Ski club meetup", "start": "2026-11-20T18:30:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Stoked — see everyone the 20th.", "needs_clarification": null}} +{"id": "r07", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Prof Idris: revised draft due in five days, by 5pm\nMe: on it", "gold": {"reasoning": null, "events": [{"title": "Revised draft due", "start": "2026-10-12T17:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 1440, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "It'll be in by Monday at 5.", "needs_clarification": null}} +{"id": "r08", "category": "events", "now": "2026-10-07T08:30:00", "thread": "Nico: cabin trip a week from Saturday, leaving 8am\nMe: packing already", "gold": {"reasoning": null, "events": [{"title": "Cabin trip departure", "start": "2026-10-17T08:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Packed and ready for the 17th!", "needs_clarification": null}} +{"id": "m04", "category": "multi", "now": "2026-10-07T08:30:00", "thread": "Saturday Oct 10 plan — farmers market at 9, Quinn's game at 1, dinner with the Patels at 6\nMe: full day!", "gold": {"reasoning": null, "events": [{"title": "Farmers market", "start": "2026-10-10T09:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 30, "notes": null}, {"title": "Quinn's game", "start": "2026-10-10T13:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 60, "notes": null}, {"title": "Dinner with the Patels", "start": "2026-10-10T18:00:00", "end": null, "location": null, "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "All three added — market, game, dinner.", "needs_clarification": null}} +{"id": "m05", "category": "multi", "now": "2026-11-13T17:00:00", "thread": "Clinic: flu shots Saturday Nov 21 at 10am, and your follow-up is Dec 1 at 2:30pm\nMe: both noted", "gold": {"reasoning": null, "events": [{"title": "Flu shot", "start": "2026-11-21T10:00:00", "end": null, "location": "Clinic", "attendees": [], "reminder_minutes": 60, "notes": null}, {"title": "Follow-up appointment", "start": "2026-12-01T14:30:00", "end": null, "location": "Clinic", "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Both on the calendar.", "needs_clarification": null}} +{"id": "m06", "category": "multi", "now": "2026-10-07T08:30:00", "thread": "School: early release Friday Oct 9 at 12:15, then the fall social runs 5 to 7pm same day\nMe: busy friday", "gold": {"reasoning": null, "events": [{"title": "Early release", "start": "2026-10-09T12:15:00", "end": null, "location": "School", "attendees": [], "reminder_minutes": 120, "notes": null}, {"title": "Fall social", "start": "2026-10-09T17:00:00", "end": "2026-10-09T19:00:00", "location": "School", "attendees": [], "reminder_minutes": 60, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Got both — pickup 12:15, social at 5.", "needs_clarification": null}} +{"id": "m07", "category": "multi", "now": "2026-11-13T17:00:00", "thread": "Vet: Biscuit's nail trim is Nov 17 at 3pm and his dental is Dec 9 at 8am\nMe: poor guy", "gold": {"reasoning": null, "events": [{"title": "Biscuit — nail trim", "start": "2026-11-17T15:00:00", "end": null, "location": "Vet", "attendees": [], "reminder_minutes": 60, "notes": null}, {"title": "Biscuit — dental", "start": "2026-12-09T08:00:00", "end": null, "location": "Vet", "attendees": [], "reminder_minutes": 120, "notes": null}], "conflicts": [], "proposed_times": [], "reply_draft": "Both booked for Biscuit.", "needs_clarification": null}} +{"id": "c06", "category": "no_event", "now": "2026-10-07T08:30:00", "thread": "Marisol: the recital photos came out so cute\nMe: omg yes, framing them", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Aren't they adorable? Definitely framing a few!", "needs_clarification": null}} +{"id": "c07", "category": "no_event", "now": "2026-10-07T08:30:00", "thread": "Dev: ugh, standup ran 40 minutes today\nMe: brutal", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Brutal — hope tomorrow's is snappier.", "needs_clarification": null}} +{"id": "c08", "category": "no_event", "now": "2026-11-13T17:00:00", "thread": "Neighbor: leaf pile in front of ours, kids are welcome to jump in\nMe: they'll be thrilled", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "They'll be over in five minutes, thank you!", "needs_clarification": null}} +{"id": "c09", "category": "no_event", "now": "2026-11-13T17:00:00", "thread": "Gran: loved the call yesterday, made my week\nMe: mine too", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Made mine too — talk soon, Gran!", "needs_clarification": null}} +{"id": "c10", "category": "no_event", "now": "2026-10-07T08:30:00", "thread": "Courier: your parcel was left at the side door\nMe: got it, thanks", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Got it — thanks for the heads up!", "needs_clarification": null}} +{"id": "q05", "category": "clarify", "now": "2026-10-07T08:30:00", "thread": "Room parent: bake sale is being planned, date to be announced\nMe: count me in for cookies", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "I'm in for cookies — what date should I plan for?", "needs_clarification": "What date is the bake sale?"}} +{"id": "q06", "category": "clarify", "now": "2026-10-07T08:30:00", "thread": "Piano teacher: we should add a makeup lesson — does Tuesday or Thursday suit?\nMe: let me check", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Let me check the calendar and get back to you.", "needs_clarification": "Tuesday or Thursday — and what time works for the makeup lesson?"}} +{"id": "q07", "category": "clarify", "now": "2026-11-13T17:00:00", "thread": "Uncle Ray: thinking of visiting sometime around the holidays\nMe: that would be great", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "We'd love that! Which dates are you thinking?", "needs_clarification": "Which dates would Uncle Ray visit?"}} +{"id": "q08", "category": "clarify", "now": "2026-11-13T17:00:00", "thread": "HOA: the annual meeting will be rescheduled, new time to come\nMe: ok", "gold": {"reasoning": null, "events": [], "conflicts": [], "proposed_times": [], "reply_draft": "Thanks — I'll watch for the new time.", "needs_clarification": "What is the new date and time of the annual meeting?"}} diff --git a/training/eval.py b/training/eval.py new file mode 100644 index 0000000000000000000000000000000000000000..8151d687b3d38a93a5b66ed86028dd9b5b99a5d6 --- /dev/null +++ b/training/eval.py @@ -0,0 +1,278 @@ +"""Score the scheduling fine-tune on the held-out set (training/data/eval.jsonl). + +Reuses the deployed contract exactly: server.agent.SYSTEM + build_messages for the +prompt, server.schema.ActionPlan for parsing, and the same OpenAI-compatible +`{INFERENCE_BASE_URL}/chat/completions` call with a json_schema response_format. + +Metrics (task-specific — generic LLM benchmarks don't apply here): + - schema validity : model output parses + validates as ActionPlan + - event precision/recall/F1 (matched on exact start datetime, minute precision) + - start-exact recall : did it nail the date AND time (incl. relative dates) + - end-exact rate : on matched events where gold has an end + - title similarity : difflib ratio on matched events (soft) + - location recall/sim : on matched events where gold has a location + - reminder match rate : on matched events where gold sets reminder_minutes. + NOTE: only eval_unstructured.jsonl golds encode the type rules (medical 60 / + party 30 / carpool-school 45); the structured eval.jsonl golds predate them, + so its reminder number is informational only. + - no-event accuracy : on chitchat, did it correctly return zero events + - clarification recall : on ambiguous threads, did it ask instead of inventing + +Usage (needs a model serving an OpenAI-compatible endpoint): + INFERENCE_BASE_URL=http://127.0.0.1:8080/v1 python training/eval.py + +A/B arms (same metrics, same gold): + PREDICTOR=stub python training/eval.py # arm A: the regex stub, no model/HTTP + VISION=1 python training/eval.py # arm C: the thread is fed ONLY as a + rendered screenshot (training/data/screenshots//.png — make + them with training/render_screenshots.py); needs a vision-enabled server. + EVAL_PATH=training/data/eval_unstructured.jsonl ... # the non-formulaic set +""" +from __future__ import annotations + +import base64 +import json +import os +import re +import sys +from datetime import datetime +from difflib import SequenceMatcher +from pathlib import Path + +import requests + +if hasattr(sys.stdout, "reconfigure"): # emoji in threads vs Windows cp1252 console + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +from server.agent import ( # noqa: E402 (light import; model is lazy) + TITLES_SCHEMA, + apply_text_rules, + build_messages, + build_title_messages, + merge_titles, +) +from server.schema import ActionPlan # noqa: E402 + +BASE = os.environ.get("INFERENCE_BASE_URL", "http://127.0.0.1:8080/v1").rstrip("/") +MODEL = os.environ.get("INFERENCE_MODEL", "local") +PREDICTOR = os.environ.get("PREDICTOR", "model") # "model" (HTTP) | "stub" (regex) +VISION = os.environ.get("VISION") == "1" # feed the thread as a screenshot only +TITLE_POLISH = os.environ.get("TITLE_POLISH") == "1" # second pass rewriting titles +LABEL = os.environ.get("MODEL_LABEL", + "stub-regex" if PREDICTOR == "stub" + else (f"{MODEL}+vision" if VISION else MODEL)) +EVAL_PATH = Path(os.environ.get("EVAL_PATH", ROOT / "training" / "data" / "eval.jsonl")) +SCREENSHOT_DIR = Path(os.environ.get( + "SCREENSHOT_DIR", ROOT / "training" / "data" / "screenshots")) / EVAL_PATH.stem +VISION_THREAD = "(the conversation is in the attached screenshot)" +SCHEMA = ActionPlan.model_json_schema() + + +def call_model(messages: list[dict], schema: dict = None, name: str = "ActionPlan", + max_tokens: int = 1024) -> str: + payload = { + "model": MODEL, + "messages": messages, + "temperature": 0.0, # greedy -> reproducible eval + "max_tokens": max_tokens, + "response_format": { + "type": "json_schema", + "json_schema": {"name": name, "schema": schema or SCHEMA, "strict": True}, + }, + "stream": False, + } + r = requests.post(f"{BASE}/chat/completions", json=payload, timeout=180) + r.raise_for_status() + return r.json()["choices"][0]["message"]["content"] + + +def parse_plan(raw: str): + try: + return ActionPlan(**json.loads(raw)), True + except Exception: # noqa: BLE001 + m = re.search(r"\{.*\}", raw, re.DOTALL) + if m: + try: + return ActionPlan(**json.loads(m.group(0))), True + except Exception: # noqa: BLE001 + pass + return ActionPlan(), False + + +def dt_key(s): + if not s: + return None + try: + return datetime.fromisoformat(s).strftime("%Y-%m-%dT%H:%M") + except Exception: # noqa: BLE001 + return str(s)[:16] + + +def match_events(gold, pred): + """Greedy one-to-one match on exact start (minute precision). Returns + (matched_pairs, n_fp, n_fn).""" + pred_left = list(pred) + pairs, fn = [], 0 + for g in gold: + gk = dt_key(g.get("start")) + hit = next((p for p in pred_left if dt_key(p.get("start")) == gk), None) + if hit is not None: + pairs.append((g, hit)) + pred_left.remove(hit) + else: + fn += 1 + return pairs, len(pred_left), fn + + +def sim(a, b): + return round(SequenceMatcher(None, (a or "").lower(), (b or "").lower()).ratio(), 2) + + +def _screenshot_uri(rec_id: str) -> str: + png = SCREENSHOT_DIR / f"{rec_id}.png" + data = base64.b64encode(png.read_bytes()).decode() + return f"data:image/png;base64,{data}" + + +def predict(rec: dict, now: datetime): + """Run one arm on one example -> (ActionPlan, schema_valid).""" + if PREDICTOR == "stub": + os.environ["USE_STUB_EXTRACTOR"] = "1" + from server.agent import run_agent # noqa: PLC0415 lazy, like the server + + # memory_block="" keeps the eval from writing the server-side memory file + return run_agent(rec["thread"], now=now, memory_block=""), True + images = [_screenshot_uri(rec["id"])] if VISION else None + thread = VISION_THREAD if VISION else rec["thread"] + messages = build_messages(thread, now, [], images) + # MINIMAL_PROMPT=1: drop the system prompt entirely — measures how much task + # knowledge is INTERNALIZED (fine-tune) vs prompted-in (stock). User content + # (datetime + thread + "Return the ActionPlan JSON now.") stays identical. + if os.environ.get("MINIMAL_PROMPT") == "1": + messages = [m for m in messages if m["role"] != "system"] + plan, valid = parse_plan(call_model(messages)) + # TITLE_POLISH=1: same second pass the server runs — rewrite titles only. + # Text mode only: the polish prompt quotes the thread, which VISION withholds. + if TITLE_POLISH and not VISION and plan.events: + raw = call_model( + build_title_messages(rec["thread"], [e.model_dump() for e in plan.events]), + schema=TITLES_SCHEMA, name="Titles", max_tokens=256, + ) + plan = merge_titles(plan, raw) + # Same deterministic logistics post-pass the server applies (arrival shift + + # reminder rules). Text mode only — it reads the thread text. + if not VISION: + plan = apply_text_rules(rec["thread"], plan) + return plan, valid + + +def main(): + records = [json.loads(l) for l in EVAL_PATH.read_text(encoding="utf-8").splitlines() if l.strip()] + where = "(local regex stub)" if PREDICTOR == "stub" else f"@ {BASE}" + print(f"Scoring {LABEL} on {len(records)} held-out examples {where}" + f"{' [vision: screenshots only]' if VISION else ''}\n") + + n_valid = 0 + tp = fp = fn = 0 + end_ok = end_tot = 0 + loc_ok = loc_tot = 0 + rem_ok = rem_tot = 0 + title_sims = [] + loc_sims = [] + no_event = {"ok": 0, "tot": 0} + clarify = {"ok": 0, "tot": 0} + rows = [] + mismatches = [] # gold-vs-pred event detail for failing extraction examples + + for rec in records: + now = datetime.fromisoformat(rec["now"]) + try: + pred, valid = predict(rec, now) + except Exception as e: # noqa: BLE001 + print(f" [{rec['id']}] request failed: {str(e)[:120]}") + rows.append((rec["id"], rec["category"], "ERR")); continue + + n_valid += int(valid) + gold = rec["gold"] + g_ev, p_ev = gold["events"], [e.model_dump() for e in pred.events] + + pairs, e_fp, e_fn = match_events(g_ev, p_ev) + tp += len(pairs); fp += e_fp; fn += e_fn + for g, p in pairs: + title_sims.append(sim(g.get("title"), p.get("title"))) + if g.get("end"): + end_tot += 1 + end_ok += int(dt_key(g["end"]) == dt_key(p.get("end"))) + if g.get("location"): + loc_tot += 1 + s = sim(g.get("location"), p.get("location")) + loc_sims.append(s) + loc_ok += int(bool(p.get("location")) and s >= 0.5) + if g.get("reminder_minutes") is not None: + rem_tot += 1 + rem_ok += int(p.get("reminder_minutes") == g["reminder_minutes"]) + + cat = rec["category"] + flag = "" + if cat == "no_event": + no_event["tot"] += 1 + good = len(p_ev) == 0 + no_event["ok"] += int(good) + flag = "no-event OK" if good else f"HALLUCINATED {len(p_ev)}" + elif cat == "clarify": + clarify["tot"] += 1 + good = bool(pred.needs_clarification) and len(p_ev) == 0 + clarify["ok"] += int(good) + flag = "asked OK" if good else "did NOT ask" + else: + flag = f"events {len(pairs)}/{len(g_ev)} matched" + (f", +{e_fp} extra" if e_fp else "") + if e_fp or e_fn: # capture what the model actually produced, to diagnose + mismatches.append({ + "id": rec["id"], + "gold": [(e.get("start"), e.get("title")) for e in g_ev], + "pred": [(e.get("start"), e.get("title")) for e in p_ev], + }) + rows.append((rec["id"], cat, ("ok" if valid else "BADJSON") + " | " + flag)) + + n = len(records) + prec = tp / (tp + fp) if (tp + fp) else 0.0 + rec_ = tp / (tp + fn) if (tp + fn) else 0.0 + f1 = 2 * prec * rec_ / (prec + rec_) if (prec + rec_) else 0.0 + + print("\nPer-example:") + for rid, cat, info in rows: + print(f" {rid:4s} {cat:9s} {info}") + + if mismatches: + print("\nMismatch detail (start, title) — gold vs what the model produced:") + for mm in mismatches: + print(f" [{mm['id']}]") + print(f" gold: {mm['gold']}") + print(f" pred: {mm['pred']}") + + summary = { + "model": LABEL, + "n_examples": n, + "schema_validity": round(n_valid / n, 3), + "event_precision": round(prec, 3), + "event_recall_start_exact": round(rec_, 3), + "event_f1": round(f1, 3), + "end_exact_rate": round(end_ok / end_tot, 3) if end_tot else None, + "title_similarity_avg": round(sum(title_sims) / len(title_sims), 3) if title_sims else None, + "location_recall": round(loc_ok / loc_tot, 3) if loc_tot else None, + "location_similarity_avg": round(sum(loc_sims) / len(loc_sims), 3) if loc_sims else None, + "reminder_match_rate": round(rem_ok / rem_tot, 3) if rem_tot else None, + "no_event_accuracy": round(no_event["ok"] / no_event["tot"], 3) if no_event["tot"] else None, + "clarification_recall": round(clarify["ok"] / clarify["tot"], 3) if clarify["tot"] else None, + "events_tp_fp_fn": [tp, fp, fn], + } + print("\n==================== SUMMARY ====================") + for k, v in summary.items(): + print(f" {k:28s} {v}") + print("RESULTS_JSON:", json.dumps(summary)) + + +if __name__ == "__main__": + main() diff --git a/training/export_gguf.sh b/training/export_gguf.sh new file mode 100644 index 0000000000000000000000000000000000000000..8000a291b3510b4a14a217bd84658d62dc5bc968 --- /dev/null +++ b/training/export_gguf.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Convert the merged fp16 model to GGUF, quantize to Q4_K_M, and upload to HF. +# Run after train_qlora.py. Requires a llama.cpp checkout + an HF login +# (`hf auth login`, or HF_TOKEN in the environment — Modal injects it from the +# `huggingface` secret). Uses the `hf` CLI; `huggingface-cli` was removed in hub 1.x. +set -euo pipefail + +MERGED_DIR="${MERGED_DIR:-training/outputs/gemma4-cal-lora-merged}" +LLAMA_CPP="${LLAMA_CPP:-$HOME/llama.cpp}" +HF_REPO="${HF_REPO:-n8mauer/gemma-4-cal-gguf}" +OUT="${OUT:-training/outputs/gemma4-cal}" + +mkdir -p "$(dirname "$OUT")" + +# 1) HF -> GGUF (fp16) +python "$LLAMA_CPP/convert_hf_to_gguf.py" "$MERGED_DIR" \ + --outfile "${OUT}-f16.gguf" --outtype f16 + +# 2) Quantize to Q4_K_M (~18-20GB for 31B) +"$LLAMA_CPP/build/bin/llama-quantize" "${OUT}-f16.gguf" "${OUT}-Q4_K_M.gguf" Q4_K_M + +# 3) Publish the fine-tuned LLM GGUF to HF (satisfies Well-Tuned) +hf upload "$HF_REPO" "${OUT}-Q4_K_M.gguf" \ + "$(basename "${OUT}-Q4_K_M.gguf")" + +# 4) Vision projector: fine-tuning leaves the vision encoder frozen, so reuse the +# base mmproj. Pull it from a community GGUF repo and publish to the same repo +# so the Space can load model + mmproj from one place. SKIP_MMPROJ=1 skips this +# (e.g. for eval-gated *staging* uploads — production already has the mmproj). +if [ "${SKIP_MMPROJ:-0}" != "1" ]; then + MMPROJ_SRC_REPO="${MMPROJ_SRC_REPO:-unsloth/gemma-4-31B-it-GGUF}" + MMPROJ_FILE="${MMPROJ_FILE:-mmproj-F16.gguf}" + hf download "$MMPROJ_SRC_REPO" "$MMPROJ_FILE" --local-dir "$(dirname "$OUT")" + hf upload "$HF_REPO" "$(dirname "$OUT")/$MMPROJ_FILE" "$MMPROJ_FILE" +fi + +echo "Done. Point the Space at:" +echo " MODEL_REPO=$HF_REPO" +echo " MODEL_FILE=$(basename "${OUT}-Q4_K_M.gguf")" +echo " MMPROJ_FILE=$MMPROJ_FILE # enables Gemma 4 vision" diff --git a/training/gated_retrain.py b/training/gated_retrain.py new file mode 100644 index 0000000000000000000000000000000000000000..f605dc0434a85de030d64c397a37c76d2d71506e --- /dev/null +++ b/training/gated_retrain.py @@ -0,0 +1,111 @@ +"""Eval-gated retrain: never publish a retrain unless it beats the eval. + +Flow (production model is NEVER touched unless the gate passes): + 1. Retrain on Modal -> upload to a *staging* filename in the prod repo + (`gemma-cal-staging-Q4_K_M.gguf`), skipping mmproj (prod already has it). + 2. Eval the staging file on Modal (training/modal_eval.py --model-file ...). + 3. Gate: require schema_validity >= MIN_VALID and event_f1 >= GATE_F1 and + start-exact recall >= GATE_RECALL (defaults = the current best fine-tune, v1). + 4. PASS -> promote staging -> production via a server-side CommitOperationCopy + (free, no transfer). FAIL -> production unchanged. Either way, delete staging. + +Run locally (Modal token + `huggingface` secret + HF login required): + python training/gated_retrain.py # 1 epoch on the current dataset.jsonl + python training/gated_retrain.py --gate-f1 0.85 --gate-recall 0.80 --epochs 1 +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys + +from huggingface_hub import CommitOperationCopy, CommitOperationDelete, HfApi, get_token + +REPO = "ParetoOptimal/gemma-4-cal-gguf" + +ENV = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"} + + +def run(cmd: list[str]) -> str: + print(f"\n$ {' '.join(cmd)}", flush=True) + p = subprocess.run(cmd, env=ENV, capture_output=True, text=True) + sys.stdout.write(p.stdout[-4000:]) + if p.returncode != 0: + sys.stderr.write(p.stderr[-4000:]) + sys.exit(f"step failed (exit {p.returncode}): {' '.join(cmd)}") + return p.stdout + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--epochs", default="1") + ap.add_argument("--gate-f1", type=float, default=0.81) # v1 fine-tune F1 + ap.add_argument("--gate-recall", type=float, default=0.773) # v1 start-exact recall + ap.add_argument("--min-valid", type=float, default=0.95) + ap.add_argument("--gate-clarify", type=float, default=0.0, + help="min clarification_recall (0 = not gated)") + ap.add_argument("--base-model", default="google/gemma-4-31B-it", + help="HF base model to fine-tune (e.g. google/gemma-4-E4B-it for the edge model)") + ap.add_argument("--staging-name", default="gemma-cal-staging", + help="staging output basename -> -Q4_K_M.gguf") + ap.add_argument("--prod-file", default="gemma-cal-Q4_K_M.gguf", + help="production filename the staging GGUF is promoted to on PASS") + ap.add_argument("--hand-upsample", default="4", + help="x-factor for the thread-style hand-authored training rows") + args = ap.parse_args() + STAGING_FILE = f"{args.staging_name}-Q4_K_M.gguf" + PROD_FILE = args.prod_file + + api = HfApi(token=get_token()) + + # 1) retrain -> staging file (the production file is untouched) + print("=== [1/4] retrain -> staging ===") + run(["python", "-m", "modal", "run", "training/modal_train.py", + "--hf-repo", REPO, "--base-model", args.base_model, + "--out-name", args.staging_name, "--skip-mmproj", + "--num-epochs", args.epochs, "--hand-upsample", args.hand_upsample]) + + # 2) eval the staging file + print("=== [2/4] eval staging ===") + out = run(["python", "-m", "modal", "run", "training/modal_eval.py", + "--model-hf-repo", REPO, "--model-file", STAGING_FILE]) + m = re.search(r"RESULTS_JSON:\s*(\{.*\})", out) + if not m: + api.create_commit(REPO, repo_type="model", + operations=[CommitOperationDelete(STAGING_FILE)], + commit_message="Remove staging GGUF (eval produced no results)") + sys.exit("could not parse eval RESULTS_JSON; staging deleted, production unchanged") + res = json.loads(m.group(1)) + + # 3) gate + print("=== [3/4] gate ===") + passed = (res.get("schema_validity", 0) >= args.min_valid + and res.get("event_f1", 0) >= args.gate_f1 + and res.get("event_recall_start_exact", 0) >= args.gate_recall + and (res.get("clarification_recall") or 0) >= args.gate_clarify) + print(f"scores: {res}") + print(f"gate: validity>={args.min_valid}, f1>={args.gate_f1}, recall>={args.gate_recall}, " + f"clarify>={args.gate_clarify} -> {'PASS' if passed else 'FAIL'}") + + # 4) promote or discard + print("=== [4/4] promote/discard ===") + ops = [] + if passed: + ops.append(CommitOperationCopy(src_path_in_repo=STAGING_FILE, path_in_repo=PROD_FILE)) + msg = "Promote eval-gated retrain to production (beat gate)" + else: + msg = "Discard eval-gated retrain (failed gate); production unchanged" + ops.append(CommitOperationDelete(STAGING_FILE)) + api.create_commit(REPO, repo_type="model", operations=ops, commit_message=msg) + + if passed: + print(f"PROMOTED: {STAGING_FILE} -> {PROD_FILE}. Restart the Space to serve it.") + else: + print("NOT promoted. Production model is unchanged (still the prior best).") + + +if __name__ == "__main__": + main() diff --git a/training/gen_eval.py b/training/gen_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..bb201c8565e05e3a4ec7e4b0e31484b38edb2f74 --- /dev/null +++ b/training/gen_eval.py @@ -0,0 +1,316 @@ +"""Build training/data/eval.jsonl — a held-out test set for scoring the fine-tune. + +These examples are DISJOINT from training/data/dataset.jsonl (fresh names, dates, +wording) so the eval measures generalization, not memorization. Each record fixes +a `now` so relative-date answers ("tomorrow", "next Tuesday") are deterministic. + +Anchor for relative dates: 2026-09-14 is a **Monday** (so tomorrow=Sep 15 Tue, +this Fri=Sep 18, this Sat=Sep 19, next Mon=Sep 21, next Tue=Sep 22, +3 days=Sep 17). + +Run: python training/gen_eval.py -> writes training/data/eval.jsonl +Score: see training/eval.py (needs an INFERENCE_BASE_URL serving the model). +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from server.schema import ActionPlan # noqa: E402 + +NOW = "2026-09-14T09:00:00" # Monday + + +def ev(title, start, end=None, location=None, reminder=None, attendees=None, notes=None): + return { + "title": title, "start": start, "end": end, "location": location, + "attendees": attendees or [], "reminder_minutes": reminder, "notes": notes, + } + + +def plan(events=None, reply="", clarify=None, reasoning=None): + return { + "reasoning": reasoning, "events": events or [], "conflicts": [], + "proposed_times": [], "reply_draft": reply, "needs_clarification": clarify, + } + + +# (id, category, now, thread, gold_plan) +EVAL = [ + # --- A. explicit-date events (copy the stated date/time) --- + ("e01", "events", NOW, + "Teacher: Back-to-school night is Wednesday September 16 at 6:30pm in the gym\nMe: we'll be there", + plan([ev("Back-to-school night", "2026-09-16T18:30:00", location="School gym", reminder=120)], + "See you Wednesday at 6:30!")), + ("e02", "events", NOW, + "Dr. Okafor's office: Liam's cleaning is set for Tuesday October 6 at 3:30pm\nMe: thanks", + plan([ev("Liam — dental cleaning", "2026-10-06T15:30:00", location="Dr. Okafor's office", reminder=120)], + "Got it — Tuesday the 6th at 3:30.")), + ("e03", "events", NOW, + "Sam: dinner Friday September 25, 7-9pm at Trattoria Verde?\nMe: yes!", + plan([ev("Dinner with Sam", "2026-09-25T19:00:00", "2026-09-25T21:00:00", "Trattoria Verde", 60)], + "Friday at 7 — can't wait!")), + ("e04", "events", NOW, + "Coach: makeup game is Sunday Sept 27 at 1pm at Field 2\nMe: got it", + plan([ev("Makeup game", "2026-09-27T13:00:00", location="Field 2", reminder=60)], + "We'll be at Field 2 by 1!")), + ("e05", "events", NOW, + "Clinic: flu shot clinic Thursday October 1 at 8:15am\nMe: see you", + plan([ev("Flu shot", "2026-10-01T08:15:00", location="Clinic", reminder=60)], + "See you Thursday at 8:15.")), + ("e06", "events", NOW, + "PTA: book fair opens Monday Sept 28 at 9am\nMe: noted", + plan([ev("Book fair", "2026-09-28T09:00:00", reminder=60)], "Thanks — added it!")), + ("e15", "events", NOW, + "Library: your book club meets Thursday October 8 at 7pm\nMe: see you there", + plan([ev("Book club", "2026-10-08T19:00:00", location="Library", reminder=60)], + "See you on the 8th at 7!")), + + # --- B. relative-date events (the hard skill) --- + ("e07", "events", NOW, + "Maya: study group tomorrow at 4pm at the library\nMe: I'll be there", + plan([ev("Study group", "2026-09-15T16:00:00", location="Library", reminder=30)], + "See you at 4 tomorrow!")), + ("e08", "events", NOW, + "Dad: family dinner this Friday 6:30pm at Grandma's\nMe: yum", + plan([ev("Family dinner", "2026-09-18T18:30:00", location="Grandma's", reminder=60)], + "See you Friday at 6:30!")), + ("e09", "events", NOW, + "Manager: our 1:1 moves to next Tuesday at 11am\nMe: ok", + plan([ev("1:1 with manager", "2026-09-22T11:00:00", reminder=15)], "Got it — Tuesday at 11.")), + ("e10", "events", NOW, + "Prof: the problem set is due in three days at noon\nMe: on it", + plan([ev("Problem set due", "2026-09-17T12:00:00", reminder=1440)], "It'll be in by Thursday noon!")), + ("e11", "events", NOW, + "Zoe: trivia night tonight at 8pm at The Tap\nMe: in!", + plan([ev("Trivia night", "2026-09-14T20:00:00", location="The Tap", reminder=30)], "See you at 8!")), + ("e12", "events", NOW, + "Neighbor: garage sale this Saturday 7am to 1pm\nMe: I'll swing by", + plan([ev("Garage sale", "2026-09-19T07:00:00", "2026-09-19T13:00:00")], "I'll stop by Saturday!")), + ("e13", "events", NOW, + "HR: onboarding session is next Monday at 10am\nMe: thanks", + plan([ev("Onboarding session", "2026-09-21T10:00:00", reminder=60)], "Thanks — see you Monday at 10.")), + ("e14", "events", NOW, + "Landlord: apartment inspection on the 30th at 2pm\nMe: noted", + plan([ev("Apartment inspection", "2026-09-30T14:00:00", reminder=120)], "Noted — the 30th at 2.")), + ("e16", "events", NOW, + "Boss: all-hands is a week from today at 3pm\nMe: noted", + plan([ev("All-hands", "2026-09-21T15:00:00", reminder=30)], "Got it — added the all-hands.")), + + # --- C. no-event chitchat (must NOT invent events) --- + ("c01", "no_event", NOW, + "Friend: that movie was incredible, thanks for the rec!\nMe: right?? so good", + plan([], "Right? One of the best this year!")), + ("c02", "no_event", NOW, + "Mom: just checking you got home safe\nMe: yep, all good!", + plan([], "Home safe — thanks for checking!")), + ("c03", "no_event", NOW, + "Coworker: congrats on shipping the release!\nMe: thanks, big team effort", + plan([], "Appreciate it — couldn't have done it without the team!")), + ("c04", "no_event", NOW, + "Sis: the cookies turned out amazing\nMe: yay! glad the recipe worked", + plan([], "So glad — they're a keeper!")), + ("c05", "no_event", NOW, + "Coach: great hustle at practice today, everyone\nMe: thanks coach!", + plan([], "Thanks, Coach — good session today!")), + + # --- D. needs clarification (ambiguous; ask, don't invent) --- + ("q01", "clarify", NOW, + "Alex: we should grab coffee soon!\nMe: yes, let's!", + plan([], "Definitely! What day works for you?", clarify="When would you like to grab coffee?")), + ("q02", "clarify", NOW, + "Team: let's schedule the offsite sometime next month\nMe: sounds good", + plan([], "Sounds good — which dates are we considering?", + clarify="What date next month works for the offsite?")), + ("q03", "clarify", NOW, + "Jen: dinner this week? not sure which night works for you\nMe: let's find a time", + plan([], "I'm free most nights — what works for you?", + clarify="Which night this week works for dinner?")), + ("q04", "clarify", NOW, + "Coach: we'll add an extra practice, date still TBD\nMe: keep me posted", + plan([], "Sounds good — let me know the day and time.", + clarify="Which day and time is the extra practice?")), + + # --- E. multi-event threads --- + ("m01", "multi", NOW, + "Spouse: Friday Sept 18 — Mia has the dentist at 9am, then soccer at 4pm\nMe: adding both", + plan([ev("Mia — dentist", "2026-09-18T09:00:00", reminder=60), + ev("Mia — soccer", "2026-09-18T16:00:00", reminder=30)], + "Both on the calendar — dentist at 9, soccer at 4.")), + ("m02", "multi", NOW, + "Office: standup is Wednesday Sept 16 at 9:30am and the sprint demo is Sept 16 at 2pm\nMe: blocked both", + plan([ev("Standup", "2026-09-16T09:30:00", reminder=15), + ev("Sprint demo", "2026-09-16T14:00:00", reminder=15)], + "Blocked both — standup at 9:30, demo at 2.")), + ("m03", "multi", NOW, + "Camp: drop-off is tomorrow at 8am and pickup is tomorrow at 3pm\nMe: got it", + plan([ev("Camp drop-off", "2026-09-15T08:00:00", reminder=30), + ev("Camp pickup", "2026-09-15T15:00:00", reminder=30)], + "Got it — drop-off 8am, pickup 3pm tomorrow.")), +] + +# Expansion (2026-06-10): 28 examples left single binary cases dominating the score +# (each event = 4.5 recall points), so run-to-run training jitter swamped the gate. +# +32 examples with TWO additional weekday anchors. 2026-10-07 = Wednesday, +# 2026-11-13 = Friday. Convention reminder: "next " = that DOW of NEXT week. +NOW2 = "2026-10-07T08:30:00" # Wednesday +NOW3 = "2026-11-13T17:00:00" # Friday + +EVAL2 = [ + # --- explicit dates, varied formats --- + ("e17", "events", NOW2, + "Dr. Nguyen's office: confirming Quinn's filling on October 21 at 4:45pm\nMe: thanks", + plan([ev("Quinn — filling", "2026-10-21T16:45:00", location="Dr. Nguyen's office", reminder=120)], + "See you on the 21st at 4:45.")), + ("e18", "events", NOW2, + "Tessa: my housewarming is 11/14 at 6pm, 48 Birchwood Ln\nMe: wouldn't miss it", + plan([ev("Tessa's housewarming", "2026-11-14T18:00:00", location="48 Birchwood Ln", reminder=120)], + "Can't wait — see you the 14th!")), + ("e19", "events", NOW2, + "Choir: winter audition is December 3rd at 9:15am in room 12\nMe: signed up", + plan([ev("Choir audition", "2026-12-03T09:15:00", location="Room 12", reminder=60)], + "Signed up — Dec 3rd at 9:15.")), + ("e20", "events", NOW2, + "Parent council: meeting Thursday October 15 at 7pm in the cafeteria\nMe: I'll come", + plan([ev("Parent council meeting", "2026-10-15T19:00:00", location="Cafeteria", reminder=60)], + "See you Thursday the 15th.")), + ("e21", "events", NOW2, + "Mr. Alvarez: tutoring moved to 5:30 on the 28th\nMe: ok", + plan([ev("Tutoring", "2026-10-28T17:30:00", reminder=30)], "Got it — the 28th at 5:30.")), + ("e22", "events", NOW3, + "Rec center: lifeguard recert is November 30 at 8am, bring your card\nMe: noted", + plan([ev("Lifeguard recertification", "2026-11-30T08:00:00", location="Rec center", + reminder=60, notes="Bring certification card")], + "Noted — Nov 30 at 8.")), + ("e23", "events", NOW3, + "Wren: brunch December 5, noon, at Petit Four?\nMe: yes please", + plan([ev("Brunch with Wren", "2026-12-05T12:00:00", location="Petit Four", reminder=60)], + "Noon on the 5th — in!")), + ("e24", "events", NOW3, + "Garage: your inspection slot is Nov 18 at 7:45am\nMe: I'll drop it off", + plan([ev("Car inspection", "2026-11-18T07:45:00", location="Garage", reminder=60)], + "Dropping it off the 18th at 7:45.")), + # --- "opens/starts/launches" phrasing (e06's failure shape) --- + ("e25", "events", NOW2, + "Library: the used book sale opens Friday October 16 at 9am\nMe: I'll be there early", + plan([ev("Used book sale", "2026-10-16T09:00:00", location="Library", reminder=60)], + "There at 9 on the 16th!")), + ("e26", "events", NOW2, + "City pool: winter session registration opens Tuesday October 13 at 6am\nMe: setting an alarm", + plan([ev("Pool registration opens", "2026-10-13T06:00:00", reminder=30)], + "Alarm set for the 13th at 6.")), + ("e27", "events", NOW3, + "Museum: the new dinosaur exhibit opens November 20 at 10am\nMe: kids will love that", + plan([ev("Dinosaur exhibit opening", "2026-11-20T10:00:00", location="Museum", reminder=120)], + "Taking the kids on the 20th!")), + # --- relative dates from non-Monday anchors --- + ("r01", "events", NOW2, + "Sage: yoga tomorrow at 7:15am?\nMe: I'm in", + plan([ev("Yoga with Sage", "2026-10-08T07:15:00", reminder=30)], "See you at 7:15!")), + ("r02", "events", NOW3, + "Theo: pancakes at ours tomorrow, 9am\nMe: yum, in", + plan([ev("Pancakes at Theo's", "2026-11-14T09:00:00", location="Theo's", reminder=30)], + "See you at 9!")), + ("r03", "events", NOW3, + "Bowling league: lanes tonight at 8\nMe: rolling in", + plan([ev("Bowling league", "2026-11-13T20:00:00", reminder=30)], "See everyone at 8!")), + ("r04", "events", NOW2, + "Aunt Rosa: lunch this Sunday at 1?\nMe: lovely", + plan([ev("Lunch with Aunt Rosa", "2026-10-11T13:00:00", reminder=60)], "Sunday at 1 — lovely!")), + ("r05", "events", NOW2, + "Barber: I can fit you in next Wednesday at 5:45\nMe: book it", + plan([ev("Haircut", "2026-10-14T17:45:00", location="Barber", reminder=30)], + "Booked — next Wednesday at 5:45.")), + ("r06", "events", NOW3, + "Ski club: first meetup is next Friday at 6:30pm\nMe: stoked", + plan([ev("Ski club meetup", "2026-11-20T18:30:00", reminder=60)], + "Stoked — see everyone the 20th.")), + ("r07", "events", NOW2, + "Prof Idris: revised draft due in five days, by 5pm\nMe: on it", + plan([ev("Revised draft due", "2026-10-12T17:00:00", reminder=1440)], + "It'll be in by Monday at 5.")), + ("r08", "events", NOW2, + "Nico: cabin trip a week from Saturday, leaving 8am\nMe: packing already", + plan([ev("Cabin trip departure", "2026-10-17T08:00:00", reminder=120)], + "Packed and ready for the 17th!")), + # --- multi-event --- + ("m04", "multi", NOW2, + "Saturday Oct 10 plan — farmers market at 9, Quinn's game at 1, dinner with the Patels at 6\nMe: full day!", + plan([ev("Farmers market", "2026-10-10T09:00:00", reminder=30), + ev("Quinn's game", "2026-10-10T13:00:00", reminder=60), + ev("Dinner with the Patels", "2026-10-10T18:00:00", reminder=60)], + "All three added — market, game, dinner.")), + ("m05", "multi", NOW3, + "Clinic: flu shots Saturday Nov 21 at 10am, and your follow-up is Dec 1 at 2:30pm\nMe: both noted", + plan([ev("Flu shot", "2026-11-21T10:00:00", location="Clinic", reminder=60), + ev("Follow-up appointment", "2026-12-01T14:30:00", location="Clinic", reminder=120)], + "Both on the calendar.")), + ("m06", "multi", NOW2, + "School: early release Friday Oct 9 at 12:15, then the fall social runs 5 to 7pm same day\nMe: busy friday", + plan([ev("Early release", "2026-10-09T12:15:00", location="School", reminder=120), + ev("Fall social", "2026-10-09T17:00:00", "2026-10-09T19:00:00", "School", 60)], + "Got both — pickup 12:15, social at 5.")), + ("m07", "multi", NOW3, + "Vet: Biscuit's nail trim is Nov 17 at 3pm and his dental is Dec 9 at 8am\nMe: poor guy", + plan([ev("Biscuit — nail trim", "2026-11-17T15:00:00", location="Vet", reminder=60), + ev("Biscuit — dental", "2026-12-09T08:00:00", location="Vet", reminder=120)], + "Both booked for Biscuit.")), + # --- no-event --- + ("c06", "no_event", NOW2, + "Marisol: the recital photos came out so cute\nMe: omg yes, framing them", + plan([], "Aren't they adorable? Definitely framing a few!")), + ("c07", "no_event", NOW2, + "Dev: ugh, standup ran 40 minutes today\nMe: brutal", + plan([], "Brutal — hope tomorrow's is snappier.")), + ("c08", "no_event", NOW3, + "Neighbor: leaf pile in front of ours, kids are welcome to jump in\nMe: they'll be thrilled", + plan([], "They'll be over in five minutes, thank you!")), + ("c09", "no_event", NOW3, + "Gran: loved the call yesterday, made my week\nMe: mine too", + plan([], "Made mine too — talk soon, Gran!")), + ("c10", "no_event", NOW2, + "Courier: your parcel was left at the side door\nMe: got it, thanks", + plan([], "Got it — thanks for the heads up!")), + # --- clarify (announced but unconfirmed / choices) --- + ("q05", "clarify", NOW2, + "Room parent: bake sale is being planned, date to be announced\nMe: count me in for cookies", + plan([], "I'm in for cookies — what date should I plan for?", + clarify="What date is the bake sale?")), + ("q06", "clarify", NOW2, + "Piano teacher: we should add a makeup lesson — does Tuesday or Thursday suit?\nMe: let me check", + plan([], "Let me check the calendar and get back to you.", + clarify="Tuesday or Thursday — and what time works for the makeup lesson?")), + ("q07", "clarify", NOW3, + "Uncle Ray: thinking of visiting sometime around the holidays\nMe: that would be great", + plan([], "We'd love that! Which dates are you thinking?", + clarify="Which dates would Uncle Ray visit?")), + ("q08", "clarify", NOW3, + "HOA: the annual meeting will be rescheduled, new time to come\nMe: ok", + plan([], "Thanks — I'll watch for the new time.", + clarify="What is the new date and time of the annual meeting?")), +] +EVAL = EVAL + EVAL2 + + +def main(): + out = Path(__file__).with_name("data") / "eval.jsonl" + ok, bad = [], 0 + seen_ids = set() + for rid, cat, now, thread, gold in EVAL: + assert rid not in seen_ids, f"duplicate id {rid}" + seen_ids.add(rid) + try: + ActionPlan(**gold) # validate the gold answer + except Exception as e: # noqa: BLE001 + print(f" [{rid}] GOLD INVALID: {e}"); bad += 1; continue + ok.append({"id": rid, "category": cat, "now": now, "thread": thread, "gold": gold}) + print(f"{len(ok)} valid / {len(EVAL)} ({bad} invalid)") + with out.open("w", encoding="utf-8") as f: + for r in ok: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/training/gen_new_seeds.py b/training/gen_new_seeds.py new file mode 100644 index 0000000000000000000000000000000000000000..6e7cb1d02ff262d70bbb890093d537380eeb1e51 --- /dev/null +++ b/training/gen_new_seeds.py @@ -0,0 +1,1305 @@ +"""Generate and validate 15 new synthetic training examples, then append to dataset.jsonl.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from server.agent import SYSTEM +from server.schema import ActionPlan + +NEW_SEEDS = [ + # 1. relative date: 'next Monday' + ( + "2026-06-08T09:00:00", + "Morgan: can we do a 30-min project sync next Monday at 10am?\nMe: that works for me", + { + "reasoning": "'Next Monday' from Monday Jun 8 resolves to Jun 15.", + "events": [ + { + "title": "Project sync with Morgan", + "start": "2026-06-15T10:00:00", + "end": "2026-06-15T10:30:00", + "location": None, + "attendees": ["Morgan"], + "reminder_minutes": 15, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Monday the 15th at 10 works — talk soon!", + "needs_clarification": None, + }, + ), + # 2. image-derived: OpenTable reservation screenshot + ( + "2026-06-08T09:00:00", + ( + "Chris: [image: screenshot of OpenTable confirmation — 'Reservation at Osteria Mia," + " Sat Jun 13 at 7:30pm, party of 4, confirmation #OT98271']\nMe: perfect!" + ), + { + "reasoning": "The OpenTable screenshot confirms a restaurant reservation Saturday at 7:30pm.", + "events": [ + { + "title": "Dinner at Osteria Mia", + "start": "2026-06-13T19:30:00", + "end": "2026-06-13T21:30:00", + "location": "Osteria Mia", + "attendees": ["Chris"], + "reminder_minutes": 120, + "notes": "Party of 4; confirmation #OT98271", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Added it — see you Saturday at 7:30!", + "needs_clarification": None, + }, + ), + # 3. no-event chitchat + ( + "2026-06-08T09:00:00", + "Sam: thanks so much for watching the dog last weekend!\nMe: any time, she's the best", + { + "reasoning": "A thank-you exchange, nothing to schedule.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Anytime — she was a joy!", + "needs_clarification": None, + }, + ), + # 4. multi-event: two work meetings in same thread + ( + "2026-06-08T09:00:00", + "Manager: sprint review is Tuesday at 3pm, also block 4-4:30 for retro right after\nMe: done", + { + "reasoning": "Two back-to-back work meetings on Tuesday: sprint review 3pm and retro 4pm.", + "events": [ + { + "title": "Sprint review", + "start": "2026-06-09T15:00:00", + "end": "2026-06-09T16:00:00", + "location": None, + "attendees": ["Manager"], + "reminder_minutes": 15, + "notes": None, + }, + { + "title": "Sprint retro", + "start": "2026-06-09T16:00:00", + "end": "2026-06-09T16:30:00", + "location": None, + "attendees": ["Manager"], + "reminder_minutes": 15, + "notes": None, + }, + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Blocked both — review at 3, retro at 4.", + "needs_clarification": None, + }, + ), + # 5. conflict: adjacent severity — doctor starts when team lunch ends + ( + "2026-06-08T09:00:00", + ( + "Existing calendar:\n- Team lunch: 2026-06-10T12:00..2026-06-10T13:00\n\n" + "Doctor: your 1pm appointment is confirmed for Wednesday\nMe: see you then" + ), + { + "reasoning": "The 1pm doctor appointment starts immediately after team lunch ends — adjacent.", + "events": [ + { + "title": "Doctor appointment", + "start": "2026-06-10T13:00:00", + "end": "2026-06-10T14:00:00", + "location": None, + "attendees": [], + "reminder_minutes": 60, + "notes": None, + } + ], + "conflicts": [ + { + "event_index": 0, + "clashes_with": "Team lunch 12:00-1:00pm", + "severity": "adjacent", + } + ], + "proposed_times": ["2026-06-10T14:00:00"], + "reply_draft": "Confirmed — just note it is right after team lunch so I may be a couple minutes late.", + "needs_clarification": None, + }, + ), + # 6. relative date: 'tonight' + ( + "2026-06-08T18:00:00", + "Zoe: game night at our place tonight, 8pm — you coming?\nMe: wouldn't miss it", + { + "reasoning": "'Tonight' is Jun 8; explicit time is 8pm.", + "events": [ + { + "title": "Game night at Zoe's", + "start": "2026-06-08T20:00:00", + "end": "2026-06-08T22:30:00", + "location": "Zoe's place", + "attendees": ["Zoe"], + "reminder_minutes": 30, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "See you at 8!", + "needs_clarification": None, + }, + ), + # 7. no-event chitchat + ( + "2026-06-08T09:00:00", + "Olivia: just made that lemon pasta recipe you sent — total hit!\nMe: so glad! it's a staple", + { + "reasoning": "Friendly recipe chat, no scheduling.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Right? It never gets old!", + "needs_clarification": None, + }, + ), + # 8. image-derived: after-school activity flyer + ( + "2026-06-08T09:00:00", + ( + "Room parent: [image: flyer — 'Swim Club Sign-Ups, Tuesdays & Thursdays 3:30-4:30pm" + " starting Jun 10, Lake Park Pool, $40/month']\nMe: signing Lily up!" + ), + { + "reasoning": "Flyer shows a recurring swim club starting Tuesday Jun 10 on Tues/Thurs 3:30-4:30pm.", + "events": [ + { + "title": "Swim club — Lily", + "start": "2026-06-10T15:30:00", + "end": "2026-06-10T16:30:00", + "location": "Lake Park Pool", + "attendees": [], + "reminder_minutes": 60, + "notes": "Recurring Tues & Thurs; $40/month. From flyer.", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Lily's signed up — first session Tuesday the 10th!", + "needs_clarification": None, + }, + ), + # 9. multi-event: haircut and early school release + ( + "2026-06-08T09:00:00", + ( + "Stylist: confirming your haircut Fri Jun 12 at 11am\n" + "School: reminder, Fri Jun 12 early release at 1pm\nMe: got both!" + ), + { + "reasoning": "Two distinct items on Friday: haircut at 11am and early school release at 1pm.", + "events": [ + { + "title": "Haircut", + "start": "2026-06-12T11:00:00", + "end": "2026-06-12T12:00:00", + "location": None, + "attendees": [], + "reminder_minutes": 30, + "notes": None, + }, + { + "title": "Early school release", + "start": "2026-06-12T13:00:00", + "end": None, + "location": "School", + "attendees": [], + "reminder_minutes": 60, + "notes": "Pickup at 1pm instead of usual time", + }, + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Got it — haircut at 11, school pickup at 1.", + "needs_clarification": None, + }, + ), + # 10. relative date: 'in three days' + ( + "2026-06-08T09:00:00", + "Team lead: heads up — design review deck due in three days\nMe: will have it ready", + { + "reasoning": "'In three days' from Jun 8 is Jun 11 (Thursday).", + "events": [ + { + "title": "Design review deck due", + "start": "2026-06-11T09:00:00", + "end": None, + "location": None, + "attendees": [], + "reminder_minutes": 1440, + "notes": "Deadline: three days from Jun 8", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "It'll be ready by Thursday!", + "needs_clarification": None, + }, + ), + # 11. conflict: overlap — doctor and all-hands meeting + ( + "2026-06-08T09:00:00", + ( + "Existing calendar:\n- All-hands meeting: 2026-06-09T10:00..2026-06-09T11:00\n\n" + "Doctor office: we have a 10:30am slot Tuesday for your annual physical\nMe: I'll take it" + ), + { + "reasoning": "The 10:30am appointment overlaps the 10:00-11:00 all-hands meeting.", + "events": [ + { + "title": "Annual physical", + "start": "2026-06-09T10:30:00", + "end": "2026-06-09T11:30:00", + "location": "Doctor office", + "attendees": [], + "reminder_minutes": 60, + "notes": None, + } + ], + "conflicts": [ + { + "event_index": 0, + "clashes_with": "All-hands meeting 10:00-11:00am", + "severity": "overlap", + } + ], + "proposed_times": ["2026-06-09T14:00:00", "2026-06-10T10:30:00"], + "reply_draft": "That slot overlaps my all-hands — could you do 2pm Tuesday or Wednesday morning instead?", + "needs_clarification": None, + }, + ), + # 12. no-event chitchat: wellness check-in + ( + "2026-06-08T09:00:00", + "Nina: just checking in — hope you're feeling better!\nMe: much better, thanks for asking", + { + "reasoning": "A wellness check-in, no event to schedule.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "All good now, thanks! Really appreciate you checking in.", + "needs_clarification": None, + }, + ), + # 13. image-derived: flight itinerary with two legs + ( + "2026-06-08T09:00:00", + ( + "Marcus: [image: screenshot of flight confirmation — 'AA 412 departs SFO Mon Jun 22" + " 6:00am, arrives JFK 2:30pm; return AA 721 Thu Jun 25 5:45pm JFK -> SFO']\nMe: booked!" + ), + { + "reasoning": "Flight confirmation shows two legs: outbound Jun 22 and return Jun 25.", + "events": [ + { + "title": "Flight SFO to JFK (AA 412)", + "start": "2026-06-22T06:00:00", + "end": "2026-06-22T14:30:00", + "location": "SFO -> JFK", + "attendees": [], + "reminder_minutes": 120, + "notes": "AA 412; from flight confirmation screenshot", + }, + { + "title": "Flight JFK to SFO (AA 721)", + "start": "2026-06-25T17:45:00", + "end": "2026-06-25T21:00:00", + "location": "JFK -> SFO", + "attendees": [], + "reminder_minutes": 120, + "notes": "AA 721; from flight confirmation screenshot", + }, + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Both legs added — safe travels!", + "needs_clarification": None, + }, + ), + # 14. relative date: 'this Saturday' + ( + "2026-06-08T09:00:00", + "Dan: hiking trip this Saturday, meet at the trailhead at 7am, bring layers\nMe: I'm in!", + { + "reasoning": "'This Saturday' from Monday Jun 8 is Jun 13.", + "events": [ + { + "title": "Hiking trip with Dan", + "start": "2026-06-13T07:00:00", + "end": "2026-06-13T13:00:00", + "location": "Trailhead", + "attendees": ["Dan"], + "reminder_minutes": 60, + "notes": "Bring layers", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "See you at the trailhead Saturday at 7!", + "needs_clarification": None, + }, + ), + # 15. needs clarification: vague catch-up + ( + "2026-06-08T09:00:00", + "Priya: we should catch up soon, it's been forever\nMe: yes! let's make it happen", + { + "reasoning": "Mutual intent to meet but no date, time, or location mentioned.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "So overdue! What does your week look like?", + "needs_clarification": "When and where works for a catch-up?", + }, + ), +] + + +# A second, larger batch — the same categories plus the app's headline family +# use-cases (carpool, appointments, parties/RSVP, sports, school events). main() +# dedups on append, so running this repeatedly never duplicates existing records. +# Anchor date 2026-06-08 is a Monday (Jun 9 Tue ... Jun 13 Sat, Jun 15 next Mon). +MORE_SEEDS = [ + # --- carpool & pickups --- + ( + "2026-06-08T09:00:00", + "Carpool group: I can drive the Tuesday 3:15 pickup if someone takes Thursday\nMe: I'll grab Thursday 3:15", + { + "reasoning": "I volunteered for the Thursday 3:15pm school pickup (Jun 11).", + "events": [{ + "title": "Carpool — Thursday school pickup", "start": "2026-06-11T15:15:00", + "end": "2026-06-11T15:45:00", "location": "School", "attendees": [], + "reminder_minutes": 30, "notes": "My week to drive Thursday", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Got Thursday's 3:15 pickup covered!", "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Coach Dana: carpool to Saturday's away game leaves the school lot at 8am sharp\nMe: we'll be there", + { + "reasoning": "Carpool to the away game departs Saturday Jun 13 at 8am from the school lot.", + "events": [{ + "title": "Carpool to away game", "start": "2026-06-13T08:00:00", "end": None, + "location": "School lot", "attendees": ["Coach Dana"], "reminder_minutes": 45, + "notes": "Leaves 8am sharp", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "We'll be at the lot by 8 — thanks for organizing!", "needs_clarification": None, + }, + ), + # --- appointments (doctor / dentist / vet / ortho) --- + ( + "2026-06-08T09:00:00", + "Dr. Lee's office: reminder, Maya's dental cleaning is Wed Jun 17 at 4:00pm\nMe: thanks, we'll be there", + { + "reasoning": "Dental cleaning confirmed for Wednesday Jun 17 at 4pm.", + "events": [{ + "title": "Maya — dental cleaning", "start": "2026-06-17T16:00:00", + "end": "2026-06-17T17:00:00", "location": "Dr. Lee's office", "attendees": [], + "reminder_minutes": 120, "notes": None, + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "See you Wednesday at 4!", "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Vet: Bella's vaccination appt is booked for Fri Jun 19, 10:30am\nMe: perfect", + { + "reasoning": "Vet vaccination appointment is Friday Jun 19 at 10:30am.", + "events": [{ + "title": "Bella — vet vaccination", "start": "2026-06-19T10:30:00", + "end": "2026-06-19T11:00:00", "location": "Vet", "attendees": [], + "reminder_minutes": 60, "notes": None, + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Booked — thanks!", "needs_clarification": None, + }, + ), + ( # relative date: 'next Thursday' -> Jun 18 + "2026-06-08T09:00:00", + "Clinic: Sam's check-up is next Thursday at 9:15am\nMe: see you then", + { + "reasoning": "'Next Thursday' from Monday Jun 8 is Jun 18.", + "events": [{ + "title": "Sam — check-up", "start": "2026-06-18T09:15:00", + "end": "2026-06-18T10:00:00", "location": "Clinic", "attendees": [], + "reminder_minutes": 60, "notes": None, + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Got it — Thursday the 18th at 9:15.", "needs_clarification": None, + }, + ), + ( # image-derived appointment card + "2026-06-08T09:00:00", + "Me: [image: appointment card — 'Orthodontist, Dr. Patel, Tue Jun 16, 8:00am']", + { + "reasoning": "Appointment card photo shows an orthodontist visit Tuesday Jun 16 at 8am.", + "events": [{ + "title": "Orthodontist — Dr. Patel", "start": "2026-06-16T08:00:00", + "end": "2026-06-16T08:45:00", "location": "Dr. Patel", "attendees": [], + "reminder_minutes": 60, "notes": "From appointment card", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Added the orthodontist appointment for Tuesday at 8.", "needs_clarification": None, + }, + ), + # --- parties & RSVPs --- + ( + "2026-06-08T09:00:00", + "Jenna: Leo's 7th birthday is Sat Jun 20, 2pm at Pump It Up — please RSVP by Thursday!\nMe: we're in!", + { + "reasoning": "Birthday party Saturday Jun 20 2pm; RSVP requested by Thursday Jun 18.", + "events": [{ + "title": "Leo's 7th birthday party", "start": "2026-06-20T14:00:00", + "end": "2026-06-20T16:00:00", "location": "Pump It Up", "attendees": ["Jenna"], + "reminder_minutes": 120, "notes": "RSVP by Thursday Jun 18", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "We'll be there — count us in, thanks for the invite!", "needs_clarification": None, + }, + ), + ( # image-derived invitation card + "2026-06-08T09:00:00", + "Sara: [image: birthday invitation — \"You're invited! Emma turns 5 — Sun Jun 14, 3-5pm, 12 Oak St\"]\nMe: aww, we'll come", + { + "reasoning": "Invitation card shows Emma's birthday Sunday Jun 14, 3-5pm at 12 Oak St.", + "events": [{ + "title": "Emma's 5th birthday party", "start": "2026-06-14T15:00:00", + "end": "2026-06-14T17:00:00", "location": "12 Oak St", "attendees": ["Sara"], + "reminder_minutes": 120, "notes": "From invitation card", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "We'll be there Sunday — can't wait!", "needs_clarification": None, + }, + ), + # --- sports / practices / games --- + ( # conflict: practice moved overlaps an existing lesson + "2026-06-08T09:00:00", + "Existing calendar:\n- Piano lesson: 2026-06-09T17:00..2026-06-09T17:45\n\nCoach: practice moves to Tuesday 5:30-6:30 this week\nMe: noted", + { + "reasoning": "Practice now Tuesday 5:30-6:30pm overlaps the 5:00-5:45 piano lesson.", + "events": [{ + "title": "Soccer practice", "start": "2026-06-09T17:30:00", + "end": "2026-06-09T18:30:00", "location": None, "attendees": ["Coach"], + "reminder_minutes": 30, "notes": "Rescheduled from usual time", + }], + "conflicts": [{ + "event_index": 0, "clashes_with": "Piano lesson 5:00-5:45pm", "severity": "overlap", + }], + "proposed_times": [], + "reply_draft": "Noted — heads up it overlaps piano, so we may be a few minutes late.", + "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Coach: Saturday's game is at 11am, arrive 30 minutes early at Field 4\nMe: got it", + { + "reasoning": "Game Saturday Jun 13 at 11am at Field 4; arrive by 10:30, so start there.", + "events": [{ + "title": "Soccer game", "start": "2026-06-13T10:30:00", "end": "2026-06-13T12:30:00", + "location": "Field 4", "attendees": ["Coach"], "reminder_minutes": 60, + "notes": "Game at 11:00; arrive 30 min early", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "We'll be at Field 4 by 10:30!", "needs_clarification": None, + }, + ), + # --- school events --- + ( + "2026-06-08T09:00:00", + "Room parent: Picture day is Thursday — photos at 9am, kids wear the green class shirt\nMe: thanks!", + { + "reasoning": "School picture day Thursday Jun 11 at 9am; wear green shirt.", + "events": [{ + "title": "School picture day", "start": "2026-06-11T09:00:00", "end": None, + "location": "School", "attendees": [], "reminder_minutes": 720, + "notes": "Wear green class shirt", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Got it — green shirt Thursday!", "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Teacher: your parent-teacher conference is Wed Jun 17 at 3:45pm\nMe: confirmed", + { + "reasoning": "Parent-teacher conference Wednesday Jun 17 at 3:45pm.", + "events": [{ + "title": "Parent-teacher conference", "start": "2026-06-17T15:45:00", + "end": "2026-06-17T16:15:00", "location": "School", "attendees": ["Teacher"], + "reminder_minutes": 60, "notes": None, + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Confirmed for Wednesday at 3:45.", "needs_clarification": None, + }, + ), + ( # multi-event: permission slip deadline + the trip itself + "2026-06-08T09:00:00", + "School: permission slip for the aquarium field trip (Jun 24) is due this Friday\nMe: will send it in", + { + "reasoning": "Two items: the slip is due Friday Jun 12, and the field trip is Jun 24.", + "events": [ + { + "title": "Permission slip due — aquarium trip", "start": "2026-06-12T09:00:00", + "end": None, "location": None, "attendees": [], "reminder_minutes": 1440, + "notes": "Due Friday", + }, + { + "title": "Aquarium field trip", "start": "2026-06-24T09:00:00", "end": None, + "location": None, "attendees": [], "reminder_minutes": 720, + "notes": "School field trip", + }, + ], + "conflicts": [], "proposed_times": [], + "reply_draft": "Slip going in Friday — trip's on the 24th, noted!", "needs_clarification": None, + }, + ), + ( # image-derived school newsletter + "2026-06-08T09:00:00", + "PTA: [image: newsletter — 'Spring concert Thu Jun 18 at 6:30pm in the gym']\nMe: noted", + { + "reasoning": "Newsletter screenshot announces the spring concert Thursday Jun 18 at 6:30pm.", + "events": [{ + "title": "Spring concert", "start": "2026-06-18T18:30:00", "end": "2026-06-18T20:00:00", + "location": "School gym", "attendees": [], "reminder_minutes": 120, + "notes": "From PTA newsletter", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Added the spring concert — Thursday at 6:30!", "needs_clarification": None, + }, + ), + # --- relative dates --- + ( # 'tomorrow' + "2026-06-08T09:00:00", + "Maria: book club meets tomorrow at 7pm at my place\nMe: see you there", + { + "reasoning": "'Tomorrow' from Jun 8 is Jun 9; book club at 7pm.", + "events": [{ + "title": "Book club at Maria's", "start": "2026-06-09T19:00:00", + "end": "2026-06-09T20:30:00", "location": "Maria's place", "attendees": ["Maria"], + "reminder_minutes": 60, "notes": None, + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "See you tomorrow at 7!", "needs_clarification": None, + }, + ), + ( # 'end of the month' deadline + "2026-06-08T09:00:00", + "Landlord: friendly reminder, rent is due by the end of the month\nMe: noted, thanks", + { + "reasoning": "'End of the month' is Jun 30; rent deadline.", + "events": [{ + "title": "Rent due", "start": "2026-06-30T09:00:00", "end": None, "location": None, + "attendees": [], "reminder_minutes": 1440, "notes": "Due by end of month", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Thanks — I'll have it in by the 30th.", "needs_clarification": None, + }, + ), + ( # 'a week from Friday' -> Jun 19 + "2026-06-08T09:00:00", + "Alex: let's grab dinner a week from Friday, 7pm?\nMe: works!", + { + "reasoning": "Friday is Jun 12; a week from Friday is Jun 19.", + "events": [{ + "title": "Dinner with Alex", "start": "2026-06-19T19:00:00", "end": "2026-06-19T21:00:00", + "location": None, "attendees": ["Alex"], "reminder_minutes": 60, "notes": None, + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Friday the 19th at 7 — perfect!", "needs_clarification": None, + }, + ), + ( # specific date 'the 25th' + "2026-06-08T09:00:00", + "HR: reminder that open enrollment closes the 25th\nMe: thanks for the heads up", + { + "reasoning": "'The 25th' is Jun 25; benefits deadline.", + "events": [{ + "title": "Open enrollment closes", "start": "2026-06-25T09:00:00", "end": None, + "location": None, "attendees": [], "reminder_minutes": 1440, "notes": "Benefits deadline", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Thanks — I'll finish enrollment before the 25th.", "needs_clarification": None, + }, + ), + # --- conflicts: tight + overlap --- + ( # tight: 10-minute gap after an existing appointment + "2026-06-08T09:00:00", + "Existing calendar:\n- Dentist: 2026-06-09T09:00..2026-06-09T09:45\n\nVendor: can we do coffee tomorrow at 9:55?\nMe: sure", + { + "reasoning": "Coffee at 9:55 leaves only 10 minutes after the 9:45 dentist finish — tight.", + "events": [{ + "title": "Coffee with vendor", "start": "2026-06-09T09:55:00", "end": "2026-06-09T10:30:00", + "location": None, "attendees": ["Vendor"], "reminder_minutes": 15, "notes": None, + }], + "conflicts": [{ + "event_index": 0, "clashes_with": "Dentist 9:00-9:45am", "severity": "tight", + }], + "proposed_times": ["2026-06-09T11:00:00"], + "reply_draft": "9:55 is tight right after the dentist — could we make it 11 to be safe?", + "needs_clarification": None, + }, + ), + ( # overlap with a proposed alternative + "2026-06-08T17:00:00", + "Existing calendar:\n- Yoga: 2026-06-08T18:00..2026-06-08T19:00\n\nFriend: dinner tonight at 6:30?\nMe: let me check", + { + "reasoning": "Dinner at 6:30 overlaps the 6-7pm yoga class.", + "events": [{ + "title": "Dinner with friend", "start": "2026-06-08T18:30:00", "end": "2026-06-08T20:00:00", + "location": None, "attendees": ["Friend"], "reminder_minutes": 30, "notes": None, + }], + "conflicts": [{ + "event_index": 0, "clashes_with": "Yoga 6:00-7:00pm", "severity": "overlap", + }], + "proposed_times": ["2026-06-08T19:30:00"], + "reply_draft": "6:30 clashes with my yoga — could we push to 7:30?", "needs_clarification": None, + }, + ), + # --- multi-event threads --- + ( + "2026-06-08T09:00:00", + "Spouse: Lily has ballet Wed 4pm and Noah has karate Wed 5:30pm\nMe: adding both", + { + "reasoning": "Two kids' activities Wednesday Jun 10: ballet 4pm and karate 5:30pm.", + "events": [ + { + "title": "Lily — ballet", "start": "2026-06-10T16:00:00", "end": "2026-06-10T17:00:00", + "location": None, "attendees": [], "reminder_minutes": 30, "notes": None, + }, + { + "title": "Noah — karate", "start": "2026-06-10T17:30:00", "end": "2026-06-10T18:30:00", + "location": None, "attendees": [], "reminder_minutes": 30, "notes": None, + }, + ], + "conflicts": [], "proposed_times": [], + "reply_draft": "Both on the calendar — ballet at 4, karate at 5:30.", "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Family chat: Saturday plan — farmers market 9am, Mia's recital 2pm, dinner at grandma's 6pm\nMe: busy day!", + { + "reasoning": "Three Saturday Jun 13 events: market 9am, recital 2pm, dinner 6pm.", + "events": [ + { + "title": "Farmers market", "start": "2026-06-13T09:00:00", "end": "2026-06-13T10:30:00", + "location": None, "attendees": [], "reminder_minutes": 30, "notes": None, + }, + { + "title": "Mia's recital", "start": "2026-06-13T14:00:00", "end": "2026-06-13T15:30:00", + "location": None, "attendees": [], "reminder_minutes": 60, "notes": None, + }, + { + "title": "Dinner at grandma's", "start": "2026-06-13T18:00:00", "end": "2026-06-13T20:00:00", + "location": "Grandma's", "attendees": [], "reminder_minutes": 60, "notes": None, + }, + ], + "conflicts": [], "proposed_times": [], + "reply_draft": "Added all three — market, recital, then dinner. Busy day indeed!", + "needs_clarification": None, + }, + ), + # --- no-event chitchat --- + ( + "2026-06-08T09:00:00", + "Ben: did you catch the game last night? what a finish\nMe: unreal, still buzzing", + { + "reasoning": "Sports chat, nothing to schedule.", + "events": [], "conflicts": [], "proposed_times": [], + "reply_draft": "Best finish all season — still can't believe it!", "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Aunt Carol: happy Monday! hope the week's off to a good start\nMe: you too, thanks!", + { + "reasoning": "A friendly greeting, no event.", + "events": [], "conflicts": [], "proposed_times": [], + "reply_draft": "Appreciate it — hope yours is great too!", "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Neighbor: thanks again for the plumber recommendation, he was great\nMe: so glad it worked out", + { + "reasoning": "A thank-you exchange, nothing to schedule.", + "events": [], "conflicts": [], "proposed_times": [], + "reply_draft": "Happy to help — he's saved us a few times!", "needs_clarification": None, + }, + ), + # --- needs clarification --- + ( + "2026-06-08T09:00:00", + "Coach: extra practice this week, I'll confirm the day soon\nMe: ok, keep me posted", + { + "reasoning": "An extra practice is implied but no day or time is given yet.", + "events": [], "conflicts": [], "proposed_times": [], + "reply_draft": "Sounds good — just let me know the day and time.", + "needs_clarification": "Which day and time is the extra practice?", + }, + ), + ( + "2026-06-08T09:00:00", + "Coworker: lunch sometime next week?\nMe: yes, definitely!", + { + "reasoning": "Lunch intent for 'next week' but no specific day or time.", + "events": [], "conflicts": [], "proposed_times": [], + "reply_draft": "I'm in — what day next week works for you?", + "needs_clarification": "What day and time next week works for lunch?", + }, + ), + # --- recurring + deadline --- + ( + "2026-06-08T09:00:00", + "Camp: drop-off is 8:30am daily, Jun 22-26, at Riverside Center\nMe: thanks!", + { + "reasoning": "Summer camp drop-off 8:30am each weekday Jun 22-26; anchor the first day.", + "events": [{ + "title": "Summer camp drop-off", "start": "2026-06-22T08:30:00", "end": None, + "location": "Riverside Center", "attendees": [], "reminder_minutes": 30, + "notes": "Daily Jun 22-26", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Got it — drop-off at 8:30 starting Monday the 22nd.", "needs_clarification": None, + }, + ), + ( + "2026-06-08T16:00:00", + "Pharmacy: your prescription is ready for pickup; we close at 8pm today\nMe: I'll swing by after work", + { + "reasoning": "Prescription pickup today before the 8pm close; plan an after-work pickup.", + "events": [{ + "title": "Pick up prescription", "start": "2026-06-08T18:00:00", "end": None, + "location": "Pharmacy", "attendees": [], "reminder_minutes": 30, + "notes": "Pharmacy closes at 8pm", + }], + "conflicts": [], "proposed_times": [], + "reply_draft": "Thanks — I'll grab it on my way home.", "needs_clarification": None, + }, + ), +] + + +# Batch 3 (2026-06-09): far-future EXPLICIT-date events (Oct-Dec 2026) to fix the +# "206" dropped-year-digit bug the eval exposed on next-month dates. DISJOINT from +# training/data/eval.jsonl (different scenarios/dates) to avoid test-set leakage. +# Reinforces full 4-digit years + 2nd legs of multi-event threads + simple events. +_NOW3 = "2026-09-14T09:00:00" + + +def _e3(title, start, end=None, location=None, reminder=None, notes=None): + return {"title": title, "start": start, "end": end, "location": location, + "attendees": [], "reminder_minutes": reminder, "notes": notes} + + +def _p3(events, reply, reasoning): + return {"reasoning": reasoning, "events": events, "conflicts": [], + "proposed_times": [], "reply_draft": reply, "needs_clarification": None} + + +MORE_SEEDS2 = [ + (_NOW3, "Dr. Reyes: your annual physical is November 10 at 2:45pm\nMe: thanks", + _p3([_e3("Annual physical", "2026-11-10T14:45:00", location="Dr. Reyes", reminder=120)], + "See you November 10 at 2:45.", "Physical on Nov 10, 2026 at 2:45pm.")), + (_NOW3, "Studio: pottery class starts October 13 at 6pm\nMe: signed up!", + _p3([_e3("Pottery class", "2026-10-13T18:00:00", reminder=60)], + "Can't wait — see you the 13th!", "Pottery class begins Oct 13, 2026 at 6pm.")), + (_NOW3, "Coach: playoffs are November 14 at 10am at Field 3\nMe: we'll be there", + _p3([_e3("Playoffs", "2026-11-14T10:00:00", location="Field 3", reminder=60)], + "See you at Field 3 on the 14th!", "Playoff game Nov 14, 2026 at 10am.")), + (_NOW3, "HR: open enrollment briefing is November 3 at 1pm\nMe: noted", + _p3([_e3("Open enrollment briefing", "2026-11-03T13:00:00", reminder=60)], + "Got it — November 3 at 1.", "Briefing Nov 3, 2026 at 1pm.")), + (_NOW3, "Vet: Cooper's dental cleaning is October 20 at 11:45am\nMe: perfect", + _p3([_e3("Cooper — dental cleaning", "2026-10-20T11:45:00", location="Vet", reminder=120)], + "Booked — the 20th at 11:45.", "Vet dental Oct 20, 2026 at 11:45am.")), + (_NOW3, "PTA: fall festival is October 24, 5 to 8pm at the school\nMe: we'll come", + _p3([_e3("Fall festival", "2026-10-24T17:00:00", "2026-10-24T20:00:00", "School", 120)], + "See you at the festival on the 24th!", "Festival Oct 24, 2026, 5-8pm at school.")), + (_NOW3, "Civic Center: the holiday recital is December 12 at 2pm\nMe: adding it", + _p3([_e3("Holiday recital", "2026-12-12T14:00:00", location="Civic Center", reminder=120)], + "Added — December 12 at 2.", "Recital Dec 12, 2026 at 2pm.")), + (_NOW3, "Manager: Q4 planning offsite is December 1 at 9am\nMe: blocked", + _p3([_e3("Q4 planning offsite", "2026-12-01T09:00:00", reminder=120)], + "Blocked December 1 at 9.", "Offsite Dec 1, 2026 at 9am.")), + (_NOW3, "School: parent conferences are November 5 at 4:15pm\nMe: confirmed", + _p3([_e3("Parent conference", "2026-11-05T16:15:00", location="School", reminder=60)], + "Confirmed for the 5th at 4:15.", "Conference Nov 5, 2026 at 4:15pm.")), + (_NOW3, "Coach: the team banquet is November 21 at 6pm at Rosa's\nMe: count us in", + _p3([_e3("Team banquet", "2026-11-21T18:00:00", location="Rosa's", reminder=120)], + "We'll be at Rosa's on the 21st!", "Banquet Nov 21, 2026 at 6pm.")), + (_NOW3, "Clinic: your lab follow-up is October 29 at 10:30am\nMe: see you", + _p3([_e3("Lab follow-up", "2026-10-29T10:30:00", location="Clinic", reminder=60)], + "See you the 29th at 10:30.", "Follow-up Oct 29, 2026 at 10:30am.")), + (_NOW3, "Dance: the winter showcase is December 5 at 3:30pm\nMe: noted", + _p3([_e3("Winter showcase", "2026-12-05T15:30:00", reminder=120)], + "Added the showcase — Dec 5 at 3:30.", "Showcase Dec 5, 2026 at 3:30pm.")), + (_NOW3, "Library: there's an author talk November 18 at 7pm\nMe: I'll go", + _p3([_e3("Author talk", "2026-11-18T19:00:00", location="Library", reminder=60)], + "See you at the library on the 18th!", "Author talk Nov 18, 2026 at 7pm.")), + (_NOW3, "Orthodontist: next adjustment is October 27 at 8:00am\nMe: got it", + _p3([_e3("Orthodontist adjustment", "2026-10-27T08:00:00", location="Orthodontist", reminder=60)], + "Got it — the 27th at 8.", "Adjustment Oct 27, 2026 at 8am.")), + (_NOW3, "Spouse: October 9 — Mia has the orthodontist at 9am and her piano recital at 5pm\nMe: adding both", + _p3([_e3("Mia — orthodontist", "2026-10-09T09:00:00", reminder=60), + _e3("Mia — piano recital", "2026-10-09T17:00:00", reminder=60)], + "Both added — orthodontist 9, recital 5.", "Two events Oct 9, 2026: 9am and 5pm.")), + (_NOW3, "Office: November 12 — sprint review at 11am and retro at 3pm\nMe: blocked both", + _p3([_e3("Sprint review", "2026-11-12T11:00:00", reminder=15), + _e3("Sprint retro", "2026-11-12T15:00:00", reminder=15)], + "Blocked both — review 11, retro 3.", "Two meetings Nov 12, 2026: 11am and 3pm.")), + (_NOW3, "Salon: your color appointment is November 7 at 1:45pm\nMe: thanks", + _p3([_e3("Salon — color appointment", "2026-11-07T13:45:00", location="Salon", reminder=120)], + "See you the 7th at 1:45.", "Salon Nov 7, 2026 at 1:45pm.")), + (_NOW3, "School: early dismissal is November 25 at 12:30pm\nMe: got it", + _p3([_e3("Early dismissal", "2026-11-25T12:30:00", location="School", reminder=120, + notes="Pickup at 12:30 instead of usual time")], + "Got it — pickup at 12:30 on the 25th.", "Early dismissal Nov 25, 2026 at 12:30pm.")), +] + + +# Batch 4 (2026-06-09): DIVERSITY batch — varied date/time formats, reschedules, +# cancellations, recurring, all-day, deadlines, past/hypothetical, richer no-event +# & clarify, varied image sources. Counters the template-heavy skew that made the +# v2 retrain overfit. Anchor 2026-09-14 = Monday. Disjoint from eval.jsonl. +def _pq(reply, clarify, reasoning): # ambiguous -> ask, no events + return {"reasoning": reasoning, "events": [], "conflicts": [], + "proposed_times": [], "reply_draft": reply, "needs_clarification": clarify} + + +def _pn(reply, reasoning): # nothing to schedule + return {"reasoning": reasoning, "events": [], "conflicts": [], + "proposed_times": [], "reply_draft": reply, "needs_clarification": None} + + +_N = "2026-09-14T09:00:00" +MORE_SEEDS3 = [ + # reschedules + (_N, "Coach: practice is moved from Tuesday to Thursday, still 5pm\nMe: ok", + _p3([_e3("Soccer practice", "2026-09-17T17:00:00", reminder=30, notes="Moved from Tuesday")], + "Got it — Thursday at 5.", "Practice moved to Thu Sep 17, 2026 at 5pm.")), + (_N, "Dr. office: we need to move your Friday appointment to Monday Sept 21 at 10am\nMe: that works", + _p3([_e3("Doctor appointment", "2026-09-21T10:00:00", location="Dr. office", reminder=60)], + "Monday the 21st at 10 works.", "Appointment moved to Mon Sep 21, 2026 at 10am.")), + # cancellations -> nothing to schedule + (_N, "School: tomorrow's assembly is cancelled\nMe: thanks for letting me know", + _pn("Thanks for the heads up!", "An assembly was cancelled — nothing to schedule.")), + (_N, "Coach: this Saturday's game is called off due to weather\nMe: ok, stay dry", + _pn("No problem — see everyone next time!", "Game cancelled — nothing to add.")), + # recurring + (_N, "Studio: yoga is every Monday at 6pm starting next week\nMe: signing up", + _p3([_e3("Yoga", "2026-09-21T18:00:00", reminder=30, notes="Recurring weekly on Mondays")], + "See you Monday at 6!", "Weekly yoga starts Mon Sep 21, 2026 at 6pm.")), + (_N, "School: chess club meets Wednesdays 3:30 to 4:30, starting September 16\nMe: great", + _p3([_e3("Chess club", "2026-09-16T15:30:00", "2026-09-16T16:30:00", reminder=30, + notes="Recurring Wednesdays")], + "Added — Wednesdays at 3:30.", "Weekly chess club from Wed Sep 16, 2026, 3:30-4:30pm.")), + # all-day / no time + (_N, "School: no school on Sept 25 (teacher in-service)\nMe: noted", + _p3([_e3("No school (teacher in-service)", "2026-09-25T00:00:00", notes="All day")], + "Noted — no school the 25th.", "All-day no-school on Sep 25, 2026.")), + (_N, "Library: the book sale is the 20th, all day\nMe: I'll stop by", + _p3([_e3("Library book sale", "2026-09-20T00:00:00", location="Library", notes="All day")], + "I'll swing by on the 20th!", "All-day book sale on Sep 20, 2026.")), + # varied date formats + (_N, "Dentist: see you 10/15 at 2pm\nMe: confirmed", + _p3([_e3("Dentist", "2026-10-15T14:00:00", location="Dentist", reminder=120)], + "Confirmed — 10/15 at 2.", "'10/15' = Oct 15, 2026 at 2pm.")), + (_N, "Jordan: party on the 3rd, 7pm at my place\nMe: I'll be there", + _p3([_e3("Party at Jordan's", "2026-10-03T19:00:00", location="Jordan's place", reminder=120)], + "See you on the 3rd at 7!", "Next '3rd' from Sep 14 is Oct 3, 2026 at 7pm.")), + (_N, "Mom: lunch this Saturday at noon\nMe: yes!", + _p3([_e3("Lunch with Mom", "2026-09-19T12:00:00", reminder=60)], + "Saturday noon — see you!", "This Saturday = Sep 19, 2026, noon.")), + # varied time formats + (_N, "Coach: quick call at half past 7 tonight\nMe: ok", + _p3([_e3("Call with coach", "2026-09-14T19:30:00", reminder=15)], + "Talk at 7:30!", "'Half past 7 tonight' = Sep 14, 2026 at 7:30pm.")), + (_N, "Clinic: your appointment is at noon on Friday\nMe: see you", + _p3([_e3("Clinic appointment", "2026-09-18T12:00:00", location="Clinic", reminder=60)], + "See you Friday at noon.", "Noon Friday = Sep 18, 2026 at 12:00.")), + (_N, "Team: standup at 0900 tomorrow\nMe: got it", + _p3([_e3("Standup", "2026-09-15T09:00:00", reminder=15)], + "See you at 9.", "'0900 tomorrow' = Sep 15, 2026 at 9am.")), + # durations / ranges + (_N, "Trainer: session Thursday 6 to 7:30am\nMe: booked", + _p3([_e3("Training session", "2026-09-17T06:00:00", "2026-09-17T07:30:00", reminder=30)], + "Booked — Thursday 6am.", "Thu Sep 17, 2026, 6-7:30am.")), + (_N, "Office: 2-hour workshop Monday at 1pm\nMe: blocked", + _p3([_e3("Workshop", "2026-09-21T13:00:00", "2026-09-21T15:00:00", reminder=30)], + "Blocked 1-3 Monday.", "2-hour workshop Mon Sep 21, 2026, 1-3pm.")), + # deadlines + (_N, "Prof: essay due Friday at midnight\nMe: on it", + _p3([_e3("Essay due", "2026-09-18T23:59:00", reminder=1440)], + "It'll be in by Friday night!", "Friday midnight deadline = Sep 18, 2026 23:59.")), + (_N, "Accountant: I need the tax docs by the 30th\nMe: will send", + _p3([_e3("Tax docs due", "2026-09-30T09:00:00", reminder=1440)], + "Sending the docs before the 30th.", "Deadline the 30th = Sep 30, 2026.")), + # clarify (missing info / choice) + (_N, "Coach: there's a game next week, time TBD\nMe: keep me posted", + _pq("Sounds good — let me know the details.", + "What day and time is next week's game?", "Game intended but no day/time.")), + (_N, "Riley: movie this weekend?\nMe: yes!", + _pq("I'm in — what works for you?", + "Which day and time this weekend for the movie?", "No specific day/time given.")), + (_N, "Sitter: I can do Friday or Saturday — which do you need?\nMe: let me check the calendar", + _pq("Let me confirm and get back to you.", + "Do you need the sitter Friday or Saturday, and what time?", "Two options, none chosen.")), + (_N, "Boss: we should sync soon on the roadmap\nMe: agreed", + _pq("Agreed — when works for you?", + "What day and time works to sync on the roadmap?", "Intent to meet, no time.")), + # no-event (chitchat / logistics / past / hypothetical) + (_N, "Dad: traffic was brutal on the commute today\nMe: ugh, same", + _pn("Right? Hope tomorrow's better.", "Commute venting — nothing to schedule.")), + (_N, "Neighbor: your package was delivered to my door, grab it anytime\nMe: thanks!", + _pn("Thank you — I'll pop over!", "A package note, no calendar event.")), + (_N, "Mom: the recital last night was just wonderful\nMe: wasn't it!", + _pn("It really was — so proud!", "Past event being discussed; nothing to add.")), + (_N, "Coach: great game yesterday, team!\nMe: thanks coach", + _pn("Thanks, Coach — proud of the team!", "Past game; nothing to schedule.")), + (_N, "Wes: we should maybe plan a trip sometime\nMe: for sure, one day", + _pq("Love that idea — want to pick some dates?", + "Do you want to choose dates for the trip?", "Vague hypothetical, no commitment.")), + # image-derived variety + (_N, "Mike: [image: concert ticket — 'The Lumineers, Sat Oct 17, 8pm, Red Rocks']\nMe: so hyped", + _p3([_e3("The Lumineers concert", "2026-10-17T20:00:00", location="Red Rocks", reminder=180, + notes="From ticket")], + "Can't wait — Oct 17!", "Ticket shows Sat Oct 17, 2026 8pm at Red Rocks.")), + (_N, "HR: [image: invite screenshot — 'Town Hall, Thu Sept 24, 12-1pm']\nMe: noted", + _p3([_e3("Town Hall", "2026-09-24T12:00:00", "2026-09-24T13:00:00", reminder=30, + notes="From invite")], + "Added the Town Hall.", "Invite: Thu Sep 24, 2026, 12-1pm.")), + (_N, "School: [image: notice — 'Report card pickup Tuesday Sept 22, 3-6pm']\nMe: will come", + _p3([_e3("Report card pickup", "2026-09-22T15:00:00", "2026-09-22T18:00:00", location="School", + reminder=120, notes="From notice")], + "I'll come by Tuesday.", "Notice: Tue Sep 22, 2026, 3-6pm.")), + # multi-event, varied + (_N, "Spouse: busy Thursday — Leo dentist 8am, my haircut 1pm, dinner with the Patels 7pm\nMe: adding all", + _p3([_e3("Leo — dentist", "2026-09-17T08:00:00", reminder=60), + _e3("Haircut", "2026-09-17T13:00:00", reminder=30), + _e3("Dinner with the Patels", "2026-09-17T19:00:00", reminder=60)], + "All three added for Thursday!", "Three events Thu Sep 17, 2026: 8am, 1pm, 7pm.")), + (_N, "Office: Friday — interviews at 10am and 2pm, then happy hour at 5pm\nMe: blocked it all", + _p3([_e3("Interview", "2026-09-18T10:00:00", reminder=15), + _e3("Interview", "2026-09-18T14:00:00", reminder=15), + _e3("Happy hour", "2026-09-18T17:00:00", reminder=30)], + "Blocked all three Friday.", "Three events Fri Sep 18, 2026: 10am, 2pm, 5pm.")), + # confirmation of an event with a time + (_N, "Dentist: confirming your appointment is all set for tomorrow at 3\nMe: thanks", + _p3([_e3("Dentist appointment", "2026-09-15T15:00:00", location="Dentist", reminder=120)], + "Thanks — see you at 3 tomorrow.", "Confirmed tomorrow Sep 15, 2026 at 3pm.")), + (_N, "Pharmacy: flu shots walk-in this Saturday 9am to 4pm\nMe: I'll come early", + _p3([_e3("Flu shot (walk-in)", "2026-09-19T09:00:00", "2026-09-19T16:00:00", location="Pharmacy", + reminder=60, notes="Walk-in window")], + "I'll come Saturday morning!", "Walk-in Sat Sep 19, 2026, 9am-4pm.")), + (_N, "Work: the deadline for expense reports is end of day Wednesday\nMe: noted", + _p3([_e3("Expense reports due", "2026-09-16T17:00:00", reminder=120)], + "I'll submit by Wednesday EOD.", "EOD Wednesday = Sep 16, 2026, ~5pm.")), +] + + +# Batch 5 (2026-06-10): targeted reinforcement from eval analysis. (a) "date TBD" → +# clarify, don't schedule (E4B fine-tune slipped on this); (b) "next " = the DOW +# of NEXT week (+7..13 days), never the immediately-next occurrence — SMCalFlow's +# conflicting labels are filtered by import_smcalflow.py, these teach our convention. +# Anchor _N = Monday 2026-09-14. +MORE_SEEDS4 = [ + # (a) TBD / unconfirmed → needs_clarification, no events + (_N, "Band director: we're adding an extra rehearsal, date still TBD\nMe: ok, keep me posted", + _pq("Sounds good — just tell me the date when it's set.", + "What date and time is the extra rehearsal?", + "A rehearsal is planned but its date is TBD — ask, don't schedule.")), + (_N, "Lia: dinner party at ours soon — date TBD, will text you details\nMe: sounds fun!", + _pq("Can't wait — let me know when you pick a date!", + "What date and time is the dinner party?", + "Party intent but the date is TBD.")), + (_N, "PM: kickoff call happening sometime next week, time still TBD\nMe: noted", + _pq("Noted — send the invite once the time is set.", + "What day and time next week is the kickoff call?", + "Kickoff is next week but unconfirmed — clarify.")), + # (b) next = DOW of next week (from Monday Sep 14, 2026) + (_N, "Dentist: your cleaning moved to next Wednesday at 2pm\nMe: ok", + _p3([_e3("Dental cleaning", "2026-09-23T14:00:00", location="Dentist", reminder=120)], + "Got it — Wednesday the 23rd at 2.", + "'Next Wednesday' from Monday Sep 14 is Sep 23 (next week), not Sep 16.")), + (_N, "Gym: your fitness assessment is next Friday at 7am\nMe: see you then", + _p3([_e3("Fitness assessment", "2026-09-25T07:00:00", location="Gym", reminder=60)], + "See you Friday the 25th at 7.", + "'Next Friday' from Monday Sep 14 is Sep 25 (next week), not Sep 18.")), + (_N, "Trainer: program review next Monday at 8am\nMe: works for me", + _p3([_e3("Program review", "2026-09-21T08:00:00", reminder=60)], + "Monday the 21st at 8 — works.", + "'Next Monday' from Monday Sep 14 is Sep 21.")), + (_N, "Mara: brunch next Saturday at 10:30?\nMe: in!", + _p3([_e3("Brunch with Mara", "2026-09-26T10:30:00", reminder=60)], + "Saturday the 26th at 10:30 — see you!", + "'Next Saturday' from Monday Sep 14 is Sep 26 (next week), not Sep 19.")), +] + + +# Batch 6: more "announced but unconfirmed -> ASK" variants (the one clarify case the +# E4B fine-tune kept missing). Shape: someone says a thing WILL happen but the day/time +# is not locked, user acknowledges — correct plan = no events + needs_clarification. +MORE_SEEDS5 = [ + (_N, "Coach Kim: we may add a scrimmage this week, I'll let you know the day\nMe: keep me posted", + _pq("Will do — just send the day when you have it.", + "Which day is the scrimmage?", + "A scrimmage is planned but the day is unannounced — ask, don't schedule.")), + (_N, "School office: there's a schedule change coming next week, details to follow\nMe: ok thanks", + _pq("Thanks — I'll watch for the details.", + "What is changing in next week's schedule, and when?", + "A change is announced with no specifics — clarify.")), + (_N, "Dr. Shah's office: we need to reschedule your appointment, we'll call with options\nMe: ok", + _pq("Sounds good — I'll wait for the options.", + "What new date and time options work for the appointment?", + "Reschedule pending with no new time — ask, don't schedule.")), + (_N, "Manager: planning a team offsite later this month, date not locked yet\nMe: sounds good", + _pq("Great — let me know once the date is locked.", + "What date is the team offsite?", + "Offsite intent without a date — clarify.")), +] + + +# Batch 7: the two extraction shapes the E4B fine-tune still fumbles. (a) "next " +# = that DOW of NEXT week (from Monday Sep 14: next Tuesday = Sep 22, NOT Sep 15); +# (b) "X opens/launches on DATE at TIME" is a real, schedulable event — don't abstain. +MORE_SEEDS6 = [ + (_N, "Karate studio: the belt test moved to next Tuesday at 4pm\nMe: he'll be ready", + _p3([_e3("Belt test", "2026-09-22T16:00:00", location="Karate studio", reminder=60)], + "Tuesday the 22nd at 4 — he'll be ready!", + "'Next Tuesday' from Monday Sep 14 is Sep 22 (next week), not Sep 15.")), + (_N, "Hana: coffee next Thursday at 9?\nMe: yes!", + _p3([_e3("Coffee with Hana", "2026-09-24T09:00:00", reminder=30)], + "Thursday the 24th at 9 — see you!", + "'Next Thursday' from Monday Sep 14 is Sep 24 (next week), not Sep 17.")), + (_N, "Landlord: maintenance visit next Wednesday at 10am\nMe: I'll be home", + _p3([_e3("Maintenance visit", "2026-09-23T10:00:00", reminder=60)], + "I'll be home Wednesday the 23rd at 10.", + "'Next Wednesday' from Monday Sep 14 is Sep 23 (next week), not Sep 16.")), + (_N, "Box office: ticket sales open Friday Sept 25 at 10am\nMe: ready to pounce", + _p3([_e3("Ticket sales open", "2026-09-25T10:00:00", reminder=30)], + "Alarm set for the 25th at 10!", + "A sales-open time is a schedulable event: Fri Sep 25, 2026 at 10am.")), + (_N, "School: kindergarten enrollment opens September 21 at 8am\nMe: calendar'd", + _p3([_e3("Kindergarten enrollment opens", "2026-09-21T08:00:00", location="School", reminder=60)], + "On the calendar — the 21st at 8.", + "Enrollment opening Sep 21, 2026 at 8am is an event, not chitchat.")), + (_N, "Farmers co-op: winter CSA signups open October 1 at noon\nMe: in for a share", + _p3([_e3("CSA signups open", "2026-10-01T12:00:00", reminder=60)], + "Signing up Oct 1 at noon!", + "Signups opening Oct 1, 2026 at 12pm — schedule it.")), +] + + +# Batch 8: duration -> end conversion (A/B eval 2026-06-12 found end-exact 0.0 when the +# end is stated as a duration, not a clock range). end = start + duration; for a range +# like "30-45 min" use the LOWER bound; never invent a duration that wasn't given. +MORE_SEEDS7 = [ + (_N, "Clinic: your intake visit is Tuesday September 22 at 10:30 AM\nDuration: approx. 30–45 min\nLocation: 4 Birch Rd, Suite 1", + _p3([_e3("Intake visit", "2026-09-22T10:30:00", end="2026-09-22T11:00:00", + location="4 Birch Rd, Suite 1", reminder=60)], + "Confirmed for Tuesday at 10:30.", + "Duration 'approx. 30-45 min' -> end = start + 30 min (lower bound).")), + (_N, "Coach: scrimmage Saturday Sept 19 9am, should run about 90 minutes\nMe: we'll be there", + _p3([_e3("Scrimmage", "2026-09-19T09:00:00", end="2026-09-19T10:30:00", reminder=60)], + "See you Saturday at 9!", + "'About 90 minutes' -> end = 10:30.")), + (_N, "Salon: confirming Friday Sept 18 at 1pm, please allow 2 hours\nMe: confirmed", + _p3([_e3("Salon appointment", "2026-09-18T13:00:00", end="2026-09-18T15:00:00", reminder=60)], + "Confirmed for Friday at 1.", + "'Allow 2 hours' -> end = 15:00.")), + (_N, "Tutor: session moved to Wednesday 4pm this week, still 45 min\nMe: works", + _p3([_e3("Tutoring session", "2026-09-16T16:00:00", end="2026-09-16T16:45:00", reminder=30)], + "Wednesday at 4 works.", + "'Still 45 min' -> end = 16:45.")), + (_N, "HOA: annual meeting Thursday September 24, 7pm, runs an hour, clubhouse\nMe: noted", + _p3([_e3("HOA annual meeting", "2026-09-24T19:00:00", end="2026-09-24T20:00:00", + location="Clubhouse", reminder=60)], + "On the calendar for Thursday at 7.", + "'Runs an hour' -> end = 20:00.")), + (_N, "Recital hall: dress rehearsal Sunday Sept 20 at 2:00 PM (plan for three hours)\nMe: got it", + _p3([_e3("Dress rehearsal", "2026-09-20T14:00:00", end="2026-09-20T17:00:00", + location="Recital hall", reminder=120)], + "Sunday at 2 — we'll plan for three hours.", + "'Plan for three hours' -> end = 17:00.")), +] + + +# Batch 9: title style. Echo-prone inputs (rambling announcements, forwarded +# confirmations, flyers) where the title must be an action+subject SUMMARY as it +# should read on a calendar, not a quote of the first line of the message. +MORE_SEEDS8 = [ + (_N, "Hey everyone!!! don't forget we're collecting box tops AND there's a bake sale friday sept 18 at 3pm by the gym doors\nMe: I'll bring cookies", + _p3([_e3("Bake sale — bring cookies", "2026-09-18T15:00:00", + location="By the gym doors", reminder=120)], + "Cookies coming Friday at 3!", + "The calendar entry is the action (bake sale, bring cookies), not the rambling announcement.")), + (_N, "Fwd: Order #88123 ready for pickup\nYour custom frame will be ready Wednesday September 16 after 11am at FrameWorks, 9 Mill Ave\nMe: grabbing it wed", + _p3([_e3("Pick up custom frame — FrameWorks", "2026-09-16T11:00:00", + location="FrameWorks, 9 Mill Ave", reminder=60, + notes="Order #88123")], + "Picking it up Wednesday after 11.", + "Title summarizes the user's action (pick up the frame), not the email subject line.")), + (_N, "Carpool note: it's your turn to drive the kids to robotics Tuesday Sept 15, leaves school 3:45\nMe: on it", + _p3([_e3("Drive robotics carpool", "2026-09-15T15:45:00", + location="School", reminder=30)], + "I've got the robotics run Tuesday at 3:45.", + "Title is the action the user must take (drive the carpool).")), + (_N, "Reminder from Paws Clinic: Biscuit is due for boosters. We have Friday Sept 18 2:20pm open. Reply YES to book\nMe: YES", + _p3([_e3("Biscuit — booster shots", "2026-09-18T14:20:00", + location="Paws Clinic", reminder=60)], + "YES", + "Subject + action ('Biscuit — booster shots'), not the SMS wording.")), + (_N, "GARAGE SALE — everything must go!\nSaturday September 19, 7am-1pm\n14 Larch Ct", + _p3([_e3("Garage sale — 14 Larch Ct", "2026-09-19T07:00:00", + end="2026-09-19T13:00:00", location="14 Larch Ct", reminder=60)], + "Stopping by the garage sale Saturday morning.", + "Flyer headline trimmed to a calendar-ready summary with the place.")), + (_N, "Mrs. Alvarez: parent-teacher conferences are coming up, your slot is Monday September 21 at 5:10 PM in room 12\nMe: see you then", + _p3([_e3("Parent-teacher conference — room 12", "2026-09-21T17:10:00", + location="Room 12", reminder=60)], + "See you Monday at 5:10.", + "Title names the event and where, not the teacher's full sentence.")), +] + + +# Batch 10: arrival-time-aware starts + type-based reminders. "Arrive N min early" +# -> start = arrival time, end anchored to the STATED time, stated time + reason in +# notes. Reminders: explicit lead time wins; else medical 60 / party 30 / +# carpool-school 45; else judgment. +MORE_SEEDS9 = [ + (_N, "Dr. Mercer's office: your physical is Thursday September 24 at 9:00 AM\nDuration: 30 min\nPlease arrive 10 minutes early to update paperwork", + _p3([_e3("Physical — Dr. Mercer", "2026-09-24T08:50:00", end="2026-09-24T09:30:00", + reminder=60, notes="Appointment at 9:00; arrive 10 min early for paperwork")], + "Confirmed — I'll be there at 8:50.", + "Start = arrival 8:50 (9:00 minus 10); end from the stated 9:00 + 30 min; medical -> 60-min reminder.")), + (_N, "Smile Dental: cleaning for Ava on Wednesday September 23 at 2:00 PM\nMe: she'll be there", + _p3([_e3("Ava — dental cleaning", "2026-09-23T14:00:00", location="Smile Dental", reminder=60)], + "Ava will be there Wednesday at 2.", + "Medical appointment -> 60-minute reminder.")), + (_N, "Jen: Theo's birthday party is Saturday September 19 at 2pm at our place, 31 Aspen Way\nMe: he can't wait!", + _p3([_e3("Theo's birthday party", "2026-09-19T14:00:00", location="31 Aspen Way", reminder=30)], + "See you Saturday at 2!", + "Party -> 30-minute reminder.")), + (_N, "Carpool note: you're driving the kids to practice Tuesday September 15, leave at 4:45pm\nMe: on it", + _p3([_e3("Drive practice carpool", "2026-09-15T16:45:00", reminder=45)], + "I've got the 4:45 run Tuesday.", + "Carpool -> 45-minute reminder.")), + (_N, "School office: curriculum night is Monday September 21 at 6pm in the auditorium\nMe: we'll come", + _p3([_e3("Curriculum night", "2026-09-21T18:00:00", location="School auditorium", reminder=45)], + "We'll be at curriculum night Monday at 6.", + "School event -> 45-minute reminder.")), + (_N, "Clinic: flu shot Friday September 18 at 11am. Me: ok, remind me 2 hours before", + _p3([_e3("Flu shot — clinic", "2026-09-18T11:00:00", location="Clinic", reminder=120)], + "Friday at 11 — reminder set for 9.", + "An explicit 'remind me 2 hours before' (120) overrides the medical default of 60.")), +] + + +def to_record(now: str, thread: str, plan: dict) -> dict: + from datetime import datetime as _dt + + weekday = _dt.fromisoformat(now).strftime("%A") + user = ( + f"Current datetime: {weekday}, {now}\n" + "Existing calendar: (none provided)\n\n" + f"Conversation:\n{thread}\n\n" + "Return the ActionPlan JSON now." + ) + return { + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user}, + {"role": "assistant", "content": json.dumps(plan, ensure_ascii=False)}, + ] + } + + +def _existing_user_contents(path: Path) -> set[str]: + """User-message contents already in dataset.jsonl, so appends are idempotent. + + Lets this script (and the weekly maintenance routine) be re-run safely — only + genuinely-new seeds are written; previously-added ones are skipped. + """ + seen: set[str] = set() + if not path.exists(): + return seen + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + for m in rec.get("messages", []): + if m.get("role") == "user": + seen.add(m["content"]) + return seen + + +def main(): + out_path = Path(__file__).with_name("data") / "dataset.jsonl" + seen = _existing_user_contents(out_path) + all_seeds = (NEW_SEEDS + MORE_SEEDS + MORE_SEEDS2 + MORE_SEEDS3 + MORE_SEEDS4 + + MORE_SEEDS5 + MORE_SEEDS6 + MORE_SEEDS7 + MORE_SEEDS8 + MORE_SEEDS9) + + new_records: list[dict] = [] + failed = skipped = 0 + for i, (now, thread, plan) in enumerate(all_seeds, 1): + try: + ActionPlan(**plan) # validate before writing anything + except Exception as e: # noqa: BLE001 + print(f" [{i:2d}] FAIL: {e}") + failed += 1 + continue + rec = to_record(now, thread, plan) + user_content = rec["messages"][1]["content"] + if user_content in seen: # already in the file (or earlier in this batch) + skipped += 1 + continue + seen.add(user_content) + new_records.append(rec) + print(f" [{i:2d}] NEW") + + print( + f"\n{len(new_records)} new, {skipped} already present, {failed} invalid " + f"(of {len(all_seeds)} seeds)" + ) + + if new_records: + with out_path.open("a", encoding="utf-8") as f: + for rec in new_records: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + print(f"Appended {len(new_records)} records to {out_path}") + else: + print("Nothing to append — dataset already up to date.") + + +if __name__ == "__main__": + main() diff --git a/training/import_smcalflow.py b/training/import_smcalflow.py new file mode 100644 index 0000000000000000000000000000000000000000..20249a8118ab5881a82061e7121762261b523428 --- /dev/null +++ b/training/import_smcalflow.py @@ -0,0 +1,395 @@ +"""Convert SMCalFlow (CC BY-SA 4.0) calendar dialogues into our ActionPlan format. + +SMCalFlow encodes calendar actions as compositional LISP "dataflow" programs, NOT +resolved ISO datetimes. This parses each `CreatePreflightEventWrapper` turn, extracts +the event subject / start (date+time) / location / attendees, and **resolves** the +date/time constructs against a per-example reference `now` (deterministic from the +turn id, spread across 2026) so relative dates ("tomorrow", "next Friday") become +concrete, self-consistent training targets. Conservative: a turn is emitted only when +we can resolve a title AND an explicit start time; everything else is skipped (no +guessed golds). Output rows match `gen_eval`/`build_messages` exactly. + + python training/import_smcalflow.py --dry-run # stats + samples, no writes + python training/import_smcalflow.py --limit 2000 --heldout 200 + -> appends train rows to training/data/dataset.jsonl (idempotent) + -> writes held-out slice to training/data/smcalflow_heldout.jsonl (disjoint from eval) + +Source: https://github.com/microsoft/task_oriented_dialogue_as_dataflow_synthesis +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import urllib.request +import zipfile +from datetime import date, datetime, timedelta +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +from server.agent import SYSTEM # noqa: E402 (light import) +from server.schema import ActionPlan # noqa: E402 + +BASE = "https://raw.githubusercontent.com/microsoft/task_oriented_dialogue_as_dataflow_synthesis/master/datasets/SMCalFlow-2.0" +SPLITS = { + "train": f"{BASE}/train.dataflow_dialogues.jsonl.zip", + "valid": f"{BASE}/valid.dataflow_dialogues.jsonl.zip", +} +CACHE = ROOT / "training" / "data" / ".smcalflow_cache" +DATA = ROOT / "training" / "data" +REF_START = date(2026, 1, 5) # references spread across 2026 for varied weekdays +DOW = {"Monday": 0, "Tuesday": 1, "Wednesday": 2, "Thursday": 3, + "Friday": 4, "Saturday": 5, "Sunday": 6} +MONTHS = {m: i for i, m in enumerate( + ["January", "February", "March", "April", "May", "June", "July", "August", + "September", "October", "November", "December"], 1)} + + +# --------------------------- lispress parsing ------------------------------- # +def tokenize(s: str): + toks, i, n = [], 0, len(s) + while i < n: + c = s[i] + if c.isspace(): + i += 1 + elif c in "()": + toks.append(c); i += 1 + elif c == '"': + j, buf = i + 1, [] + while j < n and s[j] != '"': + if s[j] == "\\" and j + 1 < n: + buf.append(s[j + 1]); j += 2 + else: + buf.append(s[j]); j += 1 + toks.append(("STR", "".join(buf))); i = j + 1 + else: + j = i + while j < n and not s[j].isspace() and s[j] not in '()"': + j += 1 + toks.append(s[i:j]); i = j + return toks + + +def parse(s: str): + toks = tokenize(s); pos = [0] + + def rd(): + t = toks[pos[0]]; pos[0] += 1 + if t == "(": + lst = [] + while toks[pos[0]] != ")": + lst.append(rd()) + pos[0] += 1 + return lst + return t + + try: + return rd() + except (IndexError, ValueError): + return None + + +def walk(node): + yield node + if isinstance(node, list): + for x in node: + yield from walk(x) + + +def calls(tree, head): + return [n for n in walk(tree) if isinstance(n, list) and n and n[0] == head] + + +def first_str(node): + for x in walk(node): + if isinstance(x, tuple) and x[0] == "STR": + return x[1] + return None + + +def num(tok): + try: + return int(str(tok).rstrip("Ll")) + except ValueError: + return None + + +# --------------------------- date/time resolution --------------------------- # +def resolve_time(node): + """Return (hour, minute) or None for a time construct.""" + if isinstance(node, str): + node = [node] + if not isinstance(node, list) or not node: + return None + head = node[0] + if head in ("Noon",): + return (12, 0) + if head in ("Midnight",): + return (0, 0) + if head == "NumberAM": + h = num(node[1]); return None if h is None else (0 if h == 12 else h, 0) + if head == "NumberPM": + h = num(node[1]); return None if h is None else (12 if h == 12 else h + 12, 0) + if head == "HourMinuteAm": + h, m = num(node[1]), num(node[2]) + return None if h is None or m is None else (0 if h == 12 else h, m) + if head == "HourMinutePm": + h, m = num(node[1]), num(node[2]) + return None if h is None or m is None else (12 if h == 12 else h + 12, m) + if head == "HourMinuteMilitary": + h, m = num(node[1]), num(node[2]) + return None if h is None or m is None else (h % 24, m % 60) + return None + + +def _dow_idx(node): + # node like ["Friday"] or "Friday" + name = node[0] if isinstance(node, list) and node else node + return DOW.get(name) + + +def resolve_date(node, now: date): + """Return a date or None for a date construct, relative to `now`.""" + if isinstance(node, str): + node = [node] + if not isinstance(node, list) or not node: + return None + head = node[0] + if head == "Today": + return now + if head == "Tomorrow": + return now + timedelta(days=1) + if head == "NextDOW": + idx = _dow_idx(node[1]) + if idx is None: + return None + ahead = (idx - now.weekday()) % 7 + return now + timedelta(days=ahead or 7) # strictly future + if head == "MD": # (MD day month) month = (NextMonth) or (March) ... + day = num(node[1]) + mnode = node[2] + if day is None: + return None + if isinstance(mnode, list) and mnode and mnode[0] == "NextMonth": + y, m = now.year + (now.month // 12), (now.month % 12) + 1 + else: + mname = mnode[0] if isinstance(mnode, list) and mnode else mnode + m = MONTHS.get(mname) + if m is None: + return None + y = now.year if (m, day) >= (now.month, now.day) else now.year + 1 + try: + return date(y, m, day) + except ValueError: + return None + return None + + +def resolve_start(start_eq_node, now_dt: datetime): + """start_eq_node is the argument of (?= ...) under Event.start_?. Return ISO or None.""" + if not isinstance(start_eq_node, list) or not start_eq_node: + return None + head = start_eq_node[0] + if head == "DateAtTimeWithDefaults": + d = resolve_date(start_eq_node[1], now_dt.date()) + t = resolve_time(start_eq_node[2]) + if d and t: + return f"{d.isoformat()}T{t[0]:02d}:{t[1]:02d}:00" + if head == "NextTime": # next occurrence of a time of day + t = resolve_time(start_eq_node[1]) + if t: + d = now_dt.date() + if (now_dt.hour, now_dt.minute) >= t: + d = d + timedelta(days=1) + return f"{d.isoformat()}T{t[0]:02d}:{t[1]:02d}:00" + return None + + +# --------------------------- turn -> ActionPlan ----------------------------- # +def _ref_now(key: str) -> datetime: + off = int(hashlib.md5(key.encode()).hexdigest(), 16) % 540 + return datetime.combine(REF_START + timedelta(days=off), datetime.min.time()).replace(hour=9) + + +def convert_turn(dialogue_id, turn): + lp = turn.get("lispress", "") + if "CreatePreflightEventWrapper" not in lp: + return None + tree = parse(lp) + if tree is None: + return None + + title = None + for c in calls(tree, "Event.subject_?"): + title = first_str(c) + if title: + break + if not title: + return None + + now = _ref_now(f"{dialogue_id}:{turn.get('turn_index')}") + start = None + for c in calls(tree, "Event.start_?"): + for eq in calls(c, "?="): + if len(eq) >= 2: + start = resolve_start(eq[1], now) + if start: + break + if start: + break + if not start: + return None # only emit turns with an explicit resolvable start time + + # Convention guard: SMCalFlow annotates "next " as the immediately-next + # occurrence (possibly tomorrow); our app/eval convention is the DOW of NEXT week. + # 74/2000 sampled rows carried the conflicting label and trained the e09 failure — + # drop any "next " row whose gold start lands under 7 days out. + utt_l = turn["user_utterance"]["original_text"].lower() + if re.search(r"next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)", utt_l): + days_ahead = (datetime.fromisoformat(start).date() - now.date()).days + if days_ahead < 7: + return None + + location = None + for c in calls(tree, "Event.location_?"): + location = first_str(c) + if location: + break + attendees = [s for c in calls(tree, "PersonName.apply") if (s := first_str(c))] + + plan = { + "reasoning": f"Create \"{title}\" starting {start}.", + "events": [{ + "title": title, "start": start, "end": None, "location": location, + "attendees": attendees, "reminder_minutes": None, "notes": None, + }], + "conflicts": [], "proposed_times": [], + "reply_draft": (turn.get("agent_utterance", {}).get("original_text") or "").strip() + or "Done — added it to your calendar.", + "needs_clarification": None, + } + try: + ActionPlan(**plan) + except Exception: # noqa: BLE001 + return None + + utterance = turn["user_utterance"]["original_text"].strip() + if len(utterance) < 6: + return None + user = ( + f"Current datetime: {now.strftime('%A')}, {now.isoformat()}\n" + "Existing calendar: (none provided)\n\n" + f"Conversation:\n{utterance}\n\n" + "Return the ActionPlan JSON now." + ) + return {"messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user}, + {"role": "assistant", "content": json.dumps(plan, ensure_ascii=False)}, + ]} + + +# --------------------------- io helpers ------------------------------------- # +def download(split: str) -> Path: + CACHE.mkdir(parents=True, exist_ok=True) + out = CACHE / f"{split}.dataflow_dialogues.jsonl" + if out.exists(): + return out + zp = CACHE / f"{split}.zip" + print(f"downloading {split} ...", flush=True) + urllib.request.urlretrieve(SPLITS[split], zp) + with zipfile.ZipFile(zp) as z: + name = next(n for n in z.namelist() if n.endswith(".jsonl")) + with z.open(name) as src, open(out, "wb") as dst: + dst.write(src.read()) + zp.unlink() + return out + + +def existing_user_contents(path: Path) -> set[str]: + seen: set[str] = set() + if not path.exists(): + return seen + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + for m in json.loads(line).get("messages", []): + if m.get("role") == "user": + seen.add(m["content"]) + except json.JSONDecodeError: + continue + return seen + + +def convert_split(split: str): + rows = [] + for line in download(split).read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + d = json.loads(line) + did = d.get("dialogue_id") + for turn in d.get("turns", []): + r = convert_turn(did, turn) + if r: + rows.append(r) + return rows + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--limit", type=int, default=2000, help="max train rows to append") + ap.add_argument("--heldout", type=int, default=200) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + pool = convert_split("valid") + convert_split("train") + print(f"converted {len(pool)} CreateEvent turns") + + # dedup within pool, drop anything that collides with the eval set + eval_users = existing_user_contents(DATA / "eval.jsonl") + seen, uniq = set(), [] + for r in pool: + u = r["messages"][1]["content"] + if u in seen or u in eval_users: + continue + seen.add(u) + uniq.append(r) + # deterministic shuffle (md5 of user content) so held-out/train split is stable + uniq.sort(key=lambda r: hashlib.md5(r["messages"][1]["content"].encode()).hexdigest()) + print(f"{len(uniq)} unique, disjoint from eval") + + heldout = uniq[: args.heldout] + train = uniq[args.heldout: args.heldout + args.limit] + print(f"held-out: {len(heldout)} train: {len(train)}") + + if args.dry_run: + print("\n--- 4 samples ---") + for r in train[:4]: + u = r["messages"][1]["content"].split("Conversation:\n")[1].split("\n\n")[0] + a = json.loads(r["messages"][2]["content"]) + ev = a["events"][0] + now = r["messages"][1]["content"].split("Current datetime: ")[1].split("\n")[0] + print(f" now={now} utt={u!r}\n -> {ev['title']!r} @ {ev['start']} loc={ev['location']} att={ev['attendees']}") + return + + # Write to SEPARATE, git-ignored files (NOT dataset.jsonl): SMCalFlow is CC BY-SA + # 4.0 (share-alike) and this repo is Apache-2.0 — we don't commit the derived data + # (no relicensing / no repo bloat). The rows are in dataset.jsonl FORMAT and are + # loaded *alongside* dataset.jsonl by train_qlora.py; regenerate anytime by re-running + # this script. Both files are disjoint from each other and from training/data/eval.jsonl. + def dump(path: Path, rows): + path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows), encoding="utf-8") + print(f"wrote {path} ({len(rows)})") + + dump(DATA / "smcalflow_train.jsonl", train) + dump(DATA / "smcalflow_heldout.jsonl", heldout) + print("note: git-ignored (CC BY-SA 4.0); train_qlora.py loads smcalflow_train.jsonl + dataset.jsonl") + + +if __name__ == "__main__": + main() diff --git a/training/make_dataset.py b/training/make_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..85307d11e94fc5b51f251dcf0c1fd00a18b265f0 --- /dev/null +++ b/training/make_dataset.py @@ -0,0 +1,588 @@ +"""Build a synthetic instruction dataset: thread (+image descriptions) -> ActionPlan JSON. + +Reuses the agent's SYSTEM prompt and ActionPlan schema so training and serving +stay in sync. This is a template with a few hand-written seeds plus simple +augmentation — expand toward ~500-2000 examples covering relative dates, ranges, +conflicts, no-event chitchat (empty), multiple events, and image-derived events. + +Output: training/data/dataset.jsonl (one {messages:[...]} chat record per line) +Generating training data may use any offline tooling — the "no cloud API" rule +applies to the running app's inference, not dataset prep. + +Vision note: we keep the vision encoder frozen at train time, so image examples +are represented by a short text *description* of the image (what the model would +"see"); this teaches the decision/format behavior without multimodal training. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from server.agent import SYSTEM # noqa: E402 reuse the serving prompt + +# (current_datetime, thread, action_plan_dict) +SEEDS = [ + ( + "2026-06-08T09:00:00", + "Alex: lunch tomorrow at 1pm?\nMe: works, the usual place", + { + "reasoning": "Alex proposed lunch tomorrow at 1pm and it was accepted.", + "events": [ + { + "title": "Lunch with Alex", + "start": "2026-06-09T13:00:00", + "end": "2026-06-09T14:00:00", + "location": "the usual place", + "attendees": ["Alex"], + "reminder_minutes": 30, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Perfect, see you at 1 at the usual place!", + "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Sam: dentist moved to next Thursday 3:30\nMe: noted", + { + "reasoning": "Sam rescheduled the dentist to next Thursday at 3:30pm.", + "events": [ + { + "title": "Dentist", + "start": "2026-06-11T15:30:00", + "end": "2026-06-11T16:30:00", + "location": None, + "attendees": [], + "reminder_minutes": 60, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Got it, I'll be there Thursday at 3:30.", + "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Jo: haha that meme killed me\nMe: lol", + { + "reasoning": "Casual chat, no plan to schedule.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "😂", + "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Pat: you free sometime this week to grab coffee?", + { + "reasoning": "A meetup is proposed but no concrete day/time was given.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Yeah! What day works for you?", + "needs_clarification": "Which day/time this week works for coffee?", + }, + ), + ( + # image-derived example (vision encoder frozen -> describe the image in text) + "2026-06-08T09:00:00", + "Riley: [image: screenshot of a concert ticket — 'The Lumens, Fri Jun 12, " + "doors 7:30pm, The Fillmore']\nMe: can't wait!", + { + "reasoning": "The screenshot is a concert ticket with a concrete date, time, and venue.", + "events": [ + { + "title": "The Lumens concert", + "start": "2026-06-12T19:30:00", + "end": "2026-06-12T22:30:00", + "location": "The Fillmore", + "attendees": ["Riley"], + "reminder_minutes": 120, + "notes": "From ticket screenshot", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Added it — doors at 7:30, see you Friday!", + "needs_clarification": None, + }, + ), + # --- multiple events in one thread (mirrors the live "Try a sample") --- + ( + "2026-06-08T08:00:00", + "Room parent: picture day is this Thursday at 9am, green shirts!\n" + "Coach Dana: heads up, soccer moves to Tuesday 5pm this week\nMe: thanks, adding both", + { + "reasoning": "Two concrete events: picture day Thursday 9am and the rescheduled soccer Tuesday 5pm.", + "events": [ + { + "title": "Picture day (green shirt)", + "start": "2026-06-11T09:00:00", + "end": "2026-06-11T09:30:00", + "location": None, + "attendees": [], + "reminder_minutes": 720, + "notes": "Wear the green class shirt", + }, + { + "title": "Soccer practice", + "start": "2026-06-09T17:00:00", + "end": "2026-06-09T18:00:00", + "location": None, + "attendees": ["Coach Dana"], + "reminder_minutes": 60, + "notes": None, + }, + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Got both down — picture day Thursday 9am and soccer Tuesday 5pm!", + "needs_clarification": None, + }, + ), + # --- relative date: "next Friday" --- + ( + "2026-06-08T09:00:00", + "Teacher: our class field trip permission slips are due next Friday\nMe: ok!", + { + "reasoning": "A due date stated as 'next Friday' relative to Monday Jun 8 resolves to Jun 19.", + "events": [ + { + "title": "Permission slips due", + "start": "2026-06-19T09:00:00", + "end": None, + "location": None, + "attendees": [], + "reminder_minutes": 1440, + "notes": "Class field trip", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Thanks — I'll have them signed before Friday.", + "needs_clarification": None, + }, + ), + # --- multi-hour range: field trip 9-3 --- + ( + "2026-06-08T09:00:00", + "Room parent: field trip to the science museum is Wed 9am–3pm, bring a bagged lunch\nMe: noted", + { + "reasoning": "A single all-day-ish event with an explicit start and end on Wednesday.", + "events": [ + { + "title": "Science museum field trip", + "start": "2026-06-10T09:00:00", + "end": "2026-06-10T15:00:00", + "location": "Science museum", + "attendees": [], + "reminder_minutes": 720, + "notes": "Bring a bagged lunch", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Got it — bagged lunch, 9 to 3 Wednesday.", + "needs_clarification": None, + }, + ), + # --- multi-day range: book fair (3 days) --- + ( + "2026-06-08T09:00:00", + "PTA: the book fair runs Mon–Wed next week in the library, open during pickup\nMe: 👍", + { + "reasoning": "A multi-day event spanning Monday to Wednesday of next week.", + "events": [ + { + "title": "Book fair", + "start": "2026-06-15T08:00:00", + "end": "2026-06-17T16:00:00", + "location": "School library", + "attendees": [], + "reminder_minutes": 1440, + "notes": "Open during pickup", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Thanks — we'll stop by after pickup.", + "needs_clarification": None, + }, + ), + # --- no-event chitchat (empty) --- + ( + "2026-06-08T09:00:00", + "Jess: did you see the class photo album?? so cute\nMe: omg yes 😍", + { + "reasoning": "Friendly chatter, nothing to schedule.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "They grow up so fast 🥹", + "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Coach Dana: great hustle from the kids today, proud of them\nMe: such a fun season", + { + "reasoning": "Encouragement, no plan or date mentioned.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Thanks Coach — they love it!", + "needs_clarification": None, + }, + ), + # --- conflict against existing calendar + proposed alternatives --- + ( + "2026-06-08T09:00:00", + "Existing calendar:\n- Dentist (Mia): 2026-06-10T14:00..2026-06-10T15:00\n\n" + "Room parent: makeup class photo is Wednesday at 2pm\nMe: hmm Mia has the dentist then", + { + "reasoning": "The makeup photo at Wed 2pm overlaps Mia's existing dentist appointment.", + "events": [ + { + "title": "Makeup class photo", + "start": "2026-06-10T14:00:00", + "end": "2026-06-10T14:30:00", + "location": None, + "attendees": [], + "reminder_minutes": 120, + "notes": None, + } + ], + "conflicts": [ + { + "event_index": 0, + "clashes_with": "Dentist (Mia) 2:00-3:00pm", + "severity": "overlap", + } + ], + "proposed_times": ["2026-06-10T15:00:00", "2026-06-11T14:00:00"], + "reply_draft": "Mia has the dentist at 2 — could she do the makeup photo at 3, or Thursday?", + "needs_clarification": None, + }, + ), + ( + "2026-06-08T09:00:00", + "Existing calendar:\n- Work standup: 2026-06-09T09:00..2026-06-09T09:30\n\n" + "Teacher: parent volunteers needed in class Tue 9–10am\nMe: I'll try", + { + "reasoning": "Volunteering Tue 9-10am butts against the 9:00-9:30 standup (overlap).", + "events": [ + { + "title": "Classroom volunteering", + "start": "2026-06-09T09:00:00", + "end": "2026-06-09T10:00:00", + "location": "Classroom", + "attendees": [], + "reminder_minutes": 60, + "notes": None, + } + ], + "conflicts": [ + { + "event_index": 0, + "clashes_with": "Work standup 9:00-9:30am", + "severity": "overlap", + } + ], + "proposed_times": ["2026-06-09T10:00:00", "2026-06-11T09:00:00"], + "reply_draft": "I have a 9am call — could I come in at 10 instead?", + "needs_clarification": None, + }, + ), + # --- image-described event: PTA flyer --- + ( + "2026-06-08T09:00:00", + "Room parent: [image: flyer — 'Spring Family Picnic, Sat Jun 13, 11am, Lincoln Park " + "Shelter B, bring a dish to share']\nMe: we'll be there", + { + "reasoning": "The flyer gives a concrete date, time, and location for the family picnic.", + "events": [ + { + "title": "Spring Family Picnic", + "start": "2026-06-13T11:00:00", + "end": "2026-06-13T14:00:00", + "location": "Lincoln Park, Shelter B", + "attendees": [], + "reminder_minutes": 120, + "notes": "Bring a dish to share (from flyer)", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Added it — we'll bring a salad!", + "needs_clarification": None, + }, + ), + # --- image-described event: permission slip photo --- + ( + "2026-06-08T09:00:00", + "Mom: [image: photo of a permission slip — 'Zoo trip Thursday June 11, depart 8:30am, " + "return ~2:30pm, $12 due Mon']\nMe: signing tonight", + { + "reasoning": "Permission slip photo encodes the trip date/time and a separate payment deadline.", + "events": [ + { + "title": "Zoo trip", + "start": "2026-06-11T08:30:00", + "end": "2026-06-11T14:30:00", + "location": "Zoo", + "attendees": [], + "reminder_minutes": 720, + "notes": "$12 due Monday; from permission slip", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Signed and the $12 is ready for Monday!", + "needs_clarification": None, + }, + ), + # --- ambiguity: needs clarification, no event yet --- + ( + "2026-06-08T09:00:00", + "Teacher: can a couple of parents help with the class party sometime next week?\nMe: maybe!", + { + "reasoning": "A request without a fixed day or time — ask before scheduling anything.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Happy to help — what day and time is the party?", + "needs_clarification": "Which day/time next week is the class party?", + }, + ), + # --- explicit date "the 14th" --- + ( + "2026-06-08T09:00:00", + "Coach Dana: end-of-season pizza party is the 14th at 6pm at Tony's\nMe: yum, count us in", + { + "reasoning": "'The 14th' resolves to Jun 14; concrete time and venue given.", + "events": [ + { + "title": "End-of-season pizza party", + "start": "2026-06-14T18:00:00", + "end": "2026-06-14T20:00:00", + "location": "Tony's", + "attendees": ["Coach Dana"], + "reminder_minutes": 120, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Count us in — see you at Tony's at 6!", + "needs_clarification": None, + }, + ), + # --- "in two weeks" --- + ( + "2026-06-08T09:00:00", + "Teacher: the spring recital is in two weeks, Friday at 6pm in the gym\nMe: 📅", + { + "reasoning": "Friday two weeks out is Jun 19? The teacher pins it to Friday 6pm — Jun 19.", + "events": [ + { + "title": "Spring recital", + "start": "2026-06-19T18:00:00", + "end": "2026-06-19T19:30:00", + "location": "School gym", + "attendees": [], + "reminder_minutes": 1440, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Can't wait — we'll be there Friday at 6!", + "needs_clarification": None, + }, + ), + # --- early dismissal --- + ( + "2026-06-08T09:00:00", + "School office: reminder, early dismissal this Wednesday at 12:30pm\nMe: thanks for the heads up", + { + "reasoning": "A one-off schedule change parents need to plan pickup around.", + "events": [ + { + "title": "Early dismissal", + "start": "2026-06-10T12:30:00", + "end": None, + "location": "School", + "attendees": [], + "reminder_minutes": 60, + "notes": "Pickup at 12:30 instead of usual", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Noted — I'll be there at 12:30 Wednesday.", + "needs_clarification": None, + }, + ), + # --- parent-teacher conference (specific slot) --- + ( + "2026-06-08T09:00:00", + "Teacher: your parent–teacher conference slot is Thursday 4:15pm, ~15 min\nMe: works for me", + { + "reasoning": "A confirmed 15-minute conference slot on Thursday.", + "events": [ + { + "title": "Parent–teacher conference", + "start": "2026-06-11T16:15:00", + "end": "2026-06-11T16:30:00", + "location": "Classroom", + "attendees": ["Teacher"], + "reminder_minutes": 120, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "4:15 Thursday works — see you then!", + "needs_clarification": None, + }, + ), + # --- birthday party RSVP with location --- + ( + "2026-06-08T09:00:00", + "Other parent: Leo's bday party Sat 2–4pm at Jump Zone, can Mia come?\nMe: she'd love to!", + { + "reasoning": "A party invite with a clear window and venue, accepted.", + "events": [ + { + "title": "Leo's birthday party", + "start": "2026-06-13T14:00:00", + "end": "2026-06-13T16:00:00", + "location": "Jump Zone", + "attendees": ["Leo"], + "reminder_minutes": 180, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "She'd love to — see you Saturday at Jump Zone!", + "needs_clarification": None, + }, + ), + # --- carpool reminder --- + ( + "2026-06-08T09:00:00", + "Neighbor: I've got carpool drop-off Friday at 7:45am this week\nMe: perfect, thank you!", + { + "reasoning": "A specific carpool commitment on Friday morning.", + "events": [ + { + "title": "Carpool drop-off (neighbor driving)", + "start": "2026-06-12T07:45:00", + "end": None, + "location": None, + "attendees": [], + "reminder_minutes": 30, + "notes": None, + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Thank you! I've got next Friday.", + "needs_clarification": None, + }, + ), + # --- reschedule (supersede an earlier time) --- + ( + "2026-06-08T09:00:00", + "Coach Dana: practice this Tue is now 6pm, not 5\nMe: ok updating", + { + "reasoning": "A reschedule: practice Tuesday moves to 6pm.", + "events": [ + { + "title": "Soccer practice", + "start": "2026-06-09T18:00:00", + "end": "2026-06-09T19:00:00", + "location": None, + "attendees": ["Coach Dana"], + "reminder_minutes": 60, + "notes": "Moved from 5pm to 6pm", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Updated — 6pm Tuesday it is.", + "needs_clarification": None, + }, + ), + # --- bake sale drop-off deadline --- + ( + "2026-06-08T09:00:00", + "PTA: bake sale items drop off Thursday by 8am at the front office\nMe: I'll bring cookies", + { + "reasoning": "A drop-off deadline Thursday morning.", + "events": [ + { + "title": "Bake sale drop-off", + "start": "2026-06-11T08:00:00", + "end": None, + "location": "Front office", + "attendees": [], + "reminder_minutes": 720, + "notes": "Bringing cookies", + } + ], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Cookies will be there by 8 Thursday!", + "needs_clarification": None, + }, + ), + # --- vague meetup, needs clarification --- + ( + "2026-06-08T09:00:00", + "Other parent: we should set up a playdate soon!\nMe: yes definitely", + { + "reasoning": "Mutual interest but no concrete day, time, or place.", + "events": [], + "conflicts": [], + "proposed_times": [], + "reply_draft": "Let's do it — what day works for you two?", + "needs_clarification": "Which day/time and where for the playdate?", + }, + ), +] + + +def to_record(now: str, thread: str, plan: dict) -> dict: + user = f"Current datetime: {now}\nExisting calendar: (none provided)\n\nConversation:\n{thread}\n\nReturn the ActionPlan JSON now." + return { + "messages": [ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": user}, + {"role": "assistant", "content": json.dumps(plan, ensure_ascii=False)}, + ] + } + + +def main(): + out_dir = Path(__file__).with_name("data") + out_dir.mkdir(exist_ok=True) + out = out_dir / "dataset.jsonl" + with out.open("w", encoding="utf-8") as f: + for now, thread, plan in SEEDS: + f.write(json.dumps(to_record(now, thread, plan), ensure_ascii=False) + "\n") + print(f"wrote {out} ({len(SEEDS)} seed records) — expand this before training") + + +if __name__ == "__main__": + main() diff --git a/training/modal_eval.py b/training/modal_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..2054b8ade482193b87eb39928e26a7773dc6088f --- /dev/null +++ b/training/modal_eval.py @@ -0,0 +1,140 @@ +"""Score the fine-tune on Modal: serve the GGUF with llama-server, run eval.py. + +Mirrors the deployment exactly — same official llama.cpp server image and the same +`-hf REPO:QUANT` load the Space uses — then runs training/eval.py against its +OpenAI-compatible endpoint. Text-only by default; --vision loads the mmproj +projector and feeds each thread as a rendered screenshot instead of text (run +training/render_screenshots.py first). The GGUF download is cached in a Volume. + + modal run training/modal_eval.py # the fine-tune + modal run training/modal_eval.py --model-hf-repo unsloth/gemma-4-31B-it-GGUF # baseline + modal run training/modal_eval.py --model-file gemma-cal-e4b-Q4_K_M.gguf --vision + modal run training/modal_eval.py --eval-path training/data/eval_unstructured.jsonl +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import time +import urllib.request +from pathlib import Path + +import modal + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Same llama-server the Space runs; add Python + the (light) eval deps. +image = ( + modal.Image.from_registry("ghcr.io/ggml-org/llama.cpp:server-cuda", add_python="3.11") + .entrypoint([]) # clear the base image's llama-server ENTRYPOINT so Modal can run python + .pip_install("requests", "pydantic>=2", "huggingface_hub", "python-dateutil>=2.9") + .add_local_dir( + str(REPO_ROOT), "/root/repo", + ignore=[".git", "**/__pycache__", "**/*.gguf", "training/outputs", + "training/data/.smcalflow_cache"], + ) +) +app = modal.App("imessage-cal-eval", image=image) +hf_cache = modal.Volume.from_name("imessage-cal-hf-cache", create_if_missing=True) +outputs_vol = modal.Volume.from_name("imessage-cal-outputs", create_if_missing=True) + + +@app.function( + gpu="A100-80GB", # Modal GPU type (not the HF Spaces "a100-large" flavor) + timeout=60 * 60, + secrets=[modal.Secret.from_name("huggingface")], + volumes={"/cache/hf": hf_cache, "/outputs": outputs_vol}, +) +def evaluate(model_hf_repo: str = "ParetoOptimal/gemma-4-cal-gguf", + model_quant: str = "Q4_K_M", + model_file: str = "", + minimal_prompt: bool = False, + vision: bool = False, + mmproj_repo: str = "unsloth/gemma-4-E4B-it-GGUF", + mmproj_file: str = "mmproj-F16.gguf", + eval_path: str = "", + title_polish: bool = False) -> str: + workspace = "/root/repo" + env = {**os.environ, "HF_HOME": "/cache/hf"} + if minimal_prompt: + env["MINIMAL_PROMPT"] = "1" # eval.py drops the system prompt for both models + if eval_path: + env["EVAL_PATH"] = eval_path # e.g. training/data/eval_unstructured.jsonl + ls = shutil.which("llama-server") or "/app/llama-server" + env["LD_LIBRARY_PATH"] = f"{os.path.dirname(ls)}:/app:" + env.get("LD_LIBRARY_PATH", "") + + # Serve a specific file via -m: an absolute path reads straight off the outputs + # volume (e.g. a staging GGUF the gate already deleted from HF), otherwise the + # file is fetched from `model_hf_repo`. No file -> -hf REPO:QUANT. + if model_file: + if model_file.startswith("/"): + path = model_file + label = f"volume:{os.path.basename(model_file)}" + else: + from huggingface_hub import hf_hub_download + path = hf_hub_download(model_hf_repo, model_file, cache_dir="/cache/hf") + label = f"{model_hf_repo}/{model_file}" + load_args = ["-m", path] + else: + label = f"{model_hf_repo}:{model_quant}" + load_args = ["-hf", f"{model_hf_repo}:{model_quant}"] + if vision: + # The vision arm: load the projector so llama-server accepts image_url + # content; eval.py (VISION=1) then sends each thread as a screenshot only. + # Default projector mirrors the Space's Dockerfile: the E4B fine-tune pairs + # with the BASE E4B's mmproj (unsloth repo), not the 31B one in ours. + from huggingface_hub import hf_hub_download + mmproj_path = hf_hub_download(mmproj_repo, mmproj_file, cache_dir="/cache/hf") + load_args += ["--mmproj", mmproj_path] + label += "+vision" + print(f"[eval] launching {ls} ({label}, {'vision' if vision else 'text-only'})", flush=True) + proc = subprocess.Popen( + [ls, *load_args, + "--host", "127.0.0.1", "--port", "8080", "-ngl", "999", "-c", "8192", "--jinja"], + env=env, + ) + ready = False + for i in range(900): # model download (~18.7GB first run) + load can take minutes + if proc.poll() is not None: + print("[eval] ERROR: llama-server exited early", flush=True) + break + try: + with urllib.request.urlopen("http://127.0.0.1:8080/health", timeout=5) as r: + if r.status == 200: + ready = True + print(f"[eval] llama-server ready after ~{i * 2}s", flush=True) + break + except Exception: # noqa: BLE001 (503 while loading -> retry) + time.sleep(2) + hf_cache.commit() # persist the downloaded GGUF for next run + if not ready: + raise RuntimeError("llama-server never became healthy") + + if title_polish: + label += "+titles" + env2 = {**env, "INFERENCE_BASE_URL": "http://127.0.0.1:8080/v1", "MODEL_LABEL": label} + if vision: + env2["VISION"] = "1" + if title_polish: + env2["TITLE_POLISH"] = "1" + r = subprocess.run(["python3", "training/eval.py"], cwd=workspace, env=env2, + capture_output=True, text=True) + print(r.stdout, flush=True) + if r.stderr: + print("STDERR:", r.stderr[-3000:], flush=True) + proc.terminate() + return r.stdout + + +@app.local_entrypoint() +def main(model_hf_repo: str = "ParetoOptimal/gemma-4-cal-gguf", model_quant: str = "Q4_K_M", + model_file: str = "", minimal_prompt: bool = False, vision: bool = False, + mmproj_repo: str = "unsloth/gemma-4-E4B-it-GGUF", + mmproj_file: str = "mmproj-F16.gguf", eval_path: str = "", + title_polish: bool = False): + print(evaluate.remote(model_hf_repo=model_hf_repo, model_quant=model_quant, + model_file=model_file, minimal_prompt=minimal_prompt, + vision=vision, mmproj_repo=mmproj_repo, + mmproj_file=mmproj_file, eval_path=eval_path, + title_polish=title_polish)) diff --git a/training/modal_quant_eval.py b/training/modal_quant_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..d870fb9dbe9d829d75013e4a1e319b0f75d0230b --- /dev/null +++ b/training/modal_quant_eval.py @@ -0,0 +1,112 @@ +"""Quantization-penalty test: eval the SAME fine-tuned weights at several precisions. + +Our Q4_K_M fine-tunes underperform base, and v1's misses were the "206"/`2062` +dropped-year-digit bug — a classic low-bit quantization artifact. This serves an +on-volume fp16 GGUF (from a past run) at f16 / Q8_0 / Q4_K_M and scores each on the +same 28-example eval, so precision is the only variable. If higher precision restores +schema validity + recall and kills the digit bug, quantization is the culprit. + + PYTHONUTF8=1 python -m modal run training/modal_quant_eval.py + PYTHONUTF8=1 python -m modal run training/modal_quant_eval.py \ + --f16-path /outputs/gemma-cal-staging-f16.gguf --quants f16,Q8_0 +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import time +import urllib.request +from pathlib import Path + +import modal + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# full-cuda image has BOTH llama-quantize and llama-server; clear its ENTRYPOINT so +# Modal can run python. +image = ( + modal.Image.from_registry("ghcr.io/ggml-org/llama.cpp:full-cuda", add_python="3.11") + .entrypoint([]) + .pip_install("requests", "pydantic>=2", "huggingface_hub") + .add_local_dir( + str(REPO_ROOT), "/root/repo", + ignore=[".git", "**/__pycache__", "**/*.gguf", "training/outputs", + "training/data/.smcalflow_cache"], + ) +) +app = modal.App("imessage-cal-quant-eval", image=image) +outputs = modal.Volume.from_name("imessage-cal-outputs") + + +@app.function(gpu="A100-80GB", timeout=2 * 60 * 60, volumes={"/outputs": outputs}) +def quant_eval(f16_path: str = "/outputs/gemma-cal-f16.gguf", + quants: str = "f16,Q8_0,Q4_K_M") -> dict: + workspace = "/root/repo" + quantize = shutil.which("llama-quantize") or "/app/llama-quantize" + server = shutil.which("llama-server") or "/app/llama-server" + env = {**os.environ} + env["LD_LIBRARY_PATH"] = f"{os.path.dirname(server)}:/app:" + env.get("LD_LIBRARY_PATH", "") + + if not os.path.exists(f16_path): + raise FileNotFoundError(f"{f16_path} not on the volume; `modal volume ls imessage-cal-outputs`") + + results: dict[str, dict] = {} + for q in [x.strip() for x in quants.split(",") if x.strip()]: + if q == "f16": + gguf = f16_path + tmp = None + else: + tmp = gguf = f"/outputs/_quanttest-{q}.gguf" + print(f"\n[quant] {quantize} {f16_path} -> {gguf} ({q})", flush=True) + subprocess.run([quantize, f16_path, gguf, q], env=env, check=True) + + proc = subprocess.Popen( + [server, "-m", gguf, "--host", "127.0.0.1", "--port", "8080", + "-ngl", "999", "-c", "8192", "--jinja"], env=env, + ) + ready = False + for i in range(900): + if proc.poll() is not None: + print(f"[quant] {q}: server exited early", flush=True) + break + try: + with urllib.request.urlopen("http://127.0.0.1:8080/health", timeout=5) as r: + if r.status == 200: + ready = True + print(f"[quant] {q}: ready after ~{i * 2}s", flush=True) + break + except Exception: # noqa: BLE001 + time.sleep(2) + + if ready: + env2 = {**env, "INFERENCE_BASE_URL": "http://127.0.0.1:8080/v1", + "MODEL_LABEL": f"{Path(f16_path).name}:{q}"} + r = subprocess.run(["python3", "training/eval.py"], cwd=workspace, env=env2, + capture_output=True, text=True) + print(f"\n========== PRECISION {q} ==========", flush=True) + print(r.stdout, flush=True) + if r.stderr: + print("STDERR:", r.stderr[-2000:], flush=True) + m = re.search(r"RESULTS_JSON:\s*(\{.*\})", r.stdout) + if m: + results[q] = json.loads(m.group(1)) + proc.terminate() + time.sleep(3) + if tmp and os.path.exists(tmp): + os.remove(tmp) # keep the volume clean (scratch files) + + print("\n==================== QUANT SWEEP SUMMARY ====================", flush=True) + print(f" (weights: {Path(f16_path).name})", flush=True) + for q, res in results.items(): + print(f" {q:8s} validity={res.get('schema_validity')} " + f"f1={res.get('event_f1')} recall={res.get('event_recall_start_exact')}", flush=True) + print(" base ref: validity=1.0 f1=0.977 recall=0.955", flush=True) + return results + + +@app.local_entrypoint() +def main(f16_path: str = "/outputs/gemma-cal-f16.gguf", quants: str = "f16,Q8_0,Q4_K_M"): + print(quant_eval.remote(f16_path=f16_path, quants=quants)) diff --git a/training/modal_train.py b/training/modal_train.py new file mode 100644 index 0000000000000000000000000000000000000000..4de26798e4afc658927bd42f8104cb46b2fe5508 --- /dev/null +++ b/training/modal_train.py @@ -0,0 +1,215 @@ +"""Run the QLoRA fine-tune + GGUF export on a Modal GPU and publish to HF. + +This is a thin Modal wrapper around the existing pipeline — it does NOT reinvent +the training logic. It ships the repo to a serverless A100/H100, then runs, in order: + + python training/make_dataset.py # build dataset.jsonl from seeds (if missing) + python training/train_qlora.py # Unsloth QLoRA + merge to fp16 + bash training/export_gguf.sh # fp16 -> GGUF -> Q4_K_M -> upload to HF + +Everything here runs offline before the app serves anything, so the "no cloud AI +API" rule (Off the Grid) is untouched — see PLAN.md. + +Setup (once): + pip install modal + modal token new + modal secret create huggingface HF_TOKEN=hf_xxxxxxxx # write token + +Run: + modal run training/modal_train.py # A100-80GB, gemma-4-31B + modal run training/modal_train.py --gpu H100 # faster + modal run training/modal_train.py \ + --base-model google/gemma-3-27b-it \ + --hf-repo n8mauer/gemma-cal-gguf + modal run training/modal_train.py --base-model google/gemma-4-E4B-it # cheap validation (~$) + +Rough cost (A100-80GB @ ~$2.5/hr, per-second billing): a few-hundred-to-2000 +example QLoRA run is ~1-3 hr ≈ $5-15. $250 of credit ≈ 15-40 full iterations. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import modal + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# llama.cpp is built into the image so export_gguf.sh has convert_hf_to_gguf.py + +# llama-quantize available without a runtime clone. +LLAMA_CPP = "/opt/llama.cpp" + +# --- Image: CUDA base (unsloth/bitsandbytes need it) + training deps + llama.cpp --- +image = ( + modal.Image.from_registry( + "nvidia/cuda:12.4.1-devel-ubuntu22.04", add_python="3.11" + ) + .apt_install("build-essential", "cmake", "git", "curl") + # Let unsloth resolve its OWN compatible transformers/trl/peft/accelerate/ + # bitsandbytes set — hand-pinning those (e.g. trl<0.13) drags transformers to a + # version that breaks unsloth's internals (the auto_docstring / ConstantLengthDataset + # import errors). Only add what unsloth doesn't pull: the GGUF-convert deps + hf CLI. + .pip_install( + "unsloth", + "huggingface_hub[cli]", + # convert_hf_to_gguf.py needs these: + "gguf", + "sentencepiece", + "protobuf", + ) + # Overlay the newest unsloth + unsloth_zoo from git (the documented fix for the + # auto_docstring / ConstantLengthDataset import errors against current transformers/ + # trl). --no-deps so it patches only unsloth's code, not the resolved dependency set. + .run_commands( + "pip install --no-deps --upgrade " + "git+https://github.com/unslothai/unsloth.git " + "git+https://github.com/unslothai/unsloth-zoo.git" + ) + # Build llama.cpp (CPU is enough for convert + quantize) once, into the image. + .run_commands( + f"git clone --depth 1 https://github.com/ggml-org/llama.cpp {LLAMA_CPP}", + f"cmake -S {LLAMA_CPP} -B {LLAMA_CPP}/build -DLLAMA_CURL=OFF", + f"cmake --build {LLAMA_CPP}/build --target llama-quantize -j", + ) + # Ship the repo's code (server/, training/) — read at runtime by the scripts. + .add_local_dir( + str(REPO_ROOT), + "/root/repo", + ignore=[ + ".git", + "**/__pycache__", + "training/outputs", + "training/data/.smcalflow_cache", # ~70MB raw download; not needed remotely + "**/*.gguf", + ], + ) +) + +app = modal.App("imessage-cal-train", image=image) + +# Persist the HF model cache (base weights) and outputs across runs so re-runs +# skip the multi-GB base-model download and a failed upload isn't catastrophic. +hf_cache = modal.Volume.from_name("imessage-cal-hf-cache", create_if_missing=True) +outputs = modal.Volume.from_name("imessage-cal-outputs", create_if_missing=True) + + +@app.function( + gpu="A100-80GB", # overridable per-run via .with_options(gpu=...) below + timeout=6 * 60 * 60, # QLoRA on a 31B can run hours + secrets=[modal.Secret.from_name("huggingface")], # injects HF_TOKEN + volumes={"/cache/hf": hf_cache, "/outputs": outputs}, +) +def train( + base_model: str = "google/gemma-4-31B-it", + hf_repo: str = "n8mauer/gemma-4-cal-gguf", + max_seq_len: int = 4096, + num_epochs: int = 2, + mmproj_src_repo: str = "unsloth/gemma-4-31B-it-GGUF", + mmproj_file: str = "mmproj-F16.gguf", + out_name: str = "gemma-cal", # produced file: -Q4_K_M.gguf + skip_mmproj: bool = False, # True for eval-gated staging uploads + hand_upsample: int = 4, # x-factor for thread-style hand-authored rows +) -> str: + """Build dataset -> QLoRA -> merge -> GGUF/Q4_K_M -> upload to `hf_repo`.""" + import shutil + import subprocess + + # The image layer is read-only; make_dataset.py writes into training/data/, + # so copy the code to a writable workspace and run there. + workspace = "/workspace" + if os.path.exists(workspace): + shutil.rmtree(workspace) + shutil.copytree("/root/repo", workspace) + + # Absolute output paths on the persisted volume; both scripts honor these envs. + env = { + **os.environ, + "HF_HOME": "/cache/hf", + "BASE_MODEL": base_model, + "MAX_SEQ_LEN": str(max_seq_len), + "NUM_EPOCHS": str(num_epochs), + # Per-run dirs (keyed by out_name): different-size runs sharing one merged dir + # leave stale shards behind, and convert_hf_to_gguf then reads a MIXED model + # ("Can not map tensor 'model.layers.42...'" when 31B leftovers meet an E4B). + "OUTPUT_DIR": f"/outputs/{out_name}-lora", + # export_gguf.sh inputs: + "MERGED_DIR": f"/outputs/{out_name}-lora-merged", + "OUT": f"/outputs/{out_name}", + "LLAMA_CPP": LLAMA_CPP, + "HF_REPO": hf_repo, + "MMPROJ_SRC_REPO": mmproj_src_repo, + "MMPROJ_FILE": mmproj_file, + "SKIP_MMPROJ": "1" if skip_mmproj else "0", + "HAND_UPSAMPLE": str(hand_upsample), + } + + # Start from clean output dirs — save_pretrained does not clear its target, so a + # re-run with the same out_name would otherwise mix old and new shards. + for d in (env["OUTPUT_DIR"], env["MERGED_DIR"]): + if os.path.exists(d): + shutil.rmtree(d) + + def run(*cmd: str) -> None: + print(f"\n$ {' '.join(cmd)}", flush=True) + subprocess.run(cmd, cwd=workspace, env=env, check=True) + + # `modal run` ships the local working tree, which is CRLF on Windows; bash then + # chokes on the carriage returns ("set: pipefail: invalid option name"). Normalize + # the shell scripts to LF in the (writable) workspace before running them. + for rel in ("training/export_gguf.sh", "scripts/start_space.sh"): + path = f"{workspace}/{rel}" + if os.path.exists(path): + with open(path, "rb") as fh: + data = fh.read() + with open(path, "wb") as fh: + fh.write(data.replace(b"\r\n", b"\n")) + + # 1) Dataset (skip if you've already committed a fuller dataset.jsonl). + if not os.path.exists(f"{workspace}/training/data/dataset.jsonl"): + run("python", "training/make_dataset.py") + + # 2) QLoRA fine-tune + merge to fp16 (writes to OUTPUT_DIR + -merged). + run("python", "training/train_qlora.py") + + # 3) Convert -> quantize -> upload GGUF + mmproj to HF (Well-Tuned). + run("bash", "training/export_gguf.sh") + + hf_cache.commit() # persist the base-model download so re-runs skip it + outputs.commit() # flush the volume so artifacts persist + print( + f"\nDone. Point the Space at:\n" + f" MODEL_REPO={hf_repo}\n" + f" MODEL_FILE={out_name}-Q4_K_M.gguf\n" + f" MMPROJ_FILE={mmproj_file} # enables Gemma vision" + ) + return hf_repo + + +@app.local_entrypoint() +def main( + gpu: str = "A100-80GB", + base_model: str = "google/gemma-4-31B-it", + hf_repo: str = "n8mauer/gemma-4-cal-gguf", + max_seq_len: int = 4096, + num_epochs: int = 2, + out_name: str = "gemma-cal", + skip_mmproj: bool = False, + hand_upsample: int = 4, +): + """`modal run training/modal_train.py [--gpu ...] [--base-model ...] ...` + + For eval-gated staging: `--out-name gemma-cal-staging --skip-mmproj` uploads + `gemma-cal-staging-Q4_K_M.gguf` alongside production without touching it; the + gate (training/gated_retrain.py) promotes it only if it beats the eval. + """ + # gpu is fixed at decoration time, so override it for this run via with_options. + repo = train.with_options(gpu=gpu).remote( + base_model=base_model, + hf_repo=hf_repo, + max_seq_len=max_seq_len, + num_epochs=num_epochs, + out_name=out_name, + skip_mmproj=skip_mmproj, + hand_upsample=hand_upsample, + ) + print(f"Published to https://huggingface.co/{repo}") diff --git a/training/publish_e4b.py b/training/publish_e4b.py new file mode 100644 index 0000000000000000000000000000000000000000..6df3de47e11a3bc9266753604e906679498745fe --- /dev/null +++ b/training/publish_e4b.py @@ -0,0 +1,88 @@ +"""Owner-run publication of the E4B edge fine-tune (deliberate gate override). + +The strict-dominance eval-gate never auto-promoted this model because it scored an +exact TIE with stock E4B (0.97 F1 / 0.96 recall on the 60-example eval), not a win. +Publishing a parity model as the project's edge tier is a product decision — so this +script is run BY THE OWNER, not by automation: + + PYTHONUTF8=1 python -m modal run training/publish_e4b.py + +It uploads, from the Modal `imessage-cal-outputs` volume: + 1. /outputs/gemma-cal-e4b-staging-Q4_K_M.gguf -> gemma-cal-e4b-Q4_K_M.gguf + 2. an updated model card documenting BOTH models and the honest eval numbers +to https://huggingface.co/ParetoOptimal/gemma-4-cal-gguf . +""" +import subprocess + +import modal + +image = modal.Image.debian_slim(python_version="3.11").pip_install("huggingface_hub[cli]") +app = modal.App("publish-e4b", image=image) +outputs = modal.Volume.from_name("imessage-cal-outputs") + +REPO = "ParetoOptimal/gemma-4-cal-gguf" +SRC = "/outputs/gemma-cal-e4b-staging-Q4_K_M.gguf" +DEST = "gemma-cal-e4b-Q4_K_M.gguf" + +CARD_ADDENDUM = """ +## Edge model: `gemma-cal-e4b-Q4_K_M.gguf` (Gemma-4 E4B fine-tune) + +A ~5.3 GB QLoRA fine-tune of `google/gemma-4-E4B-it` for the same ActionPlan task — +small enough for llama.cpp on modest hardware (no A100 required). Trained on 139 +hand-authored thread-style examples (4x upsampled) + 2,000 examples converted from +SMCalFlow (CC BY-SA 4.0; date/time programs resolved against per-example reference +datetimes), with the native Gemma-4 chat template, response-only loss, LR 5e-5, +1 epoch. + +**Honest evaluation** (60-example held-out eval, 50 gold events; details in the +project's `docs/eval-roadmap.md`): + +| with system prompt | stock E4B | this fine-tune | +| --- | --- | --- | +| schema validity | 1.0 | 1.0 | +| event F1 | 0.97 | 0.97 | +| start-exact recall | 0.96 | 0.96 | +| clarification recall | 1.0 | 1.0 | + +| bare (no system prompt) | stock E4B | this fine-tune | +| --- | --- | --- | +| schema validity | 0.967 | **1.0** | +| no-event accuracy | 0.70 | **0.80** | +| clarification recall | 0.50 | **0.625** | +| event F1 | **0.682** | 0.644 | + +i.e. **parity with stock under the engineered prompt** (identical error counts), with +better schema validity and discipline when the prompt is absent. Published as the +project's edge tier after six eval-gated training iterations; the publish-at-parity +decision was made manually by the project owner (the auto-gate requires strict +dominance and therefore did not promote it). +""" + + +@app.function(volumes={"/outputs": outputs}, timeout=3600, + secrets=[modal.Secret.from_name("huggingface")]) +def publish(): + from huggingface_hub import HfApi, hf_hub_download + + subprocess.run(["hf", "upload", REPO, SRC, DEST, "--commit-message", + "Publish E4B edge fine-tune (owner decision; parity with stock — see model card)"], + check=True) + print(f"uploaded {DEST}") + + api = HfApi() + card_path = hf_hub_download(REPO, "README.md") + card = open(card_path, encoding="utf-8").read() + if "## Edge model:" not in card: + card = card + "\n" + CARD_ADDENDUM + api.upload_file(path_or_fileobj=card.encode("utf-8"), path_in_repo="README.md", + repo_id=REPO, + commit_message="Model card: document the E4B edge fine-tune + eval tables") + print("model card updated") + else: + print("model card already documents the edge model; skipped") + + +@app.local_entrypoint() +def main(): + publish.remote() + print(f"Done: https://huggingface.co/{REPO}") diff --git a/training/render_screenshots.py b/training/render_screenshots.py new file mode 100644 index 0000000000000000000000000000000000000000..84445c96a29430320c9ae24fb129d93ff9e8640e --- /dev/null +++ b/training/render_screenshots.py @@ -0,0 +1,132 @@ +"""Render eval threads as iMessage-style screenshots for the vision A/B arm. + +Each record in an eval jsonl becomes training/data/screenshots//.png. +Chat-style threads ("Name: text" lines with a repeated sender or "Me") get +gray/blue bubbles; everything else (flyers, forwarded notices, appointment +cards) renders as a single notice card, line breaks preserved. The PNGs ship +to Modal with the repo (training/modal_eval.py --vision) and are fed to the +model INSTEAD of the thread text by `VISION=1 training/eval.py`. + + python training/render_screenshots.py # both eval files + python training/render_screenshots.py training/data/eval.jsonl +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_FILES = [ROOT / "training" / "data" / "eval.jsonl", + ROOT / "training" / "data" / "eval_unstructured.jsonl"] +OUT_ROOT = ROOT / "training" / "data" / "screenshots" + +W = 390 # iPhone-ish logical width +PAD = 12 +BUBBLE_PAD = 10 +MAX_BUBBLE = 270 +GRAY, BLUE, INK, WHITE = (229, 229, 234), (10, 132, 255), (20, 20, 22), (255, 255, 255) +SENDER_RE = re.compile(r"^([A-Za-z][\w .'’()-]{0,40}?):\s+(.*)$") + +_FONT_CANDIDATES = ["segoeui.ttf", "C:/Windows/Fonts/segoeui.ttf", + "DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "Arial.ttf"] + + +def _font(size: int) -> ImageFont.FreeTypeFont: + for name in _FONT_CANDIDATES: + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +BODY, LABEL = _font(15), _font(11) + + +def _wrap(text: str, font: ImageFont.FreeTypeFont, width: int) -> list[str]: + out = [] + for raw in text.split("\n"): + line = "" + for word in raw.split(" "): + cand = f"{line} {word}".strip() + if font.getlength(cand) <= width or not line: + line = cand + else: + out.append(line) + line = word + out.append(line) + return out + + +def _messages(thread: str) -> list[tuple[str | None, str]]: + """[(sender_or_None, text)] — None sender means a notice card.""" + lines = thread.splitlines() + parsed = [SENDER_RE.match(ln) for ln in lines] + senders = [m.group(1) for m in parsed if m] + is_chat = "Me" in senders or any(senders.count(s) > 1 for s in set(senders)) + if not is_chat: + return [(None, thread)] + msgs: list[tuple[str | None, str]] = [] + for ln, m in zip(lines, parsed): + if m: + msgs.append((m.group(1), m.group(2))) + elif msgs and ln.strip(): # continuation of the previous bubble + msgs[-1] = (msgs[-1][0], msgs[-1][1] + "\n" + ln.strip()) + return msgs + + +def render(thread: str, out_path: Path) -> None: + line_h = BODY.getbbox("Ag")[3] + 4 + label_h = LABEL.getbbox("Ag")[3] + 2 + msgs = [(s, _wrap(t, BODY, MAX_BUBBLE - 2 * BUBBLE_PAD)) for s, t in _messages(thread)] + + height = PAD + for sender, wrapped in msgs: + if sender not in (None, "Me"): + height += label_h + height += len(wrapped) * line_h + 2 * BUBBLE_PAD + 8 + height += PAD + + img = Image.new("RGB", (W, max(height, 80)), WHITE) + d = ImageDraw.Draw(img) + y = PAD + for sender, wrapped in msgs: + bw = min(MAX_BUBBLE, max((BODY.getlength(l) for l in wrapped), default=0) + 2 * BUBBLE_PAD) + bh = len(wrapped) * line_h + 2 * BUBBLE_PAD + mine = sender == "Me" + x = W - PAD - bw if mine else PAD + if sender not in (None, "Me"): + d.text((x + 4, y), sender, font=LABEL, fill=(120, 120, 128)) + y += label_h + d.rounded_rectangle([x, y, x + bw, y + bh], radius=14, + fill=BLUE if mine else GRAY) + ty = y + BUBBLE_PAD + for line in wrapped: + d.text((x + BUBBLE_PAD, ty), line, font=BODY, fill=WHITE if mine else INK) + ty += line_h + y += bh + 8 + img.save(out_path) + + +def main() -> None: + files = [Path(a) for a in sys.argv[1:]] or DEFAULT_FILES + for f in files: + out_dir = OUT_ROOT / f.stem + out_dir.mkdir(parents=True, exist_ok=True) + n = 0 + for line in f.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + rec = json.loads(line) + render(rec["thread"], out_dir / f"{rec['id']}.png") + n += 1 + print(f"{f.name}: rendered {n} screenshot(s) -> {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/training/requirements-train.txt b/training/requirements-train.txt new file mode 100644 index 0000000000000000000000000000000000000000..95f73f4a4cc4b02471eec5363f97befbddab6ee1 --- /dev/null +++ b/training/requirements-train.txt @@ -0,0 +1,15 @@ +# Training-only deps (NOT installed in the Space). Use a GPU box / Colab. +# Let unsloth resolve its own compatible transformers/trl/peft/accelerate/bitsandbytes +# — hand-pinning them causes the auto_docstring / ConstantLengthDataset import errors. +# For the latest bug fixes, also overlay git (see training/modal_train.py), e.g.: +# pip install --no-deps --upgrade \ +# git+https://github.com/unslothai/unsloth.git \ +# git+https://github.com/unslothai/unsloth-zoo.git +unsloth +datasets>=2.20 +huggingface_hub[cli]>=0.24 + +# Serverless GPU runner (training/modal_train.py). Only needed locally to launch +# the run — `modal run training/modal_train.py` provisions the GPU; deps above +# are installed in the Modal image, not on your machine. +modal>=0.64 diff --git a/training/share_trace.py b/training/share_trace.py new file mode 100644 index 0000000000000000000000000000000000000000..768ead8edface23899276e987ca8c107ef58b972 --- /dev/null +++ b/training/share_trace.py @@ -0,0 +1,89 @@ +"""Publish a downloaded agent trace to the Hugging Face Hub (Sharing is Caring). + +Two-step, privacy-first flow: + 1. In the Space's Activity tab, click **⬇ Download trace (JSON)** — the trace + stays on your device; the hosted Space holds NO Hub token. + 2. Run this CLI on your own machine, where you're logged in, to upload it to a + public/private HF **dataset** repo for others to learn from. + +Auth comes from your local Hugging Face login (same as training/export_gguf.sh): + hf auth login # or: export HF_TOKEN=hf_xxxxxxxx + +Usage: + python training/share_trace.py trace.json + python training/share_trace.py trace.json --repo me/imessage-cal-traces --public + python training/share_trace.py trace.json --dry-run # print plan, no network +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # make `server` importable +from server.trace import TRACE_SCHEMA # noqa: E402 single source of truth for the schema id + +DEFAULT_REPO = "n8mauer/imessage-cal-traces" + + +def _load_trace(path: Path) -> dict: + try: + trace = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + sys.exit(f"error: cannot read trace JSON at {path}: {e}") + if not isinstance(trace, dict) or trace.get("schema") != TRACE_SCHEMA: + sys.exit( + f"error: {path} is not an iMessage-cal trace " + f"(expected schema={TRACE_SCHEMA!r}). Download one from the Activity tab." + ) + return trace + + +def _parse_args(argv=None) -> argparse.Namespace: + p = argparse.ArgumentParser(description="Upload an agent trace to a HF dataset repo.") + p.add_argument("trace_path", help="path to a downloaded trace .json") + p.add_argument("--repo", default=DEFAULT_REPO, help=f"HF dataset repo (default {DEFAULT_REPO})") + vis = p.add_mutually_exclusive_group() + vis.add_argument("--private", dest="private", action="store_true", default=True, + help="create the dataset repo private (default)") + vis.add_argument("--public", dest="private", action="store_false", + help="create the dataset repo public") + p.add_argument("--path-in-repo", default=None, + help="destination path inside the repo (default traces/)") + p.add_argument("--dry-run", action="store_true", + help="print the planned upload and exit without any network call") + return p.parse_args(argv) + + +def main(argv=None) -> None: + args = _parse_args(argv) + path = Path(args.trace_path) + _load_trace(path) # validate before doing anything + + path_in_repo = args.path_in_repo or f"traces/{path.name}" + url = f"https://huggingface.co/datasets/{args.repo}" + + if args.dry_run: + vis = "private" if args.private else "public" + print(f"[dry-run] would upload {path} -> {args.repo}:{path_in_repo} ({vis})") + print(f"[dry-run] dataset: {url}") + return + + # Imported here (not at module top) so --dry-run and tests stay network-free. + from huggingface_hub import HfApi + + api = HfApi() + api.create_repo(repo_id=args.repo, repo_type="dataset", private=args.private, exist_ok=True) + api.upload_file( + path_or_fileobj=str(path), + path_in_repo=path_in_repo, + repo_id=args.repo, + repo_type="dataset", + ) + print(f"Uploaded {path.name} -> {url}/blob/main/{path_in_repo}") + print(url) + + +if __name__ == "__main__": + main() diff --git a/training/train_qlora.py b/training/train_qlora.py new file mode 100644 index 0000000000000000000000000000000000000000..8d9bf7dfdf3831acb72f901fcdc91dad778074ed --- /dev/null +++ b/training/train_qlora.py @@ -0,0 +1,152 @@ +"""QLoRA fine-tune of google/gemma-4-31B-it with Unsloth. + +Recipe notes (hard-won — see docs/eval-roadmap.md): +- Formats with the tokenizer's NATIVE Gemma-4 chat template (<|turn> markers), the + same one llama-server --jinja serves. Never override it; a train/serve template + mismatch degraded four straight fine-tunes. +- Masks loss to the assistant response only; LR 5e-5. + +Run on an A100/H100 80GB (Colab Pro+/RunPod/Lambda). For a lighter/cheaper run, +swap BASE_MODEL to the Gemma 4 E4B edge variant — see PLAN.md fallback. + + pip install -r training/requirements-train.txt + python training/make_dataset.py + python training/train_qlora.py +""" +from __future__ import annotations + +import inspect +import os + +# Unsloth must be imported before trl/transformers/peft so its patches apply. +from unsloth import FastLanguageModel + +from datasets import load_dataset # noqa: E402 +from trl import SFTConfig, SFTTrainer # noqa: E402 + + +def _filter_kwargs(cls, kwargs: dict) -> dict: + """Keep only kwargs the class' __init__ accepts. + + trl renames fields across releases (max_seq_length -> max_length, the + SFTTrainer `tokenizer` -> `processing_class`); filtering by signature lets + this script run across trl versions without pinning an exact one. + """ + valid = set(inspect.signature(cls.__init__).parameters) + return {k: v for k, v in kwargs.items() if k in valid} + +BASE_MODEL = os.environ.get("BASE_MODEL", "google/gemma-4-31B-it") +MAX_SEQ_LEN = int(os.environ.get("MAX_SEQ_LEN", "4096")) +NUM_EPOCHS = float(os.environ.get("NUM_EPOCHS", "2")) +OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "training/outputs/gemma4-cal-lora") + + +def main(): + model, tokenizer = FastLanguageModel.from_pretrained( + model_name=BASE_MODEL, + max_seq_length=MAX_SEQ_LEN, + load_in_4bit=True, + ) + model = FastLanguageModel.get_peft_model( + model, + r=16, + lora_alpha=16, + lora_dropout=0.0, + target_modules=[ + "q_proj", "k_proj", "v_proj", "o_proj", + "gate_proj", "up_proj", "down_proj", + ], + use_gradient_checkpointing="unsloth", + ) + # Use the tokenizer's NATIVE chat template. Gemma-4 ships chat_template.jinja + # with `<|turn>role ... ` markers — the SAME template convert_hf_to_gguf + # embeds in the GGUF and llama-server --jinja serves at inference. Overriding it + # with unsloth's legacy "gemma" template () trained a turn format + # inference never uses, and every fine-tune degraded monotonically with more + # steps (docs/eval-roadmap.md Steps 3-6). Train format MUST equal serve format. + probe = tokenizer.apply_chat_template( + [{"role": "user", "content": "ping"}], tokenize=False, add_generation_prompt=True + ) + assert "<|turn>" in probe, ( + "tokenizer's native chat template doesn't look like Gemma-4 (no <|turn> marker); " + f"refusing to train on a mismatched format. Rendered: {probe[:200]!r}" + ) + print(f"native chat template OK: {probe[:120]!r}") + + # Train on the hand-authored set + (if present) the git-ignored SMCalFlow import + # (training/import_smcalflow.py). Both are in the same chat-messages format. + # The hand-authored rows are thread-style (matching the app/eval domain) but are + # outnumbered ~16:1 by SMCalFlow's command-style utterances — upsample them so the + # model doesn't drift toward command-only phrasing (HAND_UPSAMPLE=1 disables). + from datasets import concatenate_datasets + + upsample = int(os.environ.get("HAND_UPSAMPLE", "4")) + hand = load_dataset("json", data_files="training/data/dataset.jsonl", split="train") + parts = [hand] * upsample + if os.path.exists("training/data/smcalflow_train.jsonl"): + parts.append(load_dataset( + "json", data_files="training/data/smcalflow_train.jsonl", split="train")) + ds = concatenate_datasets(parts).shuffle(seed=42) + print(f"training on {len(ds)} examples ({len(hand)} hand-authored x{upsample} + smcalflow)") + + def fmt(batch): + texts = [ + tokenizer.apply_chat_template(m, tokenize=False, add_generation_prompt=False) + for m in batch["messages"] + ] + return {"text": texts} + + ds = ds.map(fmt, batched=True) + + # Build SFTConfig version-tolerantly: pass both the old and new field names for + # the sequence-length cap and drop whichever the installed trl doesn't accept. + args = SFTConfig(**_filter_kwargs(SFTConfig, dict( + output_dir=OUTPUT_DIR, + per_device_train_batch_size=2, + gradient_accumulation_steps=4, + num_train_epochs=NUM_EPOCHS, + learning_rate=5e-5, # was 2e-4; gentler on a near-ceiling base (see eval roadmap) + warmup_ratio=0.03, + logging_steps=10, + save_strategy="epoch", + bf16=True, + dataset_text_field="text", + max_seq_length=MAX_SEQ_LEN, # older trl + max_length=MAX_SEQ_LEN, # newer trl (renamed) + ))) + + # SFTTrainer renamed `tokenizer` -> `processing_class` in trl 0.13. + trainer_kwargs = dict(model=model, train_dataset=ds, args=args) + tr_params = set(inspect.signature(SFTTrainer.__init__).parameters) + if "processing_class" in tr_params: + trainer_kwargs["processing_class"] = tokenizer + else: + trainer_kwargs["tokenizer"] = tokenizer + trainer = SFTTrainer(**trainer_kwargs) + + # Mask loss to the assistant turn only (the ActionPlan JSON). Without this, the + # long SYSTEM prompt dominates the token count and most gradient signal is spent + # re-learning the prompt instead of the answer. Markers are Gemma-4's native ones. + try: + from unsloth.chat_templates import train_on_responses_only + + trainer = train_on_responses_only( + trainer, + instruction_part="<|turn>user\n", + response_part="<|turn>model\n", + ) + print("loss masked to assistant responses only") + except Exception as e: # noqa: BLE001 + print(f"WARNING: response-only masking unavailable ({e}); training on full text") + + trainer.train() + + # Merge LoRA into fp16 weights, ready for GGUF conversion (see export_gguf.sh). + model.save_pretrained_merged( + OUTPUT_DIR + "-merged", tokenizer, save_method="merged_16bit" + ) + print(f"merged model saved to {OUTPUT_DIR}-merged") + + +if __name__ == "__main__": + main() diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/ui/blocks.py b/ui/blocks.py new file mode 100644 index 0000000000000000000000000000000000000000..87c34f474fb611220febe258fb31b7c605216de6 --- /dev/null +++ b/ui/blocks.py @@ -0,0 +1,2746 @@ +"""Custom Gradio Blocks UI (Off-Brand). + +Two surfaces: +- Schedule: paste/inspect a thread (+images, +existing calendar), watch the model + "think" live, then review/edit the ActionPlan, download .ics, optional GCal. +- Activity: a live dashboard of what the agent and model are doing — pipeline + stepper, metric tiles, event timeline, activity chart, and per-run traces. +""" +from __future__ import annotations + +import base64 +import html +import json +import os +import time +from datetime import timedelta +from pathlib import Path +from urllib.parse import urlencode + +import gradio as gr +import pandas as pd +from dateutil import parser as dtparser + +from calendar_out.ics import write_ics +from server import events as bus +from server import impact, memory +from server.imageutil import paths_to_data_uris +from server.schema import ActionPlan, Event + +CSS = (Path(__file__).parent.parent / "static" / "app.css").read_text(encoding="utf-8") + +# Brand logo (robot-in-a-chat-bubble holding a calendar), inlined as a data URI +# so it renders identically under every serve mode (mounted uvicorn / launch). +_LOGO_URI = "data:image/png;base64," + base64.b64encode( + (Path(__file__).parent.parent / "static" / "logo.png").read_bytes() +).decode("ascii") + +# Client-side carousel: auto-advance + arrows + dots + pause-on-hover. A +# MutationObserver re-initialises when Gradio swaps the gr.HTML after an analyze. +# Passed to mount_gradio_app/launch (Gradio 6 ignores js set on Blocks when mounted). +CAROUSEL_JS = """ +() => { + function init() { + document.querySelectorAll('.carousel:not([data-cz])').forEach(function (cz) { + cz.setAttribute('data-cz', '1'); + var slides = [].slice.call(cz.querySelectorAll('.cz-slide')); + var dots = [].slice.call(cz.querySelectorAll('.cz-dot')); + if (slides.length <= 1) return; + var idx = 0; + slides.forEach(function (s, j) { if (s.classList.contains('is-active')) idx = j; }); + var ms = parseInt(cz.getAttribute('data-interval') || '4500', 10); + var timer = null; + function show(i) { + idx = (i + slides.length) % slides.length; + slides.forEach(function (s, j) { s.classList.toggle('is-active', j === idx); }); + dots.forEach(function (d, j) { d.classList.toggle('is-active', j === idx); }); + } + function start() { + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + stop(); timer = setInterval(function () { show(idx + 1); }, ms); + } + function stop() { if (timer) { clearInterval(timer); timer = null; } } + cz.querySelectorAll('.cz-arrow').forEach(function (a) { + a.addEventListener('click', function () { show(idx + parseInt(a.getAttribute('data-dir'), 10)); start(); }); + }); + dots.forEach(function (d) { + d.addEventListener('click', function () { show(parseInt(d.getAttribute('data-go'), 10)); start(); }); + }); + cz.addEventListener('mouseenter', stop); + cz.addEventListener('mouseleave', start); + start(); + }); + wireNav(); + wireAnchors(); + wireFaq(); + wireCounter(); + wireGcal(); + wireFooter(); + wirePasteRun(); + wireModeTheme(); + wirePipeClock(); + } + // Theme the tool card for the device's chosen workflow. The page-load part + // reads localStorage (set-once: don't clobber a live switch); the listener + // part recolors INSTANTLY at click time, so the theme never waits for the + // server round-trip (which only swaps step content/visibility). + function wireModeTheme() { + var tc = document.getElementById('tool-card'); + if (!tc) return; + if (!tc.getAttribute('data-mode')) { + var m = ''; + try { m = localStorage.getItem('offgrid_mode') || ''; } catch (e) {} + tc.setAttribute('data-mode', m.indexOf('Online') >= 0 ? 'online' : 'offline'); + } + document.querySelectorAll('#mode-toggle input[type="radio"]:not([data-mt])').forEach(function (r) { + r.setAttribute('data-mt', '1'); + r.addEventListener('change', function () { + tc.setAttribute('data-mode', (r.value || '').indexOf('Online') >= 0 ? 'online' : 'offline'); + }); + }); + } + // Pasting IS the intent — run the analysis automatically on paste so the + // user never has to click "Find the events". The button stays as fallback. + function wirePasteRun() { + var ta = document.querySelector('#rv-textbox textarea'); + if (!ta || ta.getAttribute('data-pasterun')) return; + ta.setAttribute('data-pasterun', '1'); + ta.addEventListener('paste', function () { + setTimeout(function () { + if ((ta.value || '').trim().length < 12) return; + var btn = document.querySelector('#rv-analyze button'); + if (btn) btn.click(); + }, 250); + }); + } + // Fold Gradio's built-in footer (Use via API · Built with Gradio · Settings) + // into the custom dark banner. appendChild MOVES the node, so Gradio's click + // handlers (API docs / Settings modals) keep working; the MutationObserver + // re-runs this if Gradio ever re-renders the footer. + function wireFooter() { + var gf = document.querySelector('.gradio-container footer:not(#site-footer)'); + var host = document.querySelector('#site-footer .footer-inner'); + if (gf && host && !host.contains(gf)) host.appendChild(gf); + } + // Plain in-page anchors ("See it in action", footer CTA): native #hash jumps + // are unreliable inside the Gradio SPA, so scroll explicitly like the nav does. + function wireAnchors() { + document.querySelectorAll('a[href^="#"]:not([data-tab]):not([data-az])').forEach(function (a) { + a.setAttribute('data-az', '1'); + a.addEventListener('click', function (e) { + var id = (a.getAttribute('href') || '').slice(1); + if (!id) return; + var el = document.getElementById(id); + if (!el) return; + e.preventDefault(); + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }); + } + // Per-user Google Calendar: open the OAuth popup; persist + restore the + // connection. The token JSON lives ONLY in localStorage; it carries a + // refresh_token (the server refreshes at push time), so once connected the + // visitor stays connected across visits — never asked again. mark() re-runs + // on every init()/re-render, restoring the ✓ state on page load. + function wireGcal() { + function tokenInfo() { + try { + var raw = localStorage.getItem('gcal_token'); + if (!raw) return null; + var t = JSON.parse(raw); + if (t.refresh_token) return t; // server can refresh + if (t.expiry && new Date(t.expiry) > new Date()) return t; // still valid + localStorage.removeItem('gcal_token'); // stale + unrefreshable + return null; + } catch (e) { return null; } + } + // Verification state lives on window: wireGcal re-runs with a fresh + // closure on every MutationObserver tick, and mark() must not downgrade + // a server-verified connection on re-render. + // __gcalVerify: '' (unchecked) | 'ok' (Google answered) | 'bad' (token dead) + // __gcalCheck: once-per-page-load guard for the /oauth2/check fetch + // Write textContent ONLY when it actually changes: an identical-value + // write still replaces the text node, which is a childList mutation — + // and the body MutationObserver re-runs init() (→ mark()) on every + // childList mutation, so an unconditional write here is an infinite + // init↔mark feedback loop that pegs the main thread ("This page is + // slowing down Firefox"). + function setTxt(el, v) { if (el.textContent !== v) el.textContent = v; } + function mark() { + var v = window.__gcalVerify || ''; + var ok = !!tokenInfo() && v !== 'bad'; + var label = ok ? (v === 'ok' ? ' ✓ connected · verified' : ' ✓ connected') : ''; + document.querySelectorAll('.cal-provider[data-provider="google"]').forEach(function (p) { + p.classList.toggle('is-connected', ok); + }); + document.querySelectorAll('.gcal-state').forEach(function (s) { setTxt(s, label); }); + document.querySelectorAll('.gcal-badge').forEach(function (b) { + setTxt(b, ok ? 'Google:' + label : 'Google: not connected'); + b.classList.toggle('is-on', ok); + }); + } + // Round-trip the stored token to Google once per page load: the local + // shape check can't see a revoked/garbage token. Only a DEFINITIVE "no" + // clears the token; transient failures (env unset, network, launch-mode + // Space where /oauth2/check isn't served) keep the optimistic state. + function verify() { + var t = null; + try { t = localStorage.getItem('gcal_token'); } catch (e) {} + if (!t || window.__gcalCheck) return; + window.__gcalCheck = true; + fetch('/oauth2/check', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: t }) }) + .then(function (r) { if (r.status !== 200) throw 0; return r.json(); }) + .then(function (d) { + if (d.ok) { + window.__gcalVerify = 'ok'; + if (d.token) { try { localStorage.setItem('gcal_token', d.token); } catch (e) {} } + } else if (!d.transient) { + window.__gcalVerify = 'bad'; + try { localStorage.removeItem('gcal_token'); } catch (e) {} + } + mark(); + }) + .catch(function () { /* indeterminate: keep shape-check state */ }); + } + mark(); + verify(); + document.querySelectorAll('.gcal-connect:not([data-g])').forEach(function (a) { + a.setAttribute('data-g', '1'); + a.addEventListener('click', function (e) { + e.preventDefault(); + window.open('/oauth2/start', 'gcal_oauth', 'width=520,height=680'); + }); + }); + document.querySelectorAll('.gcal-disconnect:not([data-g])').forEach(function (a) { + a.setAttribute('data-g', '1'); + a.addEventListener('click', function (e) { + e.preventDefault(); + try { localStorage.removeItem('gcal_token'); } catch (_) {} + window.__gcalVerify = ''; + mark(); + }); + }); + if (!window.__gcalMsg) { + window.__gcalMsg = true; + window.addEventListener('message', function (e) { + if (e.origin !== location.origin || !e.data || !e.data.gcal_token) return; + try { localStorage.setItem('gcal_token', e.data.gcal_token); } catch (_) {} + // freshly minted token: reset and re-verify it + window.__gcalCheck = false; + window.__gcalVerify = ''; + mark(); + verify(); + }); + } + } + // Live character counter for the paste box. + function wireCounter() { + var ta = document.querySelector('#rv-textbox textarea'); + var out = document.getElementById('rv-counter'); + if (!ta || !out || ta.getAttribute('data-counter')) return; + ta.setAttribute('data-counter', '1'); + function upd() { out.textContent = (ta.value ? ta.value.length : 0) + ' / 12000'; } + ta.addEventListener('input', upd); + upd(); + } + // Make the grouped nav banner drive the (hidden) Gradio tabs. + function wireNav() { + function activate(name) { + var all = [].slice.call(document.querySelectorAll('button[role="tab"], .tab-nav button')); + // Prefer the real interactive buttons over Gradio's hidden measuring clones. + var vis = all.filter(function (b) { return !b.closest('.visually-hidden'); }); + for (var i = 0; i < vis.length; i++) { + if ((vis[i].textContent || '').trim() === name) { vis[i].click(); return true; } + } + // Mobile fallback: a narrow viewport pushes the later tabs (Memory / Feed / + // Submission, index >= 3) into Gradio's collapsed, .visually-hidden overflow + // — absent from `vis`, so those nav links went dead while Home/Activity + // (which stay visible) worked. Click every matching button in the FULL set: + // the real (hidden) tab button switches the tab; the clones are no-ops. + var hit = false; + for (var j = 0; j < all.length; j++) { + if ((all[j].textContent || '').trim() === name) { all[j].click(); hit = true; } + } + return hit; + } + document.querySelectorAll('#site-nav [data-tab]:not([data-nav])').forEach(function (a) { + a.setAttribute('data-nav', '1'); + a.addEventListener('click', function (e) { + e.preventDefault(); + activate(a.getAttribute('data-tab')); + var anchor = a.getAttribute('data-anchor'); + if (anchor) { + setTimeout(function () { + var el = document.querySelector(anchor); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 160); + } else { + setTimeout(function () { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 60); + } + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); + }); + }); + } + // FAQ: tab switching + client-side search filter. + // While a search query is active, both panels are shown so cross-tab + // matches are visible; clearing the search restores the active tab. + function wireFaq() { + document.querySelectorAll('.lp-faq-section:not([data-faq])').forEach(function (sec) { + sec.setAttribute('data-faq', '1'); + var tabs = [].slice.call(sec.querySelectorAll('.lp-faq-tab')); + var lists = [].slice.call(sec.querySelectorAll('.lp-faq-list')); + var empty = sec.querySelector('.lp-faq-empty'); + var search = sec.querySelector('#faq-search'); + var query = ''; + + function activeTab() { + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].classList.contains('is-active')) return tabs[i].getAttribute('data-tab'); + } + return tabs[0] && tabs[0].getAttribute('data-tab'); + } + function render() { + var active = activeTab(); + var anyVisible = false; + lists.forEach(function (l) { + var show = query ? true : (l.getAttribute('data-panel') === active); + l.classList.toggle('is-hidden', !show); + if (show) { + l.querySelectorAll('.lp-faq-item').forEach(function (it) { + var match = !query || (it.textContent || '').toLowerCase().indexOf(query) !== -1; + it.style.display = match ? '' : 'none'; + if (match) anyVisible = true; + if (!match && it.open) it.open = false; + }); + } + }); + if (empty) empty.classList.toggle('is-hidden', !query || anyVisible); + } + tabs.forEach(function (t) { + t.addEventListener('click', function () { + tabs.forEach(function (o) { o.classList.toggle('is-active', o === t); }); + render(); + }); + }); + if (search) { + search.addEventListener('input', function (e) { + query = ((e.target && e.target.value) || '').trim().toLowerCase(); + render(); + }); + } + render(); + }); + } + // Live elapsed clock for the pipeline card. ONE global interval + a + // window-level start timestamp, because Gradio replaces the trace DOM on + // every generator yield — per-element timers would die each re-render. + function wirePipeClock() { + var running = document.querySelector('.pipe-card[data-state="running"]'); + if (running) { + if (!window.__pipeStart) window.__pipeStart = Date.now(); + if (!window.__pipeTick) { + window.__pipeTick = setInterval(function () { + var s = ((Date.now() - (window.__pipeStart || Date.now())) / 1000).toFixed(1) + 's'; + document.querySelectorAll('.pipe-card[data-state="running"] .pipe-clock') + .forEach(function (c) { + // nodeValue mutates the EXISTING text node (characterData) — + // the body MutationObserver below is childList-only, so the + // 100ms tick never re-fires init(). + if (c.firstChild) { c.firstChild.nodeValue = s; } else { c.textContent = s; } + }); + }, 100); + } + } else { + // done/error/idle: stop ticking and leave the server-rendered Speed + // value alone; reset so the next run starts from zero. + window.__pipeStart = null; + if (window.__pipeTick) { clearInterval(window.__pipeTick); window.__pipeTick = null; } + } + } + init(); + // Coalesce re-init: Gradio replaces whole subtrees on every streamed yield, + // and a raw MutationObserver(init) would run the (expensive) full re-wire + // once per mutation batch — and turn any unguarded DOM write inside init() + // into a tight feedback loop. One trailing 60ms timer caps re-wiring at + // ~16/s no matter what mutates; newly rendered elements get wired on the + // next pass, which is imperceptible. + var reinitPending = false; + new MutationObserver(function () { + if (reinitPending) return; + reinitPending = true; + setTimeout(function () { reinitPending = false; init(); }, 60); + }).observe(document.body, { childList: true, subtree: true }); +} +""" + +THEME = gr.themes.Base( + primary_hue=gr.themes.colors.purple, + secondary_hue=gr.themes.colors.cyan, + neutral_hue=gr.themes.colors.slate, +) + +STAGE_COLORS = { + "ingest": "#22d3ee", + "vision": "#a78bfa", + "model": "#f59e0b", + "decision": "#34d399", + "conflict": "#f87171", + "calendar": "#8b5cf6", +} + + +# A realistic class group-chat so a first-time visitor sees value in one tap, +# even in stub mode (adapted from a seed in training/make_dataset.py). +SAMPLE_THREAD = ( + "Room parent (3rd grade chat): Reminder — school picture day is this " + "Thursday at 9am, kids wear the green class shirt!\n" + "Coach Dana: Also heads up, soccer practice moves to Tuesday 5pm this week.\n" + "Me: thanks! adding both so I don't forget" +) + + +def _load_sample() -> str: + return SAMPLE_THREAD + + +# --------------------------------------------------------------------------- # +# Schedule tab helpers +# --------------------------------------------------------------------------- # +def _events_to_rows(events: list[Event]) -> list[list]: + return [ + [e.title, e.start, e.end or "", e.location or "", e.reminder_minutes or ""] + for e in events + ] + + +def _rows_to_events(rows) -> list[Event]: + out = [] + for r in rows or []: + if not r or not r[0]: + continue + out.append( + Event( + title=r[0], + start=r[1], + end=(r[2] or None), + location=(r[3] or None), + reminder_minutes=int(r[4]) if str(r[4]).strip() else None, + ) + ) + return out + + +def _fmt_when(start: str, end: str | None = None) -> str: + """Human-friendly time range from ISO strings; falls back to the raw value.""" + try: + s = dtparser.isoparse(start) + day = s.strftime("%a, %b ") + str(s.day) + t1 = s.strftime("%I:%M %p").lstrip("0") + if end: + e = dtparser.isoparse(end) + return f"{day} · {t1} – {e.strftime('%I:%M %p').lstrip('0')}" + return f"{day} · {t1}" + except Exception: # noqa: BLE001 + return start or "" + + +def _plan_markdown(plan: ActionPlan) -> str: + """Render the plan summary as HTML (shown in a gr.HTML). Keeps the ⚠️ marker so + ``_count_conflicts`` can still tally conflicts from the rendered string.""" + parts = [] + if plan.reasoning: + parts.append(f'

{html.escape(plan.reasoning)}

') + if plan.conflicts: + badges = "".join( + f'⚠️ {html.escape(c.severity)} · ' + f'{html.escape(c.clashes_with)}' + for c in plan.conflicts + ) + parts.append(f'
{badges}
') + if plan.proposed_times: + chips = "".join( + f'{html.escape(_fmt_when(t))}' + for t in plan.proposed_times + ) + parts.append( + f'
Free slots{chips}
' + ) + if plan.needs_clarification: + parts.append(f'

❓ {html.escape(plan.needs_clarification)}

') + if not parts: + return '

✓ All clear — no conflicts.

' + return f'
{"".join(parts)}
' + + +def _event_meta(e: Event) -> str: + """A • bullet-separated meta line (Netflix-style), location/reminder.""" + bits = [] + if e.location: + bits.append(f"📍 {html.escape(e.location)}") + if e.reminder_minutes: + bits.append(f"⏰ {int(e.reminder_minutes)} min before") + return f'
{" • ".join(bits)}
' if bits else "" + + +# --------------------------------------------------------------------------- # +# Rotating hero carousel (billboard slides; auto-advance via CAROUSEL_JS) +# --------------------------------------------------------------------------- # +def _slide(kicker: str, title: str, sub: str = "", note: str = "") -> str: + sub_html = f'
{html.escape(sub)}
' if sub else "" + note_html = f'
{html.escape(note)}
' if note else "" + return ( + '
' + '
' + '
' + f'
{html.escape(kicker)}
' + f'

{html.escape(title)}

' + f"{sub_html}{note_html}" + "
" + ) + + +def _event_slide(e: Event, kicker: str) -> str: + note = f'
{html.escape(e.notes)}
' if e.notes else "" + return ( + '
' + '
' + '
' + f'
{html.escape(kicker)}
' + f'

{html.escape(e.title or "Event")}

' + f'
{html.escape(_fmt_when(e.start, e.end))}
' + f"{_event_meta(e)}{note}" + "
" + ) + + +def _carousel_html(slides: list[str], interval: int = 9000) -> str: + """Wrap slides in an auto-rotating carousel. First slide is active by default so + it shows even if JS is unavailable; CAROUSEL_JS wires advance + arrows + dots.""" + if not slides: + return "" + track = "".join( + s.replace('class="cz-slide', 'class="cz-slide is-active', 1) if i == 0 else s + for i, s in enumerate(slides) + ) + multi = len(slides) > 1 + arrows = ( + '' + '' + ) if multi else "" + dots = ( + '
' + + "".join( + f'' + for i in range(len(slides)) + ) + + "
" + ) if multi else "" + return ( + f'' + ) + + +def _img_slide(bg_class: str, kicker: str, title: str, sub: str) -> str: + """A showcase slide with an SVG illustration background + dark scrim + white text.""" + return ( + f'
' + '
' + '
' + f'
▍{html.escape(kicker)}
' + f'

{html.escape(title)}

' + f'
{html.escape(sub)}
' + "
" + ) + + +def _idle_carousel() -> str: + """Showcase slides (visible on load) — 4 image-backed cards selling impact + use.""" + return _carousel_html([ + _img_slide("cz-bg-chat", "Never miss a school event", + "Paste the class group chat", + "Every date & time becomes a calendar event — automatically."), + _img_slide("cz-bg-flyer", "Snap it, don’t type it", + "A flyer or screenshot works too", + "It reads the date, time & place right from the image."), + _img_slide("cz-bg-cal", "No more double-booking", + "Checked against your calendar", + "Catches clashes and suggests free times before you commit."), + _img_slide("cz-bg-reply", "Reply in one tap", + "A response, written for you", + "RSVP and confirm without drafting a thing."), + _img_slide("cz-bg-carpool", "Carpool, sorted", + "Drop the carpool thread", + "Pickup & drop-off times — and whose turn to drive — land on your calendar."), + _img_slide("cz-bg-appt", "Don’t miss the appointment", + "Forward the reminder text", + "Doctor, dentist & vet visits become events — with a heads-up before."), + _img_slide("cz-bg-party", "RSVP before it slips", + "Drop the party invite", + "The party, the RSVP deadline, and a one-tap reply — all handled."), + ]) + + +def _render_event_carousel(rows) -> str: + """Rotating hero carousel of the found events — one per slide.""" + events = _rows_to_events(rows) + if not events: + return _carousel_html([ + _slide("No events", "Nothing to schedule yet", + "Paste a chat with a date & time, or tap “Try a sample”.") + ]) + n = len(events) + slides = [ + _event_slide(e, "Next up" if i == 0 else f"{i + 1} of {n}") + for i, e in enumerate(events) + ] + return _carousel_html(slides) + + +def _provider_row(provider: str) -> str: + """One row of the unified 'Connect your calendar' block. Google is the only + provider with real OAuth (one click, token kept in the visitor's browser); + Outlook and Apple already work with no sign-in via the per-event quick-add + links and the .ics download, so their rows just say where to find that.""" + if provider == "google": + return ( + '
' + '📅 Google Calendar' + '🔗 Connect' + '' + 'disconnect' + 'one click — stays connected ' + "in this browser" + 'switch to ☁️ Online to ' + "enable
" + ) + if provider == "microsoft": + return ( + '
' + '📅 Microsoft Outlook' + 'one-click “Add to Outlook” links appear on ' + "every event found — no sign-in needed
" + ) + return ( + '
' + '📅 Apple Calendar' + 'use “Add to Apple Calendar” on any event or the ' + ".ics download — opens straight in Calendar
" + ) + + +def _gcal_badge_html() -> str: + """Tiny Google connection badge for the export toolbar — wireGcal()'s mark() + rewrites its text/class from the (server-verified) connection state, so the + user sees whether 'Add to Google Calendar' will work BEFORE clicking it.""" + return ( + '
' + 'Google: not connected
' + ) + + +def _connect_block_html() -> str: + """The unified provider block in Step 2a (Google row mirrored on the Agent + tab). wireGcal() restores the connected state from localStorage on page + load, so a connected visitor is never asked to connect again.""" + return ( + '
' + + _provider_row("google") + + _provider_row("microsoft") + + _provider_row("apple") + + '
Your Google token stays in this browser — ' + "never on our server.
" + ) + + +def _quick_add_links(e: Event) -> str: + """Per-event one-click 'add to calendar' hyperlinks — THE single export + surface: prefilled Google / Outlook template URLs (one click + Save, no + OAuth), plus an iCal link that delivers a single-event .ics (Apple + Calendar, Outlook desktop, and most calendar apps; no prefill URL exists + for those).""" + try: + s = dtparser.isoparse(e.start) + end = dtparser.isoparse(e.end) if e.end else s + timedelta(hours=1) + except Exception: # noqa: BLE001 unparseable date -> no links + return "" + fmt = "%Y%m%dT%H%M%S" + g = "https://calendar.google.com/calendar/render?" + urlencode({ + "action": "TEMPLATE", "text": e.title or "Event", + "dates": f"{s.strftime(fmt)}/{end.strftime(fmt)}", + "details": e.notes or "", "location": e.location or "", + }) + o = "https://outlook.live.com/calendar/0/action/compose?" + urlencode({ + "subject": e.title or "Event", "startdt": s.isoformat(), + "enddt": end.isoformat(), "body": e.notes or "", "location": e.location or "", + }) + try: + from calendar_out.ics import events_to_ics + + a = ("data:text/calendar;base64," + + base64.b64encode(events_to_ics([e])).decode("ascii")) + ical = (f' · iCal' + f' · .ics') + except Exception: # noqa: BLE001 + ical = "" + return ( + '
Add to: ' + f'Google · ' + f'Outlook' + f"{ical}
" + ) + + +def _render_event_cards(rows, mode: str = "", notes=None) -> str: + """The condensed event list (plain cards, no billboard). Every card carries + the quick-add hyperlinks in both modes. (``mode`` stays in the signature — + all existing wiring passes it.) + + ``notes`` is the per-event notes list captured at analysis time (the + ``found_notes`` State). Notes aren't a Dataframe column, so rows alone + can't carry the arrival context ("Appointment at 10:30; arrive 15 min + early") — this card is the one place the user sees it. Notes are matched + to events by position and dropped if the counts diverge (a row was added + or removed in the editor), so a stale note never lands on the wrong event. + """ + events = _rows_to_events(rows) + if not events: + return ( + '
No events found yet — tap ' + "Try a sample, or paste a chat with a date & time.
" + ) + notes = list(notes or []) + aligned = len(notes) == len(events) + cards = [] + for i, e in enumerate(events): + note_txt = e.notes or (notes[i] if aligned else "") or "" + loc = (f'
📍 {html.escape(e.location)}
' + if e.location else "") + rem = (f'
⏰ Reminder {int(e.reminder_minutes)} min before
' + if e.reminder_minutes else "") + note = f'
📝 {html.escape(note_txt)}
' if note_txt else "" + quick = _quick_add_links(e) + cards.append( + f'
' + '' + '
' + f'

{html.escape(e.title or "Event")}

' + f'{html.escape(_fmt_when(e.start, e.end))}' + f"{loc}{rem}{note}{quick}" + "
" + ) + n = len(events) + # 2+ events: one tap for everything — an all-events .ics in the same link + # style (Google/Outlook have no multi-event prefill URL, iCal does it all). + all_link = "" + if n > 1: + try: + from calendar_out.ics import events_to_ics + + a = ("data:text/calendar;base64," + + base64.b64encode(events_to_ics(events)).decode("ascii")) + all_link = (f'' + f"⬇ iCal — all {n} events") + except Exception: # noqa: BLE001 + all_link = "" + head = (f'
{n} event{"" if n == 1 else "s"} found' + f"{all_link}
") + return f'
{head}
{"".join(cards)}
' + + +def _load_busy(cal_path): + # Self-hosted convenience: a locally hosted agent/install can point + # CAL_ICS_PATH at an exported calendar file so step 4 completes itself — + # an explicit upload always takes precedence. Unset on the cloud Space. + if not cal_path: + cal_path = os.environ.get("CAL_ICS_PATH", "").strip() or None + if not cal_path: + return [] + try: + from calendar_out.freebusy import load_ics_busy + + with open(cal_path, "rb") as f: + return load_ics_busy(f.read()) + except Exception: # noqa: BLE001 + return [] + + +def _on_analyze(conversation: str, cal_path, image_paths, mem_json: str = ""): + """Generator: run the SAME agentic workflow as the Agent tab (the MiniCPM/MCP + orchestrator) behind the homepage's existing UX — the step trace streams into + the "watch it think" panel, then the usual events / conflicts / reply fill in. + Yields a 7-tuple: (trace, table rows, plan html, reply, status, notes, + pipeline) — notes feeds the ``found_notes`` State (the 5-column table can't + carry per-event notes); the trailing pipeline HTML feeds the tracker card + that lives ABOVE the status line, outside the trace accordion. ``mem_json`` + is the user's per-device (localStorage) memory.""" + import base64 as _b64 + + from server.orchestrator import run_orchestrator + + # Calendar: explicit upload wins; CAL_ICS_PATH keeps the self-hosted + # auto-calendar behavior (step 4 completes itself locally). + if not cal_path: + cal_path = os.environ.get("CAL_ICS_PATH", "").strip() or None + ics_b64 = None + if cal_path: + try: + ics_b64 = _b64.b64encode(Path(cal_path).read_bytes()).decode("ascii") + except Exception: # noqa: BLE001 + ics_b64 = None + images = paths_to_data_uris(image_paths or []) + memory_block = memory.facts_to_recall(_facts_load(mem_json)) or None + + # Pipeline-card meta: evidence reflects what's ACTUALLY read (cal_path is + # post-fallback here); elapsed at the final yield is the Speed chip. + t0 = time.monotonic() + evidence = _evidence_label(conversation, image_paths, cal_path) + _meta = lambda: {"elapsed": time.monotonic() - t0, "evidence": evidence} # noqa: E731 + + steps: list[dict] = [] + final = None + for step in run_orchestrator(conversation or "", ics_b64=ics_b64, + memory_block=memory_block, images=images or None): + steps.append(step) + if step.get("kind") == "final": + final = step + yield (_render_agent_trace(steps), gr.update(), gr.update(), gr.update(), + "🤖 the agent is working…", gr.update(), + _render_pipeline_card(steps, _meta())) + if not final: + yield (_render_agent_trace(steps), gr.update(), gr.update(), gr.update(), + "The agent didn't reach a final step.", gr.update(), + _render_pipeline_card(steps, _meta())) + return + plan_dict = final.get("plan") or {} + try: + plan = ActionPlan(**plan_dict) + except Exception: # noqa: BLE001 defensive: dict drift -> empty plan + plan = ActionPlan(reasoning="", events=[], conflicts=[], proposed_times=[], + reply_draft="", needs_clarification=None) + # The per-event card links (the one export surface) are plain anchors — + # Gradio can't hook their clicks — so a capture is recorded when the run + # surfaces events, not on an export button. + if plan.events: + impact.record_capture(len(plan.events), conflicts_caught=len(plan.conflicts)) + yield ( + _render_agent_trace(steps), + _events_to_rows(plan.events), + _plan_markdown(plan), + plan.reply_draft, + f"Found {len(plan.events)} event(s) ({len(images)} image(s) read), " + f"{len(plan.conflicts)} conflict(s).", + [e.notes or "" for e in plan.events], + _render_pipeline_card(steps, _meta()), + ) + + +def _count_conflicts(plan_md: str) -> int: + """Conflicts the user just saw, read back from the rendered plan markdown + (one ⚠️ per conflict in ``_plan_markdown``). Lets the export handlers record + conflicts_caught without widening ``_on_analyze``'s output tuple.""" + return (plan_md or "").count("⚠️") + + +def _on_start_over(): + """Clear inputs + hide results — the 'Start over' button.""" + return ( + "", # conversation + None, # images_in + None, # existing_cal + gr.update(visible=False), # results + "", # status + "", # screenshot_status + "", # trace_status + _render_pipeline_card([]), # pipeline tracker back to idle + ) + + +def _screenshot_status(files) -> str: + """Contextual hint shown when screenshot(s) are attached.""" + n = len(files) if files else 0 + if not n: + return "" + plural = "s" if n != 1 else "" + return f'
📎 {n} screenshot{plural} attached — it’ll be read for events.
' + + +def _on_share_trace(checked: bool, rows) -> str: + """Privacy-safe trace toggle. When on, record a REDACTED trace locally + (events only — never the raw chat text) and emit an activity event. Does + not publish to the Hub; that stays in training/share_trace.py.""" + if not checked: + return "" + events = _rows_to_events(rows) + trace = { + "events": [ + {"title": e.title, "start": e.start, "end": e.end, "location": e.location} + for e in events + ], + "n_events": len(events), + } + try: + path = Path(os.environ.get("TRACE_PATH", "/tmp/offgrid_traces.jsonl")) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(trace) + "\n") + except Exception: # noqa: BLE001 local logging is best-effort + pass + bus.emit("decision", f"privacy-safe trace recorded ({len(events)} event(s))", events=len(events)) + return ('
✓ Privacy-safe trace recorded ' + "(events only; raw text not stored).
") + + +def _prep_ics(rows): + """Pre-generate the .ics the moment analysis finishes so the file widget is + a ready download link. Deliberately does NOT record impact — a capture is + only counted on an explicit export action (_on_make_ics / gcal push).""" + events = _rows_to_events(rows) + if not events: + return gr.update() + return write_ics(events) + + +# --------------------------------------------------------------------------- # +# Agent tab: MiniCPM-planned orchestrator over the Space's own MCP tools +# --------------------------------------------------------------------------- # +_STEP_ICONS = {"plan": "🧠", "tool_call": "🔧", "tool_result": "📥", + "final": "✅", "error": "⚠️"} + +# The pipeline card's six stages: (key, tile label, running caption). +# Keys map onto the orchestrator's real step stream — capture/read on the plan +# step, the middle three on their MCP tool calls, reply on the final step. +_PIPE_STAGES = [ + ("capture", "Capture", "Reading your thread…"), + ("read", "Read", "Planning the work…"), + ("extract", "Extract", "Extracting events…"), + ("conflicts", "Conflicts", "Checking your calendar…"), + ("export", "Export", "Writing your .ics…"), + ("reply", "Reply", "Drafting a reply…"), +] +_PIPE_TOOL_STAGE = {"extract_events": "extract", "check_conflicts": "conflicts", + "make_ics": "export"} + + +def _pipeline_stage_states(steps: list[dict]) -> tuple[dict[str, str], str]: + """Derive per-stage states (todo/active/done/skip/error) plus the card state + (idle/running/done/error) purely from the orchestration step stream. The LLM + planner may interleave extra plan steps, "(observation)" results, and an + error step followed by the scripted fallback replay — only a TRAILING error + renders the error state.""" + if not steps: + return {k: "todo" for k, *_ in _PIPE_STAGES}, "idle" + called, resulted = set(), set() + seen_plan = has_final = False + for s in steps: + kind, tool = s.get("kind"), s.get("tool", "") + if kind == "plan": + seen_plan = True + elif kind == "tool_call" and tool in _PIPE_TOOL_STAGE: + called.add(tool) + elif kind == "tool_result" and tool in _PIPE_TOOL_STAGE: + resulted.add(tool) + elif kind == "final": + has_final = True + states = {"capture": "done"} + states["read"] = "done" if (called or has_final) else ( + "active" if seen_plan else "todo") + for tool, key in _PIPE_TOOL_STAGE.items(): + if tool in resulted: + states[key] = "done" + elif tool in called: + states[key] = "active" + else: + # final arrived without this tool ever running: genuinely skipped + # (no calendar uploaded / no events to export) + states[key] = "skip" if has_final else "todo" + if has_final: + states["reply"] = "done" + elif "make_ics" in resulted or "extract_events" in resulted: + states["reply"] = "active" + else: + states["reply"] = "todo" + card = "done" if has_final else "running" + if steps[-1].get("kind") == "error": + card = "error" + for key, *_ in _PIPE_STAGES: + if states.get(key) not in ("done", "skip"): + states[key] = "error" + break + return states, card + + +def _evidence_label(conversation: str, image_paths, cal_path) -> str: + """What the run actually read — the Evidence chip in the pipeline summary.""" + bits = [] + n_chars = len((conversation or "").strip()) + if n_chars: + bits.append(f"Pasted thread ({n_chars} chars)") + n_img = len(image_paths or []) + if n_img: + bits.append(f"{n_img} screenshot{'s' if n_img != 1 else ''}") + if cal_path: + try: + bits.append(f"+ {Path(cal_path).name}") + except Exception: # noqa: BLE001 + bits.append("+ calendar.ics") + label = " · ".join(bits) or "No input" + return label[:63] + "…" if len(label) > 64 else label + + +_PIPE_BADGE = {"done": "✓", "skip": "–", "error": "!"} + + +def _render_pipeline_card(steps: list[dict], meta: dict | None = None) -> str: + """The elevated stepper card above the step trace: live stage tiles, a + sweeping shimmer + ticking clock while running (wirePipeClock drives the + 100ms tick client-side off data-state), and a permanent summary-chip row + once the run completes.""" + states, card_state = _pipeline_stage_states(steps) + title = {"done": "Processing complete", "error": "Processing failed"}.get( + card_state, "Processing Pipeline") + elapsed = (meta or {}).get("elapsed") + clock = f"{elapsed:.1f}s" if elapsed is not None else "—" + tiles = [] + for i, (key, name, _cap) in enumerate(_PIPE_STAGES): + st = states.get(key, "todo") + badge = _PIPE_BADGE.get(st, str(i + 1)) + tiles.append( + f'
' + f'{badge}' + f'{name}
' + ) + track = ''.join(tiles) + caption = "" + if card_state == "running": + cap_txt = next((c for k, _n, c in _PIPE_STAGES + if states.get(k) == "active"), "Working…") + caption = f'
{html.escape(cap_txt)}
' + summary = "" + if card_state == "done" and meta is not None: + final = next((s for s in steps if s.get("kind") == "final"), {}) + plan = final.get("plan") or {} + confident = not plan.get("needs_clarification") + conf_cls, conf_txt = (("is-high", "HIGH") if confident + else ("is-review", "NEEDS REVIEW")) + counts = (f"{len(plan.get('events') or [])} event(s) · " + f"{len(plan.get('conflicts') or [])} conflict(s)") + evidence = html.escape(meta.get("evidence") or "—") + summary = ( + '
' + f'Speed{clock}' + f'Confidence{conf_txt}' + f'Evidence{evidence}' + f'Found{counts}' + "
" + ) + return ( + f'
' + '
' + f'{title}' + f'{clock}
' + f'
{track}
' + f"{caption}{summary}
" + ) + + +def _render_agent_trace(steps: list[dict]) -> str: + """The agent's visible work: one card per orchestration step. The pipeline + card is a SEPARATE component now (above the status line, outside the + accordion) — see the ``pipeline`` / ``ag_pipeline`` gr.HTMLs, fed by + `_render_pipeline_card` from the same generators.""" + if not steps: + return '
No run yet — paste a thread and run the agent.
' + cards = [] + for s in steps: + icon = _STEP_ICONS.get(s.get("kind"), "•") + kind = s.get("kind", "") + if kind == "tool_call": + body = (f'{html.escape(str(s.get("tool", "")))}' + f' {html.escape(json.dumps(s.get("args", {}), default=str)[:160])}') + elif kind == "tool_result": + body = (f'{html.escape(str(s.get("tool", "")))} → ' + f'{html.escape(json.dumps(s.get("result", ""), default=str)[:200])}') + elif kind == "final": + summ = s.get("summary", {}) + body = (f'done — {summ.get("events", 0)} event(s), ' + f'{summ.get("conflicts", 0)} conflict(s), .ics ready') + else: + body = html.escape(str(s.get("text", ""))) + cards.append(f'
{icon}' + f'{body}
') + return f'
{"".join(cards)}
' + + +def _agent_plan_rows(plan: dict) -> list[list]: + return [ + [e.get("title"), e.get("start"), e.get("end") or "", + e.get("location") or "", e.get("reminder_minutes") or ""] + for e in (plan.get("events") or []) + ] + + +def _on_run_agent(thread: str, cal_path, image_paths, mem_json: str = "", + mode: str = ""): + """Generator: stream the orchestrator's steps into the trace panel, then + surface the FULL homepage result surface — plan summary, carousel, cards, + editable table, reply, and the agent-made .ics. + + Yields: (trace, table_rows, plan_md, cards, carousel, reply, status, + conflicts, results_visible, ics_file, pipeline) + """ + import base64 as _b64 + import tempfile + + from server.orchestrator import run_orchestrator + + _idle = (gr.update(),) * 9 + + if not (thread or "").strip() and not image_paths: + yield (_render_agent_trace([]), + *(gr.update(),) * 5, + "Paste a thread or attach a screenshot first (or use the sample).", + gr.update(), gr.update(), gr.update(), + _render_pipeline_card([])) + return + # Calendar: explicit upload wins; CAL_ICS_PATH keeps parity with the + # homepage's self-hosted auto-calendar behavior. + if not cal_path: + cal_path = os.environ.get("CAL_ICS_PATH", "").strip() or None + ics_b64 = None + if cal_path: + try: + ics_b64 = _b64.b64encode(Path(cal_path).read_bytes()).decode("ascii") + except Exception: # noqa: BLE001 + ics_b64 = None + images = paths_to_data_uris(image_paths or []) + memory_block = memory.facts_to_recall(_facts_load(mem_json)) or None + + # Same pipeline-card meta as the homepage run (full parity in the trace). + t0 = time.monotonic() + evidence = _evidence_label(thread, image_paths, cal_path) + _meta = lambda: {"elapsed": time.monotonic() - t0, "evidence": evidence} # noqa: E731 + + steps: list[dict] = [] + final = None + for step in run_orchestrator(thread or "", ics_b64=ics_b64, + memory_block=memory_block, images=images or None): + steps.append(step) + if step.get("kind") == "final": + final = step + yield (_render_agent_trace(steps), *(gr.update(),) * 5, "🤖 working…", + gr.update(), gr.update(), gr.update(), + _render_pipeline_card(steps, _meta())) + if not final: + yield (_render_agent_trace(steps), *(gr.update(),) * 5, + "The agent didn't reach a final step.", + gr.update(), gr.update(), gr.update(), + _render_pipeline_card(steps, _meta())) + return + plan_dict = final.get("plan") or {} + rows = _agent_plan_rows(plan_dict) + try: + plan_md = _plan_markdown(ActionPlan(**plan_dict)) + except Exception: # noqa: BLE001 defensive: dict drift -> skip summary + plan_md = "" + ics_path = None + if final.get("ics_base64"): + fd, ics_path = tempfile.mkstemp(suffix=".ics", prefix="agent_events_") + os.close(fd) + Path(ics_path).write_bytes(_b64.b64decode(final["ics_base64"])) + summ = final.get("summary", {}) + # Same capture semantics as the homepage run (links can't be hooked). + if rows: + impact.record_capture(len(rows), + conflicts_caught=int(summ.get("conflicts", 0) or 0)) + yield ( + _render_agent_trace(steps), + rows, + plan_md, + _render_event_cards(rows, mode), + _render_event_carousel(rows), + plan_dict.get("reply_draft") or "", + f"Agent finished: {summ.get('events', 0)} event(s) " + f"({len(images)} image(s) read), {summ.get('conflicts', 0)} conflict(s).", + int(summ.get("conflicts", 0) or 0), + gr.update(visible=True), + (gr.update(value=ics_path) if ics_path else gr.update()), + _render_pipeline_card(steps, _meta()), + ) + + +def _on_make_ics(rows, captured_conflicts: int = 0): + events = _rows_to_events(rows) + if not events: + return None, "No events to export." + path = write_ics(events) + # Exporting is the user accepting these events — record it for "This week". + impact.record_capture(len(events), conflicts_caught=int(captured_conflicts or 0)) + return path, f"Wrote {len(events)} event(s) to .ics." + + +def _on_download_trace(redact: bool = True): + """Export the most recent agent run as a downloadable JSON trace (Sharing is + Caring). Stays local — no Hub token in the Space; publish with + training/share_trace.py.""" + from server.trace import export_run, write_trace # lazy, mirrors other handlers + + trace = export_run(redact=redact) + if not trace["steps"]: + return None, "No agent run to export yet — analyze a thread first." + path = write_trace(trace) + return path, f"Trace ready ({trace['summary']['steps']} step(s), redacted={redact})." + + +def _on_publish_trace() -> str: + """One-click 'Sharing is Caring': publish the latest run's REDACTED trace to a + PUBLIC Hugging Face dataset so anyone can learn from it. Requires a Space write + token (HF_WRITE_TOKEN); degrades gracefully with a clear message otherwise. + Redaction is forced — raw text / titles never reach the public repo.""" + token = (os.environ.get("HF_WRITE_TOKEN") or "").strip() + repo = os.environ.get("TRACE_DATASET", "ParetoOptimal/offgridschedula-traces") + if not token: + return ("⚠️ Publishing isn't configured on this Space. Set an **`HF_WRITE_TOKEN`** " + "secret (a dataset-scoped *write* token) to enable one-click publish — " + "or use **⬇ Download trace** + `python training/share_trace.py`.") + from server.trace import export_run # lazy + + trace = export_run(redact=True) # force redaction for a PUBLIC dataset + if not trace["steps"]: + return "Nothing to publish yet — run an analysis first, then publish its trace." + + import io + from datetime import datetime + + from huggingface_hub import HfApi + + url = f"https://huggingface.co/datasets/{repo}" + fname = f"traces/trace-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json" + try: + api = HfApi(token=token) + api.create_repo(repo_id=repo, repo_type="dataset", private=False, exist_ok=True) + api.upload_file( + path_or_fileobj=io.BytesIO(json.dumps(trace, indent=2).encode("utf-8")), + path_in_repo=fname, + repo_id=repo, + repo_type="dataset", + commit_message="Add agent trace (redacted) — Sharing is Caring", + ) + except Exception as e: # noqa: BLE001 surface a friendly message, never crash the UI + return f"Publish failed: {type(e).__name__}: {e}" + bus.emit("decision", f"published redacted trace ({trace['summary']['steps']} step(s)) to the Hub") + return f"✅ Shared your **redacted** trace publicly → [{repo}]({url}/blob/main/{fname})" + + +def _on_push_gcal(rows, token_json, captured_conflicts: int = 0): + """Push to the VISITOR'S own Google Calendar using their per-session OAuth + token (from the Connect flow). The token lives in the browser; it's passed + here only to perform the push and is never stored server-side.""" + if not (token_json or "").strip(): + return "Connect Google Calendar first (Step 2a → **Connect your calendar**)." + events = _rows_to_events(rows) + if not events: + return "No events to push." + try: + from calendar_out.gcal import push_events_with_token + + links = push_events_with_token(token_json, events) + # Only record once the push actually succeeded. + impact.record_capture(len(events), conflicts_caught=int(captured_conflicts or 0)) + return "Added to your Google Calendar:\n" + "\n".join(links) + except Exception as e: # noqa: BLE001 + return f"Google Calendar push failed: {e}" + + +# --------------------------------------------------------------------------- # +# Activity dashboard renderers +# --------------------------------------------------------------------------- # +def _stepper_html() -> str: + cur = bus.current_stage() + cells = [] + for s in bus.STAGES: + active = " active" if s == cur else "" + color = STAGE_COLORS.get(s, "#888") + cells.append(f'
{s}
') + return '
' + "".join(cells) + "
" + + +def _tiles_html() -> str: + m = bus.metrics() + defs = [ + ("Messages", m["messages"]), + ("Events", m["events_created"]), + ("Conflicts", m["conflicts"]), + ("Images", m["images_read"]), + ("Model calls", m["model_calls"]), + ("Avg latency", f'{m["avg_latency_ms"]} ms'), + ("Errors", m["errors"]), + ] + cells = "".join( + f'
{v}
{k}
' + for k, v in defs + ) + return f'
{cells}
' + + +def _timeline_html(n: int = 40) -> str: + rows = bus.recent(n) + if not rows: + return '
No activity yet — analyze a thread or POST to /ingest.
' + items = [] + for e in rows: + color = STAGE_COLORS.get(e["stage"], "#888") + meta_bits = [] + for k in ("latency_ms", "events", "conflicts", "images", "tokens"): + v = e.get(k) + if v: + meta_bits.append(f"{k}={v}") + meta = " · ".join(meta_bits) + err = " err" if e.get("level") == "error" else "" + items.append( + f'
' + f'{e["stage"]}' + f'{e["message"]}' + f'{meta}' + f'{e["ts"][11:]}
' + ) + return '
' + "".join(items) + "
" + + +def _impact_html() -> str: + """Durable weekly impact (events captured, conflicts caught, time saved). + Reuses the same .tiles/.tile markup as _tiles_html so the CSS styles it for + free — but reads from the persisted impact log, not the ephemeral bus.""" + w = impact.this_week() + mins = w["minutes_saved"] + saved = f"{mins // 60}h {mins % 60}m" if mins >= 60 else f"{mins}m" + defs = [ + ("Events captured", w["events_captured"]), + ("Conflicts caught", w["conflicts_caught"]), + ("Time saved", saved), + ] + cells = "".join( + f'
{v}
{k}
' + for k, v in defs + ) + return f'
{cells}
' + + +def _chart_df() -> pd.DataFrame: + return pd.DataFrame(bus.stage_counts()) + + +def _traces_html(n: int = 6) -> str: + runs = bus.recent_runs(n) + if not runs: + return '
No agent runs yet.
' + blocks = [] + for rid, evs in runs: + lines = [] + for e in evs: + color = STAGE_COLORS.get(e["stage"], "#888") + lat = f' ({e["latency_ms"]} ms)' if e.get("latency_ms") else "" + lines.append( + f'
{e["stage"]} {e["message"]}{lat}
' + ) + summary = f"run #{rid} — {len(evs)} step(s)" + blocks.append( + f'
{summary}{"".join(lines)}
' + ) + return "".join(blocks) + + +def _refresh(): + return ( + _stepper_html(), + _tiles_html(), + _timeline_html(), + _chart_df(), + _traces_html(), + _impact_html(), + ) + + +# --------------------------------------------------------------------------- # +# Landing-page sections (static marketing HTML; the tool itself stays Gradio) +# --------------------------------------------------------------------------- # +def _nav_html() -> str: + """Single grouped nav. Page links carry data-tab (the injected JS clicks the + matching Gradio tab); landing links also carry data-anchor to scroll within + the Schedule page. The default Gradio tab strip is hidden via CSS. + (The fine-tuned-model "Powered by" line lives under the hero badges, not here.)""" + return ( + '' + ) + + +def _hero_copy_html() -> str: + return ( + '
' + '
Your Calendar's Off-Grid Agent
' + '

Schedule your invite
' + 'before it slips away.

' + '

Meetings, events, activities — buried in a noisy group chat or a ' + 'screenshot. Paste it in and get calendar-ready events, conflicts caught against your ' + 'calendar, and a reply written for you.

' + '

No app · no account · runs locally — your messages never touch a cloud AI.

' + '
' + ) + + +def _hero_visual_html() -> str: + """A design play on the core function: a chat bubble morphing into a + calendar event chip. Pure HTML/CSS (no JS) so it renders on every path.""" + return ( + '
' + '
' + '
Class group chat
' + '
Picture day is Thursday at 9am — wear the green shirt! 📸
' + '
' + '
' + '
' + '
' + '
' + '
📸 Picture Day
' + '
Thu · 9:00 AM
' + '
Lincoln Elementary · wear green
' + '
' + '
' + ) + + +def _hero_examples_html() -> str: + """A 2×3 grid of compact 'chat → event' example cards for the hero. + + The event lines are COMPUTED against today's date, following the site's + date-resolution rules: weekdays resolve to the next occurrence on or after + today; AM/PM is preserved exactly and inferred meridiems are flagged + 'assumed'; stated times are never dropped; time-only messages inherit + today; deadlines are separate entries; month-day dates already past roll + to next year. (Rendered at app startup — refreshes on each restart.)""" + from datetime import datetime as _dt + + today = _dt.now() + + def next_weekday(dow: int) -> _dt: # Mon=0; next occurrence ON OR AFTER today + return today + timedelta(days=(dow - today.weekday()) % 7) + + def day_label(d: _dt) -> str: + return f"{d.strftime('%a')}, {d.strftime('%b')} {d.day}" + + def line(title: str, when: str, time_s: str = "", assumed: bool = False) -> str: + parts = [title, when] + ([time_s] if time_s else []) + flag = ' assumed' if assumed else "" + return html.escape(" · ".join(parts)) + flag + + def month_day(month: int, day: int) -> _dt: # rule 6: roll past dates to next year + d = _dt(today.year, month, day) + return d if d.date() >= today.date() else _dt(today.year + 1, month, day) + + bake = month_day(6, 12) + ex = [ + ("School chat", "Picture day Thursday 9am", + [line("📸 Picture Day", day_label(next_weekday(3)), "9:00 AM")]), + ("Coach", "Practice moved to Tue 5pm", + [line("⚽ Practice", day_label(next_weekday(1)), "5:00 PM")]), + ("Carpool", "I’ll grab them at 3:15", + [line("🚗 Pickup", "Today", "3:15 PM", assumed=True)]), + ("Doctor", "Appt confirmed Mon 10:30", + [line("🩺 Checkup", day_label(next_weekday(0)), "10:30 AM", assumed=True)]), + ("Party", "RSVP by Fri — party Sat 2pm", + [line("📝 RSVP due", day_label(next_weekday(4))), + line("🎉 Party", day_label(next_weekday(5)), "2:00 PM")]), + ("Flyer", "Bake sale — June 12, 8am · drop off cookies by Thu 6pm", + [line("🍪 Drop off cookies", day_label(next_weekday(3)), "6:00 PM"), + line("🧁 Bake Sale", day_label(bake), "8:00 AM")]), + ] + cards = "".join( + f'
{html.escape(frm)}
' + f'
{html.escape(chat)}
' + + "".join(f'
{ev}
' for ev in evs) + + "
" + for frm, chat, evs in ex + ) + return f'
{cards}
' + + +def _hero_badges_html() -> str: + """Live trust badges under the hero copy (read from env).""" + f = _compliance_facts() + local = "🔒 100% local" if f["local"] else "☁️ remote" + return ( + '
' + f'{local}' + '🦙 llama.cpp' + '🎯 Fine-tuned' + f'🪶 {html.escape(f["params"])} · ≤ 32B' + '🤖 Agentic' + "
" + ) + + +# --- hackathon compliance: live facts (read from env) + showcase HTML ---------- +# Model: gemma-cal E4B — our calendar-native fine-tune of google/gemma-4-E4B-it +# (~4B effective params, <= 32B), published as build-small-hackathon/gemma-4-cal-gguf. +_SPACE_URL = "https://huggingface.co/spaces/build-small-hackathon/OffGridSchedula" +_BLOB = _SPACE_URL + "/blob/main/" +_MODEL_URL = "https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf" + + +def _compliance_facts() -> dict: + """Read the live model/runtime config so the badges reflect reality.""" + repo = os.environ.get("MODEL_REPO", "build-small-hackathon/gemma-4-cal-gguf") + fname = os.environ.get("MODEL_FILE", "gemma-cal-e4b-Q4_K_M.gguf") + base = os.environ.get("INFERENCE_BASE_URL", "") + local = (not base) or ("127.0.0.1" in base) or ("localhost" in base) + rl = f"{repo}/{fname}".lower() + if "e4b" in rl: + label, params = "gemma-cal E4B (fine-tuned)", "~4B" + elif "e2b" in rl: + label, params = "Gemma 4 E2B", "~2B" + elif "31b" in rl or "gemma-cal-q4" in rl: + label, params = "gemma-cal 31B (fine-tuned)", "31B" + else: + label, params = repo.split("/")[-1], "≤32B" + return {"repo": repo, "local": local, "label": label, "params": params} + + +def _submission_html() -> str: + """A live compliance scorecard — maps every hackathon rule to evidence.""" + f = _compliance_facts() + loc = "100% local — no cloud AI APIs" if f["local"] else "remote inference seam" + + def row(ok: bool, title: str, ev: str) -> str: + cls, mark = ("sub-ok", "✓") if ok else ("sub-warn", "⚠") + return ( + f'
{mark}' + f'
{title}
' + f'
{ev}
' + ) + + hard = ( + row(True, "Small model ≤ 32B", + f'{html.escape(f["label"])} · {html.escape(f["params"])} params (≤ 32B) — ' + "our own fine-tune of Gemma 4 E4B, published on the Hub. Laptop-class by design: " + "~4B effective params, about 5 GB at 4-bit.") + + row(True, "Gradio app · Hugging Face Space", + "app.py is a Gradio Blocks app, shipped as a Hugging Face Space — a " + "Docker image that runs llama.cpp locally. You're reading this scorecard inside it.") + + row(True, "Show, don’t tell", + "So we didn't tell — we showed. A demo video of the real product running (no slides, " + "just the thing doing the thing) ships with the submission, alongside the social post.") + ) + track = ( + '

Track A — Backyard AI. A specific real person: a busy parent ' + "whose kid’s school & activity events are buried in a noisy class group chat. Honest fit — " + "short pasted chats and screenshots are exactly what a small, local model handles well, and the " + "output (a reviewable .ics) is genuinely usable from a phone browser.

" + ) + bonus = ( + row(True, "Off the Grid (local-first)", f"{loc} — inference runs via llama.cpp in the Space.") + + row(True, "Well-Tuned", + "A QLoRA fine-tune of Gemma 4 E4B, eval-gated: every retrain has to clear a " + "60-example scorecard before it can ship — eight of them didn't. The one that did is " + "published on the Hub.") + + row(True, "Off-Brand (custom UI)", + "This site — custom landing, grouped nav, dark/light hero, and bespoke CSS/JS, far past " + "the default Gradio look.") + + row(True, "Llama Champion", + "Runs through the llama.cpp runtime (official ggml-org/llama.cpp image).") + + row(True, "Sharing is Caring", + "One-click ☁ Publish my trace to the Hub (Activity tab) pushes a redacted " + "run to a public dataset — counts and stage names, never your chat text — so anyone can " + "learn from it. (Token-free route: ⬇ Download trace and publish it yourself.)") + + row(True, "Agentic (MCP tool server + agent)", + "Both sides of MCP: the Space exposes " + "extract_events / make_ics / check_conflicts " + "as Model Context Protocol tools (schemas auto-generated) — and the " + "Agent tab consumes them: an OpenBMB MiniCPM planner " + "(local llama.cpp) makes a single, visible extract_events call over MCP, " + "while conflict-checking and .ics rendering are finalized deterministically. " + "No cloud AI APIs anywhere in the loop.") + + row(True, "OpenBMB MiniCPM (sponsor)", + "The Agent tab's planner is MiniCPM " + "(openbmb/MiniCPM5-1B-GGUF, the ≤4B tiny variant; the 8B " + "MiniCPM4.1-8B is config-selectable) served by the same local " + "llama.cpp — core to the agent experience, under the 32B cap.") + + row(True, "Field Notes", + "By the numbers: 8 fine-tunes rejected at the eval gate, " + "7 bugs in agent code that had never run, a planner trimmed " + "207 s → 75 s — and still zero cloud calls.") + ) + return ( + '
' + '
Hackathon submission
' + '

Built-small, by the rules.

' + '

Hard constraints

' + hard + "
" + '

Track

' + track + "
" + '

Bonus quests

' + bonus + "
" + "
" + ) + + +def _how_html() -> str: + steps = [ + ("01", "Paste it in", "Drop in the group chat — or a screenshot of a flyer, invite, or text."), + ("02", "Review everything", "See the events, conflicts against your calendar, and a ready-to-send reply — before anything is saved."), + ("03", "Add to your calendar", "Download a local .ics for any calendar, or push straight to Google or Apple Calendar."), + ] + cards = "".join( + f'
{n}
' + f'

{t}

{d}

' + for n, t, d in steps + ) + return ( + '
' + '
How it works
' + '

From buried message to booked — in three taps.

' + f'
{cards}
' + '
' + ) + + +def _uses_html() -> str: + uses = [ + ("🗓", "Meetings", "“Sync moved to Thursday 2pm” → on your calendar, no retyping."), + ("🎒", "School & activities", "Picture day, the practice that moved, the field trip slip."), + ("🚗", "Carpool & pickups", "Pickup times and whose turn to drive — sorted."), + ("🩺", "Appointments", "Doctor, dentist & vet reminders, with a heads-up before."), + ("🎉", "Parties & RSVPs", "The party, the RSVP deadline, and a one-tap reply."), + ("📸", "Screenshots & flyers", "Snap an invite — it reads the date, time & place from the image."), + ] + cards = "".join( + f'
{i}
' + f'

{t}

{d}

' + for i, t, d in uses + ) + return ( + '
' + '
Use cases
' + '

Anywhere a date hides in a conversation.

' + f'
{cards}
' + '
' + ) + + +def _faq_html() -> str: + """FAQ with two tabs (General / For developers), search filter, and native +
accordion rows. Wired by the FAQ block in CAROUSEL_JS.""" + general: list[tuple[str, str]] = [ + ( + "What problem does this actually solve?", + "

School pickups, practice changes, RSVPs, doctor reminders — " + "the dates that hide in noisy group chats and flyers. You paste " + "the relevant chat (or a screenshot); the agent extracts the " + "events, flags conflicts against your calendar, and drafts a " + "reply. You go back to managing one calendar instead of " + "scanning four chats.

", + ), + ( + "Is anything sent to a cloud AI service?", + "

No. Inference runs locally inside the Space via " + "llama.cpp — no OpenAI / Anthropic / Google AI " + "calls. The only outbound network call you might make is the " + "optional push to Google Calendar, and only if you click " + "it and authorise it yourself.

", + ), + ( + "Do I need an app or an account?", + "

No. It works from any phone or laptop browser. There's " + "nothing to install and nothing to sign up for. The output is " + "a .ics file you own — import it into Apple " + "Calendar, Google Calendar, Outlook, or anything else.

", + ), + ( + "Does it read my messages automatically?", + "

No. It reads only the text you choose to paste in (and any " + "screenshots you attach). It has no access to your inbox, your " + "contacts, or your phone's Messages app. Nothing is read " + "without your action.

", + ), + ( + "What inputs work best?", + "
    " + "
  • Short pasted chats — the last few relevant messages, " + "not a six-month history.
  • " + "
  • Screenshots of flyers, invites, or texts where the " + "date lives in the image.
  • " + "
  • Your current calendar as .ics " + "(optional) — used only for deterministic conflict detection, " + "never sent anywhere.
  • " + "
", + ), + ( + "How accurate is it, really?", + "

Small fine-tuned models are good at narrow tasks and worse " + "at edge cases. Honestly:

" + "
    " + "
  • Reliable for explicit times — \"Tuesday at 3pm at " + "school\".
  • " + "
  • Decent for nearby relative times — \"tomorrow\", " + "\"next Monday\".
  • " + "
  • Worse for ambiguous wording — \"maybe later\". That's " + "why the agent always shows you the events for review before " + "anything is saved.
  • " + "
", + ), + ] + dev: list[tuple[str, str]] = [ + ( + "Can other AI agents call this Space as a tool?", + "

Yes. The Space exposes a Model Context Protocol (MCP) " + "server, so any MCP-aware agent (Claude Desktop, Cursor, " + "etc.) can call three tools against it:

" + "
    " + "
  • extract_events(thread, images) — the headline " + "tool: read a chat or screenshot, return events + conflicts + " + "reply.
  • " + "
  • make_ics(events) — render events as a " + "base64-encoded .ics.
  • " + "
  • check_conflicts(events, ics_base64) — " + "deterministic free/busy check against an uploaded calendar.
  • " + "
" + "

Schemas are auto-generated from the typed wrappers in " + f'server/mcp_tools.py.

', + ), + ( + "How do I connect an MCP-aware agent to this Space?", + "

Point the agent's MCP config at the Space's SSE endpoint:

" + "

build-small-hackathon-offgridschedula.hf.space" + "/gradio_api/mcp/sse

" + "

In Claude Desktop, add an entry under " + "mcpServers in " + "claude_desktop_config.json with that URL. In " + "Cursor, add it under Settings → MCP. The three tools " + "appear automatically; the agent picks them up by docstring.

", + ), + ( + "Where do I find the fine-tuned model?", + "

Published openly on the Hugging Face Hub as " + f'build-small-hackathon/gemma-4-cal-gguf' + " — a QLoRA fine-tune of Gemma 4 converted to GGUF so " + "llama.cpp can run it on CPU or GPU. The Space pulls the model " + "weights at startup; nothing custom is bundled into this repo.

", + ), + ( + "Can I run this off the Space, locally?", + "

Yes — this Space is the repo. Its " + f'Files tab is a public git ' + "repository: git clone " + f"{_SPACE_URL}, install requirements.txt (or " + "build the Dockerfile), set MODEL_REPO + " + "MODEL_FILE to point at any GGUF on the Hub, and " + "run python app.py. The same Gradio UI + FastAPI " + "/agent endpoint + MCP server come up on " + "localhost:7860.

", + ), + ] + + def _rows(items: list[tuple[str, str]]) -> str: + # Inline SVG: outer circle + horizontal line (always shown) + vertical + # line (hidden when item is open) -> visual "+" toggles to "-". + ico = ( + '" + ) + return "".join( + f'
' + f'{q}{ico}' + f'
{a}
' + for q, a in items + ) + + search_svg = ( + '' + '' + ) + + return ( + '
' + '
' + '

Frequently asked questions

' + '" + "
" + '
' + '' + '' + "
" + f'
{_rows(general)}
' + f'' + '" + "
" + ) + + +def _privacy_html() -> str: + points = [ + ("Runs locally", "Inference happens in the Space via llama.cpp — no cloud AI APIs."), + ("Nothing auto-read", "It reads only what you choose to paste. No inbox, no message access."), + ("No app, no account", "Works from your phone's browser. Output is a local .ics you own."), + ] + items = "".join( + f'

✓ {t}

{d}

' + for t, d in points + ) + return ( + '
' + '
Private by design
' + '

Off the grid, on purpose.

' + f'
{items}
' + '
' + ) + + +def _tool_intro_html() -> str: + return ( + '
' + '
' + '
Try it now
' + '

Paste a chat — get your events.

' + ) + + +def _tool_header_html() -> str: + """Header for the elevated tool card: eyebrow + title + the "Powered by" pill + (moved here from the hero — it identifies the model where you actually use it).""" + return ( + '
' + '
' + '
Try it for free
' + '

Paste a chat, manage your events

' + f'' + '🟢 Powered by fine-tuned Gemma 4 · gemma-4-cal · llama.cpp' + "
" + ) + + +def _flow_step(n: "int | str", title: str, sub: str = "") -> str: + """Numbered workflow step header: round chip + title (+ muted sub note), + with a dashed tail (CSS) tying the steps together down the card.""" + sub_html = f'{sub}' if sub else "" + return ( + f'
{n}' + f'{title}{sub_html}
' + ) + + +def _mode_note_html(online: bool) -> str: + """Guidance line + named workflow chip inside the mode band.""" + if online: + chip = "ONLINE WORKFLOW" + txt = ("☁️ Online — Google Calendar enabled: connect it in step 2a, " + "or just continue. Everything else still runs locally.") + else: + chip = "OFF-GRID WORKFLOW" + txt = ("🔌 Offline — everything stays on this device: upload your " + "calendar in step 2a, get an .ics back.") + return (f'
{chip}' + f"{txt}
") + + +def _footer_html() -> str: + return ( + '' + ) + + +# --------------------------------------------------------------------------- # +# App +# --------------------------------------------------------------------------- # +def build_demo() -> gr.Blocks: + # Theme/css set on the Blocks so the Off-Brand look applies no matter who + # launches it (our uvicorn locally, or HF's gradio runtime on a Spaces deploy). + with gr.Blocks(theme=THEME, css=CSS, title="OffGridSchedula") as demo: + gr.HTML('', elem_id="status-banner-host") + gr.HTML(_nav_html()) + + # Per-user memory lives in the visitor's browser (localStorage); this hidden + # box mirrors it so handlers can read/edit it and the agent can use it. + # Loaded from localStorage on page load; persisted back on every change. + mem_box = gr.Textbox(visible=False, elem_id="mem-box") + + with gr.Tab("Schedule"): + captured_conflicts = gr.State(0) + + # --- landing hero: dark band — copy + example-card grid --- + with gr.Row(elem_id="hero"): + with gr.Column(elem_id="hero-left"): + gr.HTML(_hero_copy_html()) + with gr.Row(elem_id="hero-cta"): + hero_try_btn = gr.Button( + "Try a sample", variant="primary", elem_id="hero-try", scale=0 + ) + gr.HTML('See it in action ↓') + gr.HTML(_hero_badges_html()) + with gr.Column(elem_id="hero-right"): + gr.HTML(_hero_examples_html()) + + # --- elevated tool card (overlaps the hero); the agent up top --- + with gr.Column(elem_id="tool-card"): + gr.HTML(_tool_header_html()) + + # ── Step 1: Choose your workflow — the mode decision point ──── # + gr.HTML(_flow_step(1, "Choose your workflow")) + # Offline / Online mode toggle: ONE decision point that re-themes + # and reconfigures the whole workflow card. Lives in a full-width + # tinted "mode band"; the card carries data-mode for the CSS theme. + with gr.Group(elem_id="mode-band"): + mode = gr.Radio( + ["🔌 Offline", "☁️ Online"], value="🔌 Offline", + show_label=False, elem_id="mode-toggle", container=False, + ) + mode_note = gr.HTML(_mode_note_html(online=False)) + + # ── Step 2: Upload context — screenshot + paste, one group ──── # + gr.HTML(_flow_step(2, "Upload context")) + with gr.Row(elem_id="io-cols"): + with gr.Column(elem_classes="io-col"): + gr.HTML('
Upload a screenshot
') + images_in = gr.Files( + label="", file_types=["image"], type="filepath", elem_id="io-drop", + ) + screenshot_status = gr.HTML() + with gr.Column(elem_classes="io-col"): + gr.HTML('
Or paste the chat
') + conversation = gr.Textbox( + label="", + placeholder=( + "Paste the school / activity chat — e.g. “Picture day is " + "Thursday at 9am, wear green”." + ), + lines=6, + elem_id="rv-textbox", + ) + gr.HTML( + '
Chat, SMS, or invite — English & ' + 'screenshots supported' + '0 / 12000
' + ) + + # ── Step 2a: Optional — calendar (first option) · trace · personalize ── # + gr.HTML(_flow_step("2a", "Optional", "(calendar · privacy trace · personalize)")) + # First option: the calendar, as a dropdown (caret) holding BOTH + # paths — connect a cloud provider (one unified block, all three + # providers) or upload an .ics. Always rendered; offline mode + # hides only the Google connect CTA via CSS (data-mode). + with gr.Accordion( + "🔗 Connect your calendar (+ optional .ics for conflict checks)", + open=False, + ) as cal_acc: + gr.HTML(_connect_block_html()) + existing_cal = gr.File( + label="Your calendar (.ics)", file_types=[".ics"], type="filepath", + ) + with gr.Group(elem_classes="trace-card"): + trace_checkbox = gr.Checkbox( + label=("Publish privacy-safe trace — stores a redacted event summary " + "for the agent-trace dataset; raw text, screenshots, and links " + "are not stored."), + value=False, elem_id="trace-cb", + ) + trace_status = gr.HTML() + # Onboarding — ALWAYS on the page so the agent can be personalized + # at any time (people the chat mentions, preferences, etc.). Open + # for first-time visitors; collapses (never disappears) once the + # device has memory or the user hits Skip. + with gr.Accordion( + "👋 Personalize in 10 seconds", open=True, elem_id="onboard" + ) as onboard: + gr.HTML( + '
Tell the agent a few things — saved on your device ' + "and used to read your chats better. You can skip this.
" + ) + ob_people = gr.Textbox( + label="People (one per line — “Name = role”)", lines=2, + placeholder="Dana = soccer coach\nMr. Lee = teacher", + ) + with gr.Row(): + ob_reminder = gr.Number(label="Default reminder (min before)", value=30, precision=0) + ob_decline = gr.Textbox(label="Days you usually decline", placeholder="Mondays") + with gr.Row(): + ob_save = gr.Button("Save to my device", variant="primary", scale=0) + ob_skip = gr.Button("Skip", elem_classes="rv-secondary", scale=0) + ob_status = gr.Markdown(elem_classes="rv-status") + + # ── Step 3: Run — the three actions nested under one step, + # uniformly formatted, with "Run the agents" the highlighted + # (primary) choice. ───────────────────────────────────────── # + gr.HTML(_flow_step(3, "Run the agents")) + with gr.Row(elem_id="rv-actions"): + analyze_btn = gr.Button( + "Run the agents", variant="primary", elem_id="rv-analyze", scale=0 + ) + start_over_btn = gr.Button( + "Start over", elem_id="rv-startover", elem_classes="rv-secondary", scale=0 + ) + sample_btn = gr.Button( + "Use the sample", elem_id="rv-sample", elem_classes="rv-secondary", scale=0 + ) + + # The pipeline tracker sits ABOVE the status line, always + # visible (idle card on landing) — the step-by-step trace + # stays behind the accordion below. + pipeline = gr.HTML(_render_pipeline_card([]), elem_id="rv-pipeline") + status = gr.Markdown(elem_classes="rv-status") + + with gr.Accordion("🤖 Watch the agent work (live steps)", open=False): + thinking = gr.HTML(_render_agent_trace([])) + + # --- results: hidden until the first analysis (progressive disclosure) + with gr.Column(elem_id="rv-results", visible=False) as results: + # The rotating carousel is superseded by the condensed + # results card below; the component stays (hidden, still + # rendered into) so every chain that targets it keeps + # working unchanged. + hero_carousel = gr.HTML("", elem_id="rv-hero", visible=False) + # Per-event notes from the last analysis. Notes aren't a + # Dataframe column, so without this State the arrival + # context would be lost when events round-trip through + # the editable table. + found_notes = gr.State([]) + # ONE condensed results card: the found events and the + # export actions together, instead of separate stacked + # sections. + with gr.Group(elem_id="rv-resultcard"): + plan_md = gr.HTML() + cards = gr.HTML() + with gr.Group(elem_id="rv-export"): + # The per-event "Add to: Google · Outlook · iCal" + # links on each card are now the ONE export + # surface. This button bar duplicated it (and the + # OAuth push 404s while the Space is private), so + # everything here is HIDDEN — not deleted: the + # mode wiring, wireGcal, and tests reference these + # components, and the OAuth push returns once the + # Space is public. + with gr.Row(visible=False): + ics_btn = gr.Button("⬇ Download .ics", variant="primary", scale=1) + gcal_btn = gr.Button("Add to Google Calendar", scale=1) + apple_btn = gr.Button(" Add to Apple Calendar", scale=1) + gcal_link_html = gr.HTML("", visible=False) + # hidden per-session token, hydrated from localStorage / the OAuth popup + gcal_token = gr.Textbox(visible=False, elem_id="gcal-token") + ics_file = gr.File(label="events.ics", elem_id="ics-file", + visible=False) + gcal_status = gr.Markdown(visible=False) + gr.HTML( + '
📱 On iPhone? The share-sheet ' + "Shortcut adds events in two taps — " + f'set it up.
' + ) + with gr.Accordion("✏️ Edit events", open=False): + table = gr.Dataframe( + headers=["title", "start", "end", "location", "reminder_min"], + datatype=["str", "str", "str", "str", "number"], + type="array", + interactive=True, + label="", + ) + with gr.Group(elem_classes="rv-reply"): + reply = gr.Textbox(label="Suggested reply", lines=3, elem_id="rv-reply-box") + copy_btn = gr.Button("Copy reply", size="sm", elem_classes="rv-copy") + + # --- marketing sections (now below the working tool) --- + gr.HTML(_how_html()) + gr.HTML(_uses_html()) + gr.HTML(_privacy_html()) + + # --- wiring (existing handler signatures unchanged) --- + analyze_inputs = [conversation, existing_cal, images_in, mem_box] + analyze_outputs = [thinking, table, plan_md, reply, status, found_notes, + pipeline] + _reveal = lambda: gr.update(visible=True) # noqa: E731 + # Client-side helpers: scroll the page (sample CTA lives way up in the + # hero) and turn the generated .ics into a real browser download. + _scroll_tool_js = ( + "() => { var el = document.getElementById('tool-card') || " + "document.getElementById('tool'); if (el) el.scrollIntoView(" + "{behavior: 'smooth', block: 'start'}); }" + ) + _scroll_results_js = ( + "() => { var el = document.getElementById('rv-results'); " + "if (el) el.scrollIntoView({behavior: 'smooth', block: 'start'}); }" + ) + _auto_download_js = ( + "(f) => { try { if (f && f.url) { var a = document.createElement('a'); " + "a.href = f.url; a.download = 'events.ics'; document.body.appendChild(a); " + "a.click(); a.remove(); } } catch (e) {} }" + ) + # Opening (not downloading) the .ics lets iOS/macOS hand it straight + # to Apple Calendar ("Add All"); other platforms fall back to a download. + _open_ics_js = ( + "(f) => { try { if (f && f.url) window.open(f.url, '_blank'); } catch (e) {} }" + ) + + analyze_btn.click(_on_analyze, analyze_inputs, analyze_outputs).then( + _count_conflicts, plan_md, captured_conflicts + ).then(_render_event_cards, [table, mode, found_notes], cards).then( + _render_event_carousel, table, hero_carousel + ).then(_reveal, None, results).then( + _on_share_trace, [trace_checkbox, table], trace_status + ).then(_prep_ics, table, ics_file) + # Uploading a screenshot IS the intent too — analyze as soon as the + # upload lands (the .upload event only fires with files, never on clear). + images_in.upload(_on_analyze, analyze_inputs, analyze_outputs).then( + _count_conflicts, plan_md, captured_conflicts + ).then(_render_event_cards, [table, mode, found_notes], cards).then( + _render_event_carousel, table, hero_carousel + ).then(_reveal, None, results).then( + None, None, None, js=_scroll_results_js + ).then( + _on_share_trace, [trace_checkbox, table], trace_status + ).then(_prep_ics, table, ics_file) + # One tap: fill the box with the sample, then run the same analysis. + # The hero CTA and the in-tool "try a sample" share this flow. + for _btn in (sample_btn, hero_try_btn): + _btn.click(_load_sample, None, conversation).then( + None, None, None, js=_scroll_tool_js + ).then( + _on_analyze, analyze_inputs, analyze_outputs + ).then(_count_conflicts, plan_md, captured_conflicts).then( + _render_event_cards, [table, mode, found_notes], cards + ).then(_render_event_carousel, table, hero_carousel).then( + _reveal, None, results + ).then( + None, None, None, js=_scroll_results_js + ).then( + _on_share_trace, [trace_checkbox, table], trace_status + ).then(_prep_ics, table, ics_file) + # Start over: clear inputs + hide results. + start_over_btn.click( + _on_start_over, None, + [conversation, images_in, existing_cal, results, status, + screenshot_status, trace_status, pipeline], + ) + # (Offline/Online mode wiring lives at the end of build_demo — it also + # drives components on other tabs, which don't exist yet at this point.) + # Contextual hint when a screenshot is attached. + images_in.change(_screenshot_status, images_in, screenshot_status) + # Keep cards + carousel + the pre-generated .ics in sync when the + # user edits the table. + table.change(_render_event_cards, [table, mode, found_notes], cards).then( + _render_event_carousel, table, hero_carousel + ).then(_prep_ics, table, ics_file) + # Copy the reply to the clipboard (client-side, no round-trip). + copy_btn.click(None, [reply], None, js="(t) => { navigator.clipboard.writeText(t || ''); }") + # Download .ics: generate, then immediately save it as events.ics — + # no second click on the File component needed. + ics_btn.click( + _on_make_ics, [table, captured_conflicts], [ics_file, status] + ).then(None, ics_file, None, js=_auto_download_js) + # Apple Calendar: same .ics, opened so the OS offers "Add to Calendar". + apple_btn.click( + _on_make_ics, [table, captured_conflicts], [ics_file, status] + ).then(None, ics_file, None, js=_open_ics_js) + # Pull the visitor's per-session token straight from localStorage at + # click time (set by the OAuth popup), so the push uses THEIR account. + gcal_btn.click( + _on_push_gcal, [table, gcal_token, captured_conflicts], gcal_status, + js="(rows, tok, cc) => [rows, " + "(function(){try{return localStorage.getItem('gcal_token')||'';}catch(e){return '';}})(), " + "cc]", + ) + + # --- frequently-asked questions (native
accordion) --- + gr.HTML(_faq_html()) + + # --- landing footer --- + gr.HTML(_footer_html()) + + with gr.Tab("Agent", elem_classes="page-wrap"): + ag_conflicts = gr.State(0) + gr.Markdown( + "### 🤖 Agent mode — the whole homepage workflow, run by an agent\n" + "A small **OpenBMB MiniCPM** planner (served by the same local " + "llama.cpp — no cloud AI) drives this Space's own **MCP tools** " + "(`extract_events` → `check_conflicts` → `make_ics`) as a " + "multi-step agent, with your device memory as context. Every " + "step it takes is shown below; the results carry the full " + "review-and-export surface from the Schedule page. On the free " + "preview the same playbook runs scripted, so the demo always works." + ) + with gr.Row(): + ag_images = gr.Files(label="Screenshots (optional)", + file_types=["image"], type="filepath") + ag_thread = gr.Textbox( + label="Thread", lines=5, + placeholder="Paste a chat for the agent to work end-to-end…", + ) + ag_cal = gr.File(label="Calendar (.ics, optional — for conflict checks)", + file_types=[".ics"], type="filepath") + with gr.Row(): + ag_run = gr.Button("Run the agent", variant="primary", scale=0) + ag_sample = gr.Button("use the sample", elem_classes="rv-linkbtn", + size="sm", scale=0) + # Same placement as the homepage: tracker above the status line, + # step cards below. + ag_pipeline = gr.HTML(_render_pipeline_card([])) + ag_status = gr.Markdown(elem_classes="rv-status") + gr.Markdown("#### The agent's steps") + ag_trace = gr.HTML(_render_agent_trace([])) + + # --- results: the homepage's full surface, agent edition --- + with gr.Column(visible=False) as ag_results: + ag_carousel = gr.HTML("") + ag_plan_md = gr.HTML() + ag_cards = gr.HTML() + with gr.Accordion("✏️ Edit events", open=False): + ag_table = gr.Dataframe( + headers=["title", "start", "end", "location", "reminder_min"], + datatype=["str", "str", "str", "str", "number"], + type="array", interactive=True, label="", + ) + with gr.Group(elem_classes="rv-reply"): + ag_reply = gr.Textbox(label="Suggested reply", lines=2) + ag_copy = gr.Button("Copy reply", size="sm", elem_classes="rv-copy") + with gr.Group(elem_classes="ag-export"): + # Hidden like the homepage bar: the per-event card links + # are the one export surface (components kept for the + # mode wiring + the OAuth push's return). + with gr.Row(visible=False): + ag_ics_btn = gr.Button("⬇ Download .ics", variant="primary", scale=1) + ag_apple_btn = gr.Button(" Add to Apple Calendar", scale=1) + ag_gcal_btn = gr.Button("Add to Google Calendar", scale=1, + visible=False) + ag_gcal_link = gr.HTML(_provider_row("google"), visible=False) + ag_ics = gr.File(label="events.ics (made by the agent)", + visible=False) + ag_gcal_status = gr.Markdown(visible=False) + + ag_sample.click(_load_sample, None, ag_thread) + ag_run.click( + _on_run_agent, [ag_thread, ag_cal, ag_images, mem_box, mode], + [ag_trace, ag_table, ag_plan_md, ag_cards, ag_carousel, + ag_reply, ag_status, ag_conflicts, ag_results, ag_ics, + ag_pipeline], + ) + # Same export semantics as the homepage (impact recorded on export). + ag_copy.click(None, [ag_reply], None, + js="(t) => { navigator.clipboard.writeText(t || ''); }") + ag_ics_btn.click( + _on_make_ics, [ag_table, ag_conflicts], [ag_ics, ag_status] + ).then(None, ag_ics, None, js=_auto_download_js) + ag_apple_btn.click( + _on_make_ics, [ag_table, ag_conflicts], [ag_ics, ag_status] + ).then(None, ag_ics, None, js=_open_ics_js) + ag_gcal_btn.click( + _on_push_gcal, [ag_table, gcal_token, ag_conflicts], ag_gcal_status, + js="(rows, tok, cc) => [rows, " + "(function(){try{return localStorage.getItem('gcal_token')||'';}catch(e){return '';}})(), " + "cc]", + ) + # Edits keep cards + carousel + the pre-made .ics in sync (parity). + ag_table.change(_render_event_cards, [ag_table, mode], ag_cards).then( + _render_event_carousel, ag_table, ag_carousel + ).then(_prep_ics, ag_table, ag_ics) + + with gr.Tab("Activity", elem_classes="page-wrap"): + gr.Markdown("### This week") + gr.Markdown( + "What this saved you — events captured, conflicts caught, and " + "estimated time saved. Persists across restarts." + ) + impact_panel = gr.HTML() + gr.Markdown("---\nLive view of what the agent and model are doing (refreshes every 2s).") + stepper = gr.HTML() + tiles = gr.HTML() + with gr.Row(elem_classes="act-chart-row"): + timeline = gr.HTML() + chart = gr.BarPlot( + x="stage", y="count", title="Activity by stage", height=280, + # rotate x labels so the stage names (ingest/vision/model/…) + # don't crowd into garbled overlap on a narrow phone chart + x_label_angle=-45, + ) + gr.Markdown("### Run traces") + traces = gr.HTML() + gr.Markdown( + "**Sharing is Caring.** Publish a redacted run to a public Hugging Face " + "dataset so others can learn from it — one click (needs an `HF_WRITE_TOKEN` " + "secret on the Space). Or **⬇ Download** it (stays on your device) and publish " + "with `python training/share_trace.py`." + ) + with gr.Row(): + redact_chk = gr.Checkbox(label="Redact personal data", value=True) + trace_dl_btn = gr.Button("⬇ Download trace (JSON)") + trace_pub_btn = gr.Button("☁ Publish my trace to the Hub", variant="primary") + trace_file = gr.File(label="trace.json") + trace_status = gr.Markdown() + trace_dl_btn.click( + _on_download_trace, redact_chk, [trace_file, trace_status] + ) + trace_pub_btn.click(_on_publish_trace, None, trace_status) + + outputs = [stepper, tiles, timeline, chart, traces, impact_panel] + timer = gr.Timer(2.0) + timer.tick(_refresh, None, outputs) + demo.load(_refresh, None, outputs) + + with gr.Tab("Memory", elem_classes="page-wrap"): + gr.Markdown( + "**What the agent knows about you.** Stored on *your device* " + "(your browser's localStorage) — never on our servers — and injected " + "into every extraction to personalize it. Active from your first " + "message; grows as you add facts, import contacts, or use the app." + ) + mem_table = gr.Dataframe( + headers=["id", "kind", "text", "weight"], + datatype=["number", "str", "str", "number"], + interactive=False, + label="Memory (on this device)", + ) + with gr.Row(): + mem_text = gr.Textbox(label="Add a fact", scale=3, + placeholder="e.g. Dana is the soccer coach") + mem_kind = gr.Dropdown( + ["note", "contact", "preference", "location"], value="note", label="Kind" + ) + mem_add = gr.Button("Remember", variant="primary") + with gr.Row(): + mem_fid = gr.Number(label="Forget id", precision=0) + mem_forget = gr.Button("Forget") + # Demo seed: a curated set of people/preferences/locations that + # map to the sample conversations, so visitors can see memory + # personalizing a run without typing facts first. + mem_samples = gr.Button("✨ Load sample memories", + elem_classes="rv-secondary", scale=0) + + with gr.Accordion("📇 Import contacts / calendar (.vcf · CSV · .ics)", open=False): + mem_file = gr.File(label="Contacts (.vcf/CSV) or calendar (.ics)", + file_types=[".vcf", ".csv", ".ics"], type="filepath") + mem_import = gr.Button("Import to my memory") + gr.Markdown("Parsed **on your device's behalf, locally** — no cloud, no account.") + + # Online workflow only — hidden while the site is in Offline mode. + with gr.Accordion("🗓 Import from Google Calendar (optional · cloud)", + open=False, visible=False) as gcal_import_acc: + gcal_ok = gr.Checkbox(label="Allow reading my Google Calendar to seed memory", value=False) + gcal_import = gr.Button("Import from Google Calendar") + gr.Markdown("_Opt-in cloud step. Everything else stays off-grid._") + + mem_status = gr.Markdown() + + # Every action renders the table DIRECTLY (third output) — the + # mem_box.change chain below still persists to localStorage, but + # the visible refresh no longer depends on that browser-side hop. + _mem_out = [mem_box, mem_status, mem_table] + mem_add.click(_with_rows(_mem_remember), [mem_text, mem_kind, mem_box], _mem_out) + mem_forget.click(_with_rows(_mem_forget), [mem_fid, mem_box], _mem_out) + mem_samples.click(_with_rows(_mem_load_samples), [mem_box], _mem_out) + mem_import.click(_with_rows(_import_file), [mem_file, mem_box], _mem_out) + gcal_import.click(_with_rows(_import_gcal), [gcal_ok, mem_box], _mem_out) + # Single source of truth: whenever the device memory changes, re-render + # the table AND persist back to the browser's localStorage. + mem_box.change( + lambda m: _facts_rows(_facts_load(m)), mem_box, mem_table + ).then( + None, mem_box, None, + js="(m) => { try { localStorage.setItem('offgrid_memory', m || ''); } catch (e) {} }", + ) + + with gr.Tab("Feed", elem_classes="page-wrap"): + gr.Markdown( + "Messages pushed by the Mac collector arrive at `/ingest`. " + "**Privacy:** message contents only render when the deployment " + "sets `EXPOSE_FEED=1` — public builds keep them sealed." + ) + feed = gr.JSON(label="Recent ingested messages", value=_read_feed()) + gr.Button("Refresh").click(lambda: _read_feed(), None, feed) + + with gr.Tab("About"): + gr.Markdown( + "### Who it's for\n" + "A busy parent whose kid's school and activity events are buried in a " + "noisy class group chat — picture day Thursday, the practice that moved " + "to Tuesday, the birthday-party RSVP. They read it once, mean to add it " + "later, and miss it. With this, they paste the chat (or a screenshot of " + "a flyer or invite) from their phone's browser and get back: the events, " + "a conflict check against their calendar, and a ready-to-send reply — all " + "surfaced for review before anything is saved. Output is a local .ics they " + "can add to any calendar, with optional Google Calendar push.\n\n" + "### Private by design\n" + "No app to install and no account. It reads nothing automatically — the " + "parent pastes only what they choose. Inference runs in the Space via " + "llama.cpp (no cloud AI APIs), and works out of the box with no GPU " + "(see Accuracy upgrade below).\n\n" + "### Accuracy upgrade\n" + f"**Model:** `{os.environ.get('MODEL_REPO', 'unset')}` / " + f"`{os.environ.get('MODEL_FILE', 'unset')}` · " + f"**mmproj:** `{os.environ.get('MMPROJ_FILE', 'unset')}`\n\n" + "The free **cpu-basic** preview runs a small local model so the app works " + "with no GPU. With a **GPU** enabled, the Space serves **gemma-cal E4B** — " + "our calendar-native fine-tune (~5 GB, runs on a 16 GB T4) with the " + "`mmproj` vision projector for screenshots and flyers. Every published " + "version is eval-gated (see docs/eval-roadmap.md). All inference stays in " + "the Space — no cloud AI APIs." + ) + + with gr.Tab("Submission"): + gr.HTML(_submission_html()) + + # --- MCP tool surface (hidden) ----------------------------------- # + # Each .click() with api_name=... registers a Gradio API endpoint, and + # with mcp_server=True on the launch/mount that endpoint is auto-exposed + # as a Model Context Protocol tool. Type hints + docstrings on the + # functions in server.mcp_tools become the MCP schema, so any MCP-aware + # agent (Claude Desktop, Cursor, etc.) can call this Space as a tool. + from server.mcp_tools import check_conflicts as _mcp_check_conflicts + from server.mcp_tools import extract_events as _mcp_extract_events + from server.mcp_tools import make_ics as _mcp_make_ics + + # extract_events stays text-only via a hidden component: a gr.JSON + # `images` input renders an MCP schema of {"type": {}} (gradio can't type + # it), which small planners (MiniCPM) fill with `{}` -> rejected. The UI's + # vision path is separate (run_agent); the scripted path calls + # mcp_tools.extract_events(thread, images, memory) directly. + with gr.Row(visible=False): + _mcp_thread = gr.Textbox(visible=False) + _mcp_out_plan = gr.JSON(visible=False) + _mcp_btn_extract = gr.Button(visible=False) + _mcp_btn_extract.click( + fn=_mcp_extract_events, + inputs=[_mcp_thread], + outputs=_mcp_out_plan, + api_name="extract_events", + ) + # make_ics / check_conflicts take list[dict] `events`. As a gr.JSON + # component that also renders {"type": {}}; registered via gr.api the MCP + # schema is typed from the function hints ("type": "array"), so the + # planner passes the real events list instead of a bare dict. + gr.api(_mcp_make_ics, api_name="make_ics") + gr.api(_mcp_check_conflicts, api_name="check_conflicts") + + # --- Offline/Online: ONE decision point that reconfigures the site ---- + # Each mode is a separate guided workflow: Offline = .ics in/out, no + # Google anywhere (the Step 2a connect block hides its Google CTA via + # CSS on #tool-card[data-mode]); Online = one-click Google connect in + # Step 2a, Google push in the export bar, Google import in Memory. + # The choice persists on the device and is restored on page load. + def _on_mode(m: str, rows, ag_rows, notes): + """ONE atomic update per flip (visibility + both card re-renders) — + chained .then steps caused visible intermediate states.""" + online = "Online" in (m or "") + return ( + gr.update(), # cal_acc — always available (holds both paths) + gr.update(), # gcal_btn — always shown (export trio is fixed) + gr.update(), # gcal_link_html — hidden placeholder + gr.update(visible=online), # gcal_import_acc (Memory tab) + # ag_gcal_btn / ag_gcal_link stay hidden in BOTH modes while the + # per-event card links are the single export surface (the OAuth + # push returns when the Space is public). + gr.update(), # ag_gcal_btn (Agent tab) + gr.update(), # ag_gcal_link (Agent tab) + _mode_note_html(online), # mode_note + # cards (quick-add follows mode); pass notes through so the + # arrival-context callout survives a mode flip + _render_event_cards(rows, m, notes), + _render_event_cards(ag_rows, m), # ag_cards (no notes State on that tab) + ) + + # show_progress="hidden": no loading overlays flashing across the + # outputs (that flash was the "glitchy" feel); the theme itself is + # already recolored client-side at click time by wireModeTheme. + mode.change( + _on_mode, [mode, table, ag_table, found_notes], + [cal_acc, gcal_btn, gcal_link_html, + gcal_import_acc, ag_gcal_btn, ag_gcal_link, mode_note, cards, ag_cards], + show_progress="hidden", + ).then( + None, mode, None, + js="(m) => { try { localStorage.setItem('offgrid_mode', m || ''); } catch (e) {} " + "var tc = document.getElementById('tool-card'); if (tc) tc.setAttribute(" + "'data-mode', (m || '').indexOf('Online') >= 0 ? 'online' : 'offline'); }", + ) + # Restore the device's last choice on load. Setting the radio fires + # .change (only when the stored value differs from the default), which + # re-applies every visibility update above. + demo.load(None, None, mode, + js="() => { try { return localStorage.getItem('offgrid_mode') || '🔌 Offline'; } " + "catch (e) { return '🔌 Offline'; } }") + + # --- onboarding actions --- + # Save keeps the panel open so the confirmation is visible; Skip just + # collapses it — the panel stays on the page either way, so the agent + # can be personalized again at any time. + ob_save.click(_on_onboard, [ob_people, ob_reminder, ob_decline, mem_box], + [mem_box, onboard, ob_status]) + ob_skip.click(lambda: gr.update(open=False), None, onboard) + + # --- load per-user memory from the browser on page load --- + # Reading localStorage into mem_box triggers mem_box.change → the Memory + # table renders + memory re-persists. Returning visitors (device already + # has facts) get the onboarding collapsed — still there, one tap to open. + demo.load(None, None, mem_box, + js="() => { try { return localStorage.getItem('offgrid_memory') || ''; } " + "catch (e) { return ''; } }").then( + lambda m: gr.update(open=not _facts_load(m)), mem_box, onboard + ) + return demo + + +# --------------------------------------------------------------------------- # +# Memory tab handlers +# --------------------------------------------------------------------------- # +def _memory_rows() -> list[list]: + return [[f["id"], f["kind"], f["text"], f.get("weight", 1)] for f in memory.list_facts()] + + +def _on_remember(text: str, kind: str): + if not (text or "").strip(): + return _memory_rows(), "Type a fact first." + fact = memory.remember(text, kind or "note") + return _memory_rows(), f"Remembered: {fact['text']}" + + +def _on_forget(fact_id): + if fact_id is None or str(fact_id).strip() == "": + return _memory_rows(), "Enter an id to forget." + ok = memory.forget(int(fact_id)) + return _memory_rows(), ("Forgotten." if ok else "No fact with that id.") + + +# --- client-owned (per-user, browser localStorage) memory -------------------- +def _facts_load(s) -> list[dict]: + try: + data = json.loads(s) if s else [] + except Exception: # noqa: BLE001 + return [] + # Tolerate the server memory-file shape ({"facts": [...]}) — silently + # treating it as empty would wipe those facts on the next save. + if isinstance(data, dict): + data = data.get("facts") or [] + return [f for f in data if isinstance(f, dict) and f.get("text")] if isinstance(data, list) else [] + + +def _facts_dump(facts) -> str: + return json.dumps(facts or []) + + +def _facts_rows(facts) -> list[list]: + return [[f.get("id"), f.get("kind", "note"), f.get("text", ""), f.get("weight", 1)] + for f in (facts or [])] + + +def _with_rows(handler): + """Memory handlers return (mem_json, status); the click ALSO renders the + table directly, so the visible refresh never depends on the browser-side + mem_box.change hop (the data persisted fine, but the table could stay + stale until the next page load).""" + def run(*args): + mem_json, status = handler(*args) + return mem_json, status, _facts_rows(_facts_load(mem_json)) + return run + + +def _mem_remember(text: str, kind: str, mem_json: str): + facts = _facts_load(mem_json) + if not (text or "").strip(): + return _facts_dump(facts), "Type a fact first." + facts = memory.merge_facts(facts, [(text, kind or "note")], kind or "note") + return _facts_dump(facts), f"Remembered: {text.strip()}" + + +# Showcase memories for the demo: each maps to one of the sample conversations +# (Weekend Crew BBQ invite, the school-pickup juggle, the doctor-appointment +# confirmation) so a visitor can SEE memory personalizing the extraction — +# known people in attendees, the right reminder lead, a familiar location. +_SAMPLE_FACTS = [ + ("Alex hosts the Weekend Crew — get-togethers are usually in Alex's backyard", "contact"), + ("Maya is in the Weekend Crew — always brings her famous potato salad, no arguments", "contact"), + ("Jordan is in the Weekend Crew — will drink anything cold", "contact"), + ("Party drinks: kombucha for Maya, lemonade or sparkling water for Alex", "preference"), + ("RSVP to invites before the deadline — don't leave the host hanging", "preference"), + ("Jenna is my partner — we trade school pickup when meetings collide", "contact"), + ("School pickup is at 3:30 on weekdays", "note"), + ("Primary Care of Manhattan (my doctor) is at 112A West 72nd Street, Upper West Side", "location"), + ("Medical appointments: arrive 15 minutes early and remind me 60 minutes before", "preference"), +] + + +def _mem_load_samples(mem_json: str): + """Seed the device memory with the showcase facts. merge_facts dedupes by + normalized text (reloading just bumps weights), so this is idempotent.""" + facts = memory.merge_facts(_facts_load(mem_json), _SAMPLE_FACTS) + return _facts_dump(facts), ( + f"✨ Loaded {len(_SAMPLE_FACTS)} sample memories — paste the BBQ invite, " + "a pickup chat, or an appointment text and watch them personalize the run." + ) + + +def _mem_forget(fact_id, mem_json: str): + facts = _facts_load(mem_json) + if fact_id is None or str(fact_id).strip() == "": + return _facts_dump(facts), "Enter an id to forget." + try: + fid = int(fact_id) + except Exception: # noqa: BLE001 + return _facts_dump(facts), "Invalid id." + new = [f for f in facts if int(f.get("id", -1)) != fid] + ok = len(new) != len(facts) + return _facts_dump(new), ("Forgotten." if ok else "No fact with that id.") + + +def _on_onboard(people: str, reminder, decline: str, mem_json: str): + """First-run onboarding → write structured facts to the user's device memory.""" + facts = _facts_load(mem_json) + new: list[tuple] = [] + for line in (people or "").splitlines(): + line = line.strip() + if not line: + continue + sep = "=" if "=" in line else ("-" if " - " in line else (":" if ":" in line else None)) + if sep and sep in line: + name, role = line.split(sep, 1) + name, role = name.strip(), role.strip() + if name and role: + new.append((f"{name} is the {role}", "contact")) + continue + new.append((f"{line} is someone you make plans with", "contact")) + if reminder: + try: + new.append((f"Default reminder: {int(reminder)} minutes before events", "preference")) + except Exception: # noqa: BLE001 + pass + if (decline or "").strip(): + new.append((f"You usually decline events on: {decline.strip()}", "preference")) + facts = memory.merge_facts(facts, new) + msg = f"Saved {len(new)} fact(s) to your device." if new else "Nothing to save." + # Stay open so the confirmation is visible; the panel never leaves the page. + return _facts_dump(facts), gr.update(open=True), msg + + +def _parse_memory_file(path: str) -> tuple[list[str], list[tuple]]: + """Local, off-grid parse of a contacts/calendar file → (contact names, pref facts).""" + names: list[str] = [] + prefs: list[tuple] = [] + try: + raw = Path(path).read_text(encoding="utf-8", errors="ignore") + except Exception: # noqa: BLE001 + return names, prefs + low = path.lower() + if low.endswith(".vcf"): + for line in raw.splitlines(): + if line.upper().startswith("FN:"): + nm = line[3:].strip() + if nm: + names.append(nm) + elif low.endswith(".csv"): + import csv as _csv + rdr = _csv.reader(raw.splitlines()) + rows = list(rdr) + if rows: + header = [h.strip().lower() for h in rows[0]] + idx = next((i for i, h in enumerate(header) + if h in ("name", "full name", "fullname", "first name", "display name")), 0) + for r in rows[1:]: + if len(r) > idx and r[idx].strip(): + names.append(r[idx].strip()) + elif low.endswith(".ics"): + for line in raw.splitlines(): + u = line.upper() + if u.startswith("SUMMARY:"): + s = line.split(":", 1)[1].strip() + if s: + prefs.append((f"You have an event like: {s}", "note")) + elif "ATTENDEE" in u and "CN=" in u: + try: + cn = line.split("CN=", 1)[1].split(":", 1)[0].split(";")[0].strip() + if cn: + names.append(cn) + except Exception: # noqa: BLE001 + pass + # de-dup, cap + seen, uniq = set(), [] + for n in names: + k = n.lower() + if k not in seen and len(n) <= 60: + seen.add(k) + uniq.append(n) + return uniq[:50], prefs[:20] + + +def _import_file(file_path, mem_json: str): + facts = _facts_load(mem_json) + if not file_path: + return _facts_dump(facts), "Choose a .vcf, .csv, or .ics file first." + names, prefs = _parse_memory_file(file_path) + new = [(f"{n} is a contact you make plans with", "contact") for n in names] + prefs + facts = memory.merge_facts(facts, new) + return (_facts_dump(facts), + f"Imported {len(names)} contact(s) and {len(prefs)} note(s) — saved to your device.") + + +def _import_gcal(enabled: bool, mem_json: str): + """Opt-in Google Calendar import → contacts/locations as facts. Cloud opt-in; + degrades gracefully when Google libs/creds aren't configured on the Space.""" + facts = _facts_load(mem_json) + if not enabled: + return _facts_dump(facts), "Tick the box to allow Google Calendar import." + try: + from calendar_out.gcal import read_recent_facts # lazy: google libs optional + + names, locs = read_recent_facts() + except Exception as e: # noqa: BLE001 no creds / libs / offline -> graceful + return (_facts_dump(facts), + f"Google Calendar not connected on this Space ({type(e).__name__}). " + "Local-only memory is unaffected.") + new = [(f"{n} is a contact you make plans with", "contact") for n in names] \ + + [(f"You often have events at: {loc}", "location") for loc in locs] + facts = memory.merge_facts(facts, new) + return (_facts_dump(facts), + f"Imported {len(names)} contact(s) and {len(locs)} location(s) from Google Calendar.") + + +_FEED_PATH = os.environ.get("FEED_PATH", "/tmp/ingest_feed.json") + + +def _read_feed(): + """The feed holds RAW private iMessages (text, senders, images). Never + serve it from a public UI: gated behind EXPOSE_FEED=1, which only a + trusted/local deployment should set.""" + if os.environ.get("EXPOSE_FEED") != "1": + return {"feed": "private", + "hint": "Set EXPOSE_FEED=1 on a trusted/local deployment to view " + "ingested messages here. Public builds never expose them."} + try: + return json.loads(Path(_FEED_PATH).read_text()) + except Exception: # noqa: BLE001 + return [] +