birdiee / docs /CONTRACT.md
Affaad's picture
Deploy Birdie 6ce7570+worktree
ea874ba verified
|
Raw History Blame Contribute Delete
10.3 kB
# Birdie data contract
This file is the merge contract between the two backend repos. Anything that produces or consumes seat data must use
the models below (they live in `birdie/models.py`). Map your code onto the public functions listed per module; private
helpers (leading underscore) are free to change.
Rules:
- `birdie/` never imports Gradio. UI code lives only in `app.py` and `birdie/ui/`.
- Every timestamp is a timezone-aware `datetime` in Doha time (UTC+3, `birdie.clock.DOHA_TZ`).
- Everything time-based takes a `clock` (`RealClock` in the app, `FakeClock` in tests). Nothing sleeps in tests.
- Timers and thresholds come from `birdie.config.TIMERS` (demo or production, behind `DEMO_MODE`).
- Seat polygons are normalised `(x, y)` corners in `0..1`, so they work at any camera resolution.
- The app does not persist source frames. Hosted Space logging and retention must be checked separately. The app stores seat states and aggregate counts.
## Models (`birdie/models.py`)
| Model | Fields |
|---|---|
| `Venue` | `venue_id: str`, `name: str`, `area: str`, `type: VenueType` (`cafe`, `library`, `cowork`, `study_room`), `lat: float`, `lon: float`, `zones: list[str]`, `source: VenueSource` (`live`, `recorded`, `simulated`), optional `floor_plan` and display metadata |
| `Seat` | `seat_id: str`, `venue_id: str`, `zone: str`, optional `table_id`, optional customer-map coordinates, `seat_polygon` and `workspace_polygon` (normalised camera regions), `has_outlet`, `has_window` |
| `StudyTable` | `table_id`, `table_type` (`shared`, `private`, `single_person_desk`), `capacity`, `seat_ids`, normalised `table_polygon`, `is_shareable`, `is_available_for_studying` |
| `SeatSnapshot` | `venue_id`, `seat_id`, `zone`, `has_outlet`, `state: SeatState` (`UNKNOWN`, `FREE`, `OCCUPIED`, `HELD`, `REVIEW_REQUIRED`, `UNAVAILABLE`), timestamps, detector evidence, grace-period countdown, review and override flags |
| `SeatEvent` | `ts: datetime`, `venue_id`, `seat_id`, `old_state: SeatState \| None`, `new_state: SeatState` |
| `Query` | `area: str \| None`, `party_size: int`, `needs_outlet: bool`, `quiet: bool`, `zone: str \| None`, `venue_type: VenueType \| None`, `arrival_minutes: int` |
| `VenueConfig` | `venue`, `seats`, `tables`, `unmapped_seat_ids`, private mirror-exclusion regions, legacy `camera_table_regions` fallback, and optional `recording_sha256` tying calibration to one clip |
With an explicit `tables` layout, each configured seat must link to exactly one table and appear in that table's `seat_ids`. Known IDs awaiting calibration live in `unmapped_seat_ids`; they are excluded from capacity and availability. Camera coordinates stay private and are not returned by the public venue API.
An API seat is a verified study space only when its state is `FREE`, its configured chair is detected above the detector confidence threshold, and its assigned workspace is clear. A missing chair, uncertain/shared item, missing polygon, unknown seat state, or disabled table is never counted as usable. Shared tables are evaluated seat by seat. Private or non-shareable tables are one group space and require every seat to be verified free. A belongings timeout only raises staff review; it never frees the blocked workspace. `counts.usable_study_spaces` is the customer-facing total; `counts.free` remains the lower-level occupancy count for compatibility.
Zone ids used by the demo venues: `quiet`, `open`, `group`, `family`, `women`, `terrace`, `indoor`
(labels in `birdie.venues.ZONE_LABELS`).
## Storage (`birdie/storage.py`)
SQLite at `data/birdie.db` (gitignored). `Repository(path)`; use `Repository(":memory:")` in tests.
| Function | Purpose |
|---|---|
| `add_seat_event(event, synthetic=False)`, `add_seat_events(events, synthetic=False)` | Append to the seat event log. `synthetic=True` marks back-filled history for simulated venues |
| `seat_events(venue_id=None, since=None, until=None, include_synthetic=True) -> list[SeatEvent]` | Read the event log in time order |
| `record_occupancy(venue_id, ts, free, occupied, held)`, `occupancy_buckets(venue_id, since=None)` | Live occupancy in 15-minute buckets |
| `add_alert(...)`, `alerts(venue_id=None, unresolved_only=False)`, `resolve_alerts(venue_id, seat_id, kind=None)` | Staff alerts |
| `save_seat_snapshots(snapshots)`, `seat_snapshots(venue_id)` | Persist current state and unattended timer timestamps across process restarts |
## Venues (`birdie/venues.py`)
`load_all() -> dict[str, VenueConfig]`, `load_venue(venue_id)`, `save_venue(config)`, `zone_label(zone)`.
## Vision (`birdie/vision/`)
| Function | Purpose |
|---|---|
| `detector.detect(frame, threshold=0.4) -> list[{label, score, box}]` | RT-DETR r18 (`PekingU/rtdetr_r18vd`). Keeps COCO classes `chair`, `person`, `backpack, handbag, suitcase, laptop, cup, bottle, book, cell phone`. `box = [x1, y1, x2, y2]` in pixels of the frame passed in. Resizes to long side <= 640 internally |
| `detector.resize_long_side(frame, 640) -> PIL.Image`, `detector.load()` | Helpers. `_infer` is the pure (frame in, detections out) function wrapped with `@spaces.GPU` when `spaces` is importable |
| `seats.assign_detections(detections, seats, (w, h), min_overlap=0.2, table_regions=None) -> {seat_id: [det]}` | Assign person/item evidence to configured seat/workspace regions; ambiguous shared-table items remain unknown |
| `seats.observe_evidence(assigned)` | Confidence-aware `PERSON`, `ITEMS`, `EMPTY`, or conservative `UNKNOWN` evidence |
| `seats.workspace_evidence(detections, seats, (w, h))` | Verify a visible chair and clear/blocked/uncertain desk workspace for each calibrated seat |
| `state.SeatStateMachine(venue_id, seat_id, clock, k=3, hold_seconds=...)` | Smooths evidence; belongings become `HELD`, then `REVIEW_REQUIRED` after the real-time grace period |
| `state.VenueTracker(venue_id, seats, clock, on_event)` | `.process({seat_id: Observation}) -> [SeatEvent]` (missing seats = UNKNOWN), `.tick()` (hold timers and stale-feed expiry), `.snapshots()`, `.counts()`, `.force(states)`, `.set_seats(seats)` |
| `render.annotate(frame, detections)` | RGB frame with detector boxes only; no static seat regions and no blurring |
| `render.to_data_uri(frame)` | In-memory JPEG data URL (frames never touch disk) |
| `clip.iter_clip_frames(path, fps=1)` | Sample a recorded clip at 1 fps, in memory |
| `demo.replay_recording(svc, venue_id, path, stop)` | Repeatedly process a configured clip and invalidate seat states on failure |
## Forecast (`birdie/forecast/`)
| Function | Purpose |
|---|---|
| `calendar.is_weekend(dt)` (Friday + Saturday), `day_type(dt)` (`weekday`/`friday`/`saturday`), `is_friday_prayer_quiet(dt)`, `is_ramadan(dt)`, `is_exam_season(dt)`, `is_terrace_season(dt)`, `slot_of(dt)`, `features(dt)` | Qatar calendar. `RAMADAN_RANGES`, `EXAM_RANGES` are editable |
| `synthetic.profile(venue_type, ts)`, `synthetic.generate_history(venue, n_seats, end, weeks=4) -> [{ts, occupied, free, total, synthetic}]` | Synthetic history, always labelled synthetic |
| `model.BaselineForecaster(venue_type).fit(history).predict_ratio(ts)` | Default forecaster (occupancy ratio 0..1) |
| `model.ChronosForecaster().predict(contexts, steps)`, `model.load_chronos()` | Optional `amazon/chronos-bolt-small`; raises `ForecasterUnavailable` / returns None if it can't load |
| `model.ForecastEngine(configs, clock, repo).ratio_at(venue_id, ts)` | What the app uses (`BIRDIE_USE_CHRONOS=1` to try Chronos) |
## Decision (`birdie/decision.py`)
Functions take a `LiveData` object: anything with `now()`, `venues()`, `venue(id)`, `seats(id)`, `snapshots(id)`,
and `forecast_ratio(id, ts)`. `BirdieService` implements it.
| Function | Purpose |
|---|---|
| `availability_at(data, venue_id, arrival_minutes) -> Availability` | `expected_free`, `low`, `high`, `live_free`, `forecast_free`, `live_weight`; `.summary()` |
| `blend_availability(...)` | The pure blending rule (live weight `0.5 ** (arrival / 20 min)`) |
| `focus_score(data, venue_id, arrival_minutes) -> FocusScore` | `score` 0..100 and `parts` (free 40, noise 25, outlets 20, trend 15) |
| `recommend(data, query, top=3) -> [Recommendation]` | Filters by the `Query`, ranks by focus score; each has `reasons` |
## Simulator (`birdie/simulator.py`)
`Simulator(configs, trackers, ratio_at, clock)`: `.step()` feeds observations to each simulated venue's
`VenueTracker.process` (the camera's code path); `.backfill(repo, venue_id, hours=24)` writes a synthetic event log.
## Analytics (`birdie/analytics.py`)
`venue_dashboard(repo, venue_id, seat_ids, now, hours=24, observed_since=None) -> Dashboard` with
`occupancy_by_hour`, `peak_hours`, `held_minutes_total`, `held_minutes_by_seat`. Building blocks: `seat_intervals`,
`occupancy_by_hour`, `held_minutes`.
## Service (`birdie/service.py`)
`BirdieService(repo=None, clock=None, venues=None, timers=TIMERS, detector=None)` is the one object the UI talks to:
`process_frame(venue_id, frame)` associates detections and updates seat state; `snapshots(venue_id)`, `zone_counts(venue_id)`, `upsert_seat(seat)`,
`delete_seat(venue_id, seat_id)`, `study_availability(venue_id)`, `set_table_available_for_studying(...)`, `availability_at(venue_id, minutes)`,
`forecast` (ForecastEngine), `simulator`, `tick()`, `start()` (back-fill + 2 s scheduler), `stop()`.
`GET /api/venues/{venue_id}` returns table-level and seat-level `usable_for_studying`, `workspace_clear`, and `availability_reason`. Staff may disable/re-enable a seat or table through the bearer-token protected maintenance endpoints. Venue geometry changes are stored in `venues/<venue_id>.json` and require a fresh camera sample before availability returns.
## Assistant (`birdie/assistant/`, stub)
`answer(data, question) -> AssistantAnswer` = `parse_query(question) -> Query` (Fanar, JSON) ->
`recommend()` in Python -> `phrase_reply(...)` (Fanar, user's language). No tool calling.
## Mapping a second repo onto this contract
1. Convert your seat and venue objects to the models above (`Model.model_validate(dict)`).
2. Anything that observes seats should end in `VenueTracker.process({seat_id: Observation})`, so smoothing,
hold timers and SeatEvents stay identical.
3. Anything that reads availability should go through `LiveData` + `birdie.decision`.