Spaces:
Sleeping
Sleeping
| """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)] | |