Metadata-Version: 2.5
Name: urun-sense
Version: 0.1.0
Summary: Python SDK for the Sense realtime decision protocol
Project-URL: Homepage, https://github.com/urun-sh/sense-sdk
Project-URL: Repository, https://github.com/urun-sh/sense-sdk
Author: Urun Platform Inc
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: aiohttp>=3.9
Provides-Extra: demo
Requires-Dist: rich>=13; extra == 'demo'
Requires-Dist: sounddevice>=0.5; (sys_platform != 'linux') and extra == 'demo'
Provides-Extra: quic
Requires-Dist: aioquic>=1.2; extra == 'quic'
Requires-Dist: msgpack>=1.0; extra == 'quic'
Provides-Extra: webrtc
Requires-Dist: aiortc>=1.9; extra == 'webrtc'
Description-Content-Type: text/markdown

# urun-sense (Python SDK)

Python SDK for the Sense realtime decision protocol (`docs/protocol.md`). Dependency:
`aiohttp` only.

```bash
uv add urun-sense  # or: pip install urun-sense
```

## Quick start

```python
import asyncio
from sense import Sense

app = Sense(api_key="...")  # or SENSE_API_KEY env
finished = app.noul("Did the speaker just finish their turn?", id="finished")
intent = app.choice("What does the user want?", ["support", "sales", "other"], id="intent")
urgency = app.score("How urgent is this?", ["low", "medium", "high"], id="urgency")

s = app.session()


async def main():
    async for wave in s.run(audio=mic_stream):  # async iterator / callable / iterable
        print(
            wave.wave_id,
            wave.t_ms,
            wave.result("finished").value,  # bool
            wave.result("intent").top,  # argmax option
            wave.result("urgency").p_at_least("medium"),
        )
```

## Flagship demo

The wheel ships the cheese classifier example (`sense/examples/cheese_demo.py`): it streams your
microphone, classifies what you say, and renders a live ANSI display. Requires Python 3.11+.

```bash
pip install "urun-sense[demo]"          # adds rich + sounddevice (non-Linux)
SENSE_API_KEY=<key> python -m sense.examples.cheese_demo
```

PowerShell: `$env:SENSE_API_KEY = "<key>"` first, then run the `python` line bare.

Linux drives PipeWire directly (`pw-record`/`pw-dump`/`wpctl`); macOS and Windows need microphone
permission for your terminal. `--mic-only` runs without a key.

## Surface

- **Questions** — TEXT-FIRST: `app.noul(question, *, id=None, options=None)`,
  `app.choice(question, options, *, id=None)`, `app.score(question, levels, *, id=None)`.
  The natural-language question is the first argument; the id is optional and
  auto-slugged (lowercase, non-alnum → `_`, unique) when omitted. Low-level
  alternative: `Session.add_question(qid, kind, ...)`.
- **Predicates** — questions and accessors compose into `Predicate` trees:
  `finished & (intent == "support")`, `finished.p >= 0.8`,
  `intent.p("support") >= 0.7`, `urgency.value >= 1.5`,
  `urgency.p_at_least("medium") >= 0.6`, `~q`, `Predicate.all_of/any_of`,
  `Predicate.between(subject, enter, exit)` (stateful hysteresis latch).
  Score comparison rule: on the bare question subject, `q == x` / `q != x`
  compare the TOP LEVEL NAME (argmax) while `q > x` / `>=` / `<` / `<=`
  compare the EXPECTED ORDINAL.
  `q == x` and `q.top == x` are the same argmax comparison on a choice question.
- **Context** — passed to `when`/`onWave` handlers (and usable standalone for
  offline replay): `ctx.latest(source=None)`, `ctx.waves(coalesce=False)`
  (bounded to the last 512 waves), `ctx.when(pred, handler, source=None) -> handle`
  (edge-triggered), `ctx.onWave(handler, source=None)`, `ctx.get(q, source=None)`,
  `ctx.run(key, fn(ctx), on_cancel=..., replace=False)`, `ctx.cancel(key, barge_in=True)`,
  `ctx.signal` (the current run's cancellation event, TS AbortSignal parity),
  `ctx.session`.
- **Inputs** — `s.audio.push(pcm_bytes, source="default")` (PCM16 24 kHz mono),
  `s.video.push(jpeg_bytes)` (≤ 256 KB/frame), `s.text.push(text, mode="append"|"replace"|"event")`.
  A text push alone opens and drives a turn — a text-only session works with
  zero audio.
- **Session** — `s.latest(q, source=None)`, `s.when(..., source=None)`,
  `s.onWave(handler, coalesce=False, source=None)`, `s.waves(coalesce=False)`
  (live bounded subscription — see below), `s.stats`
  (waves/decisions/errors/protocol_errors), `s.close()` (cancels every tracked
  ctx task, firing `on_cancel` hooks, before tearing down the wire).

## Wire behavior (protocol.md §1–§7)

- `wss://.../v1/realtime?model=...` + Bearer auth; `session.update` carries the
  question envelope (`{questions, hz}`) as a JSON string.
- One commit + `response.create(max_output_tokens=250_000)` opens a cadence turn;
  the empty commit before audio arrives is benign and ignored (`empty_buffer`).
- Decision lines are assembled into waves client-side (§6.3: repeat-qid flush,
  turn-end flush, `sense.wave` boundary preferred when PR-3 lands).
- An incomplete wave (missing/unknown/null answer) is a `ProtocolError`: logged
  loudly and counted in `stats.protocol_errors` — never silently treated as
  false/hold.
- `503`/`429` back off (5 s→30 s cap, `Retry-After` honored), `401`/`404` fatal,
  `session_gone` (close 1011) reconnects fresh, `buffer_full` commits.
- `ctx.cancel(key)` / barge-in sends `response.cancel`; `ctx.cancel(key, barge_in=False)`
  cancels background work without preempting the turn (used by
  `run(..., replace=True)` supersede).

## Documented degradations (awaiting serving PRs)

- **Audio sources (PR-2)**: non-default sources are queued client-side and pushed
  round-robin over the single unlabeled audio lane; every wave is labeled with
  the input source it was drained for, and `when`/`onWave`/`latest` accept
  `source=` to evaluate against that source's stream. Sources are a client-side
  concept until the server PR lands — no `source` field is sent on the wire.
- **Session.waves() vs run()**: `run()` is the primary consumption path;
  `s.waves()` is a live bounded subscription (queue of 64, drop-oldest) that
  never replays history and never competes with `run()` delivery. `ctx.waves()`
  keeps the last 512 waves for offline replay.
- **Text replace/event**: the conversation transcript is append-only on today's
  wire; `replace`/`event` are client-side bookkeeping with documented degradation.

## Tests

```bash
uv run --project . pytest tests ../conformance/python_runner.py
```
