Metadata-Version: 2.4
Name: snowflake-streamlit-snap-and-ask
Version: 0.0.2
Summary: Snap-and-Ask chart wrapper for Streamlit with brush selection and AI chat integration
License: MIT
Requires-Python: >=3.9
Requires-Dist: altair>=5.0
Requires-Dist: streamlit>=1.36
Provides-Extra: dev
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# streamlit-snap-and-ask

[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![Streamlit 1.36+](https://img.shields.io/badge/streamlit-1.36+-ff4b4b.svg)](https://streamlit.io)
[![Altair 5+](https://img.shields.io/badge/altair-5+-ffb000.svg)](https://altair-viz.github.io)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![Tests](https://img.shields.io/badge/tests-57%20JS%20%2B%2020%20PY-brightgreen.svg)](#testing)

A drop-in Streamlit custom component that wraps `st.altair_chart` with a **brush selection** that flows back to Python, and an optional **AI Action Bar** ("Add to chat" / "Explain") for chat-driven analytics in Snowsight Streamlit-in-Snowflake.

```python
from snowflake.streamlit.snap_and_ask import altair_chart

selection = altair_chart(my_chart, key="line", include_data_points=True)
if selection:
    st.write(f"{selection.start} → {selection.end}")
    st.dataframe(selection.data_points)   # records inside the brush range
```

---

## Why?

`st.altair_chart` renders a chart but doesn't tell Python *which points the user selected*. Common workarounds (e.g. `streamlit-vega-lite`) return raw Vega signal payloads with no consistent shape across chart types.

`snap-and-ask`:

- **Auto-injects** an interval brush on the x-axis — works with any line / scatter / bar / histogram / area chart with no spec changes
- Returns a typed [`AltairSelection`](#altairselection) — the bounds are always available; the underlying records are an **opt-in** so the wire payload stays small for big datasets
- Ships an **AI Action Bar** styled with Snowflake's [Stellar](https://stellar.snowflake.com/) design system tokens for native look-and-feel inside Snowsight
- Sends `SNOWFLAKE_BRUSH_AI_ACTION` events to the parent frame **and** broadcasts them on a same-origin `BroadcastChannel`, so the bar's "Add to chat" / "Explain" callbacks can be wired into a Snowsight Cortex Agent or validated locally without parent-frame access

---

## Installation

```bash
pip install snowflake-streamlit-snap-and-ask
```

> **Python**: 3.9+ &nbsp;·&nbsp; **Streamlit**: 1.36+ (auto-falls back from the v2 components API to v1 if running on older Streamlit / Python 3.9).

---

## Quick start

```python
import altair as alt
import pandas as pd
import streamlit as st
from snowflake.streamlit.snap_and_ask import altair_chart

df = pd.DataFrame({"ts": pd.date_range("2024-01-01", periods=90), "errors": ...})

chart = (
    alt.Chart(df)
    .mark_line()
    .encode(x="ts:T", y="errors:Q")
    .properties(height=260)
)

selection = altair_chart(chart, key="errors_chart")

if selection:
    st.write(f"Selected: **{selection.start}** → **{selection.end}**")
```

That's the full interaction loop — no callbacks, no manual signal listeners, no `st.session_state` plumbing.

---

## Demo

A 5-tab gallery covering every chart type lives at [`sample/app.py`](./sample/app.py):

| Tab | Chart | `include_data_points` |
| --- | --- | --- |
| Line | `mark_line` over a temporal axis | `True` |
| Scatter | `mark_circle` colored by tier | `True` (capped at 500) |
| Bar | `mark_bar` on a nominal axis | `True` |
| Histogram | `mark_bar` with `bin=` transform | `True` (explicit `brush_field`) |
| Area | `mark_area`, bounds-only mode | `False` (default — no records on the wire) |

Run it locally:

```bash
npm install && npm run build
pip install -e .
ALTAIR_COMPONENT_DEV_DIR=./dist streamlit run sample/app.py
```

The demo also includes an **AI Action Log** panel that subscribes to the `BroadcastChannel` and shows every "Add to chat" / "Explain" click in real time — useful for verifying agent integration.

---

## API reference

### `altair_chart(chart, **kwargs) -> AltairSelection | None`

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `chart` | `alt.Chart \| LayerChart \| FacetChart` | **required** | Any Altair chart object. Never mutated. |
| `inject_brush` | `bool` | `True` | Auto-inject an interval `param` named `brush` on the x-axis. Set `False` if you've already defined one. |
| `brush_field` | `str \| None` | `None` (auto-detect) | Field to brush on. Required if auto-detection fails (layered/faceted charts, `bin=` transforms, etc.). |
| `height` | `int \| None` | `None` | Override chart height in pixels. |
| `key` | `str \| None` | `None` | Widget key. **Required** when rendering multiple charts on one page. |
| `ai_context_enabled` | `bool` | `False` | When `True`, renders the AI Action Bar above an active brush selection. |
| `ai_peer` | `str \| None` | auto | Peer namespace for the AI message. Defaults to `'streamlit-{app_name}'`. |
| `explain_prompt` | `str \| None` | `None` | Custom prompt sent on **Explain**. Falls back to a generic prompt. |
| `ai_context_summary` | `str \| None` | `None` | Override the auto-generated summary sent in AI action messages. Useful for providing domain-specific context to the AI agent. |
| `include_data_points` | `bool` | `False` | When `True`, include the source records that fall within the brush range in `selection.data_points`. **Off by default to keep the payload small.** |
| `max_data_points` | `int \| None` | `None` | Cap the records returned in `data_points`. Records past the cap are dropped. Ignored if `include_data_points=False`. |

Returns `AltairSelection` while a brush is active, `None` otherwise.

Raises `ValueError` if `brush_field` cannot be auto-detected and was not supplied.

### `AltairSelection`

```python
@dataclass
class AltairSelection:
    field: str                                 # e.g. "ts"
    field_type: Literal["T", "Q", "O", "N"]    # T=temporal Q=quant O=ordinal N=nominal
    start: datetime | float                    # datetime when field_type == "T"
    end: datetime | float
    data_points: list[dict[str, Any]]          # filtered records (opt-in)
```

- For temporal fields the bounds are timezone-aware `datetime` objects (UTC).
- For all other fields the bounds are `float`.
- `data_points` is empty `[]` unless `include_data_points=True` *and* the chart spec contains inline data (Altair 5+ named-dataset format and inline `data.values` are both supported). Specs that load remote URL data return empty; do the filter in Python with the bounds.

---

## AI Action Bar

When `ai_context_enabled=True`, an **"Add to chat" / "Explain"** action bar slides in next to the active brush selection. Clicking either action:

1. Posts a `SNOWFLAKE_BRUSH_AI_ACTION` message to `window.parent` (this is the channel Snowsight relays to its AI agent in production).
2. Publishes the same message on a same-origin `BroadcastChannel('snap-and-ask:ai-actions')` so sibling iframes (e.g. an in-app log panel, an integration test harness) can observe the action without parent-frame access.

Message shape:

```jsonc
{
  "type": "SNOWFLAKE_BRUSH_AI_ACTION",
  "payload": {
    "actionId": "add-to-chat" | "explain",
    "peer": "streamlit-my_app",
    "displayText": "Brush on ts: 2024-01-15 → 2024-02-03",
    "summary": "Selected 20 records with field 'ts' between 2024-01-15 and 2024-02-03 …",
    "explainPrompt": "Explain why error rates may have changed over this period."
  }
}
```

To listen from a sibling Streamlit container (the pattern used by the demo's AI Action Log):

```javascript
const channel = new BroadcastChannel('snap-and-ask:ai-actions');
channel.onmessage = (ev) => {
  if (ev.data?.type === 'SNOWFLAKE_BRUSH_AI_ACTION') {
    console.log(ev.data.payload);
  }
};
```

The exported constant `SNAP_AND_ASK_BROADCAST_CHANNEL` is also available from the JS bundle for downstream consumers.

### Stellar design system

The bar is styled to match Snowflake's [Stellar](https://stellar.snowflake.com/) design system using canonical token values (validated via `stellar-mcp`):

| Token | Value | Used for |
| --- | --- | --- |
| `baltoTheme.surfaceLevel_2Background` | `#fbfbfb` | Bar background (light) |
| `baltoTheme.surfaceLevel_2Border` | `#d5dae4` | Bar border (light) |
| `baltoTheme.reusableTextPrimary` | `#1e252f` | Text |
| `baltoTheme.elevation_1BoxShadow` | `0 2px 4px rgba(25,30,36,0.1)` | Elevation |
| `baltoTheme.reusableBackgroundRowHover` | `#eceef1` | Action hover |
| `radius-md`, `space-gap-2xs`, `space-vertical-2xs`, `space-horizontal-sm` | `8px`, `4px`, `4px`, `8px` | Spacing |

Dark-mode tokens are applied automatically based on `prefers-color-scheme`, body luminance, and a DOM mutation observer for runtime theme switching.

---

## Architecture

```
┌─────────────────────────── Python ───────────────────────────┐
│ snowflake.streamlit.snap_and_ask.altair_chart(chart, ...)    │
│   ├── chart.to_dict()                                        │
│   ├── _detect_brush_field()      ← auto x-axis detection     │
│   ├── _inject_brush_selection()  ← adds Vega-Lite param      │
│   └── components.v2/v1.component(data={spec, brushField,…})  │
└─────────────────┬─────────────────────────────────┬──────────┘
                  │ component data                  │ component value
                  ▼                                 ▲
┌─────────────────────────── React (iframe) ───────────────────┐
│ src/main.tsx              ← createRoot per parentElement     │
│ src/AltairChart.tsx       ← top-level component              │
│   ├── useVegaEmbed        ← vega-embed + brush signal        │
│   │     listener (filters records via filterRecordsByRange)  │
│   ├── useAltairBrushSummary  ← humanises selection           │
│   ├── useAltairAiAction      ← postMessage + BroadcastChannel│
│   ├── useDarkMode            ← prefers-color-scheme + DOM    │
│   │                            mutation observer             │
│   └── components/AiActionBar.tsx ← Stellar-aligned UI        │
└──────────────────────────────────────────────────────────────┘
```

Bundled as a single ES module (`dist/main.js`) via Vite library mode with React inlined, no external CDNs at runtime.

---

## Development

### Prerequisites

- Python ≥ 3.9
- Node.js ≥ 18, npm
- (Optional) [`stellar-mcp`](https://stellar.snowflake.com/) for design-system validation

### Setup

```bash
# Frontend
npm install
npm run build           # one-shot build → ./dist
npm run dev             # vite build --watch (rebuild on change)

# Python
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
```

### Run the sample app

```bash
ALTAIR_COMPONENT_DEV_DIR=./dist streamlit run sample/app.py
```

The `ALTAIR_COMPONENT_DEV_DIR` env var points the component at your local `dist/` build, bypassing the packaged install. Set both `npm run dev` and `streamlit run` in two terminals for hot-iteration.

### Project layout

```
streamlit-snap-and-ask/
├── python/snowflake/streamlit/snap_and_ask/
│   ├── _altair_component.py     # public altair_chart() + component registration
│   ├── _altair.py               # spec inspection / brush injection
│   └── _types.py                # AltairSelection dataclass
├── src/
│   ├── main.tsx                 # React mount entry
│   ├── AltairChart.tsx          # root component
│   ├── components/AiActionBar.tsx
│   ├── hooks/                   # useVegaEmbed, useAltairAiAction, useDarkMode, …
│   ├── utils/filterRecordsByRange.ts   # inline + Altair 5+ named-dataset support
│   └── __tests__/               # vitest + Testing Library
├── public/index.html            # Streamlit v1 component shim
├── sample/app.py                # 5-tab chart gallery + AI Action Log
├── tests/                       # pytest
├── dist/                        # built component (npm run build)
├── pyproject.toml               # hatchling build, asset_dir = dist
├── vite.config.ts
└── vitest.config.ts
```

---

## Testing

```bash
# Frontend (vitest + Testing Library + jsdom)
npm test                # watch
npm run test:ci         # one-shot

# Python (pytest)
pytest
```

Currently **57 frontend tests + 20 Python tests** covering:

- Brush field auto-detection across encoding shapes
- Spec injection (idempotent; preserves existing params; works on layered/faceted charts)
- Record filtering for both inline `data.values` and Altair 5+ named-dataset formats
- Vega plot height resolution
- AI action message shape and BroadcastChannel emission
- Selection summary formatting for T / Q / O / N field types
- Component-value lifecycle (mount, brush start, brush end, key change)

---

## Snowflake / Streamlit-in-Snowflake notes

- The component works in stock Streamlit *and* Streamlit-in-Snowflake (Snowsight) without code changes.
- In Snowsight, the parent frame's `SNOWFLAKE_BRUSH_AI_ACTION` listener relays clicks to the active Cortex Agent.
- Locally, sibling iframes can observe actions via `BroadcastChannel('snap-and-ask:ai-actions')` — no parent-frame access required.
- The Stellar tokens used here are the canonical Balto values, so the bar visually matches Snowsight panels by default.

---

## Contributing

Issues and PRs welcome. Please:

1. Run `npm run type-check`, `npm run test:ci`, and `pytest` before opening a PR.
2. Keep frontend changes scoped — most logic belongs in a hook or util with a colocated `*.test.ts`.
3. Follow Stellar token values for any new Snowsight-facing UI; validate with `stellar-mcp` if you're adding colors / spacing.

---

## License

MIT — see [LICENSE](./LICENSE).
