Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ jobs:
- run: ruff check .
- run: pytest -q

web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
- run: npm run typecheck

container:
needs: test
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
Expand Down
32 changes: 30 additions & 2 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ FloatChat's live demo runs entirely on free tiers:
| Keep-awake ping | [cron-job.org](https://cron-job.org) | calls `/ping` every 10 min so Render never sleeps |
| Data loading | GitHub Actions | backfill once, refresh daily at 02:00 UTC |
| LLM | [Groq](https://console.groq.com) | free API key |
| Web app (Next.js) | [Vercel](https://vercel.com) Hobby plan | static pages from a global CDN; the browser streams answers straight from the API |

You create every account and paste every secret yourself. Nothing secret goes in the repo.

Expand Down Expand Up @@ -75,7 +76,7 @@ You create every account and paste every secret yourself. Nothing secret goes in
| `LLM_MODEL` | a current Groq chat model (see console.groq.com/docs/models), e.g. `openai/gpt-oss-120b` |
| `LLM_API_KEY` | your Groq key |
| `LLM_DAILY_BUDGET` | `2000` |
| `ALLOWED_ORIGINS` | `http://localhost:3000` for now; add the Vercel URL in Phase 3 |
| `ALLOWED_ORIGINS` | `http://localhost:3000` for now; add the Vercel URL in step 5 |

4. Click **Deploy web service**. Render passes the port in `$PORT`, which the image already uses. When the service
is **Live**, check it:
Expand All @@ -94,7 +95,33 @@ You create every account and paste every secret yourself. Nothing secret goes in
2. Render's free tier sleeps after 15 minutes without traffic and allows 750 instance hours a month, enough for one
service running all month. `/ping` never touches the database, so Neon still suspends between real visits.

## 5. Streamlit Community Cloud: the web app (interim UI)
## 5. Vercel: the web app

The site in `web/` is a Next.js app. Pages are rendered on Vercel and cached; the chat and the float charts call the
Render API straight from the visitor's browser, so each visitor's own IP counts against the rate limits and no
shared secret is needed.

1. Sign in at **https://vercel.com** with GitHub and click **Add New → Project**, then import this repository.
2. Settings:

| Setting | Value |
|---|---|
| Framework Preset | **Next.js** (detected) |
| Root Directory | `web` |
| Environment Variable | `NEXT_PUBLIC_API_URL` = `https://<your-service>.onrender.com` |

3. Click **Deploy**. When it finishes, note the production URL (for example `https://floatchat.vercel.app`).
4. In **Render → your service → Environment**, set `ALLOWED_ORIGINS` to that URL plus local development, comma
separated, and save (Render redeploys):

```text
https://floatchat.vercel.app,http://localhost:3000
```

5. Open the site and ask a question. The run log should fill in step by step and the answer should stream in.
If you later add a custom domain on Vercel, add it to `ALLOWED_ORIGINS` too.

## 6. Streamlit Community Cloud: the old interim UI (optional)

The Streamlit app (`app.py`) runs free on Streamlit Community Cloud and talks to the Render API. Streamlit's servers
call the API for every visitor, so the app passes each visitor's IP along with a shared secret (`FRONTEND_TOKEN`);
Expand Down Expand Up @@ -136,6 +163,7 @@ without it, all visitors would share one rate limit.
| LLM shows *missing or invalid LLM_API_KEY* | Re-paste the Groq key in Render's environment variables (it redeploys automatically) |
| The first request takes about a minute | The service went to sleep: check the cron-job.org job is enabled and succeeding |
| Daily refresh failed | Usually ERDDAP being slow or down; the site keeps serving the previous data. Re-run the workflow later |
| The web app shows *Couldn't reach the FloatChat API* | Check `NEXT_PUBLIC_API_URL` on Vercel (then redeploy) and that the Vercel URL is in Render's `ALLOWED_ORIGINS` |
| Streamlit app says it can't reach the API | Check `BACKEND_URL` in the app's **Settings → Secrets** (no trailing slash needed) and that the Render service is Live |
| Streamlit visitors hit "Too many questions" quickly | `FRONTEND_TOKEN` must be identical in Render and in the Streamlit secrets |
| Render's free tier changes | The API is a plain Docker image: any Docker host (Google Cloud Run, Hugging Face Spaces, Fly.io) works with the same environment variables |
99 changes: 99 additions & 0 deletions docs/superpowers/specs/2026-11-09-phase3-web-app-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Phase 3: Next.js web app

**Status:** design written to keep building overnight (user preference: build, then review). Everything below is
easy to change; the decisions most worth a look are marked ★.

## Goal

Replace the Streamlit stopgap with a fast, good-looking site a recruiter can open on a phone: a landing page that
explains how FloatChat works and lets you try it, a chat that shows its work live, and float pages with real
oceanography views. Architecture A stays: FastAPI owns all data and AI; the site only displays results.

## Decisions

- ★ **Where:** `web/` in this repo (one repo, one README). Vercel project root directory = `web`.
- **Stack:** Next.js (App Router, TypeScript, React Server Components), Tailwind CSS, Observable Plot for charts,
d3-geo + topojson for maps. No map tiles, no API keys, nothing paid.
- ★ **Calls go from the browser straight to the API** (`NEXT_PUBLIC_API_URL`). Each visitor's real IP reaches the
API's rate limiter through Cloudflare, so no proxy or shared token is needed, and SSE streams directly. Render's
`ALLOWED_ORIGINS` must include the Vercel domain. Landing-page numbers are fetched on the server with
revalidation (10 minutes), so the page renders instantly and costs the API almost nothing.
- **Benchmark score** comes from `eval/latest.json` on GitHub (raw URL), revalidated daily, so the site always shows
the latest weekly run without a redeploy.
- ★ **Look ("scientific instrument", not "AI product"):** the generic AI palette (purple/indigo glows, cyan on navy,
gradient text) is out. Instead:
- **Neutrals:** graphite. Dark `#0d0e10` → `#16181b` surfaces, light `#f3f4f2` → `#ffffff`; hairline borders.
Both themes, following the system setting, with a toggle.
- **One UI accent: marine safety orange** (`#ff5b1f` dark / `#e2470f` light), the colour of buoys and ocean
instruments. Used only for the primary action, focus, live state and the active step.
- **Data colours are the oceanographers' own:** cmocean `thermal` (temperature), `haline` (salinity), `oxy`
(oxygen), `deep` (depth), exported from the cmocean package. UI accent and data colours never mix.
- **Type:** Geist for text, Geist Mono for numbers, SQL and the run log. Tabular numerals everywhere data appears.
- **Shape:** cards 14 px, inputs and buttons full pill, chips 8 px. Icons from Phosphor (one weight).
- **Motion (dial 5):** spring entrances, steps sliding into the run log as they happen, globe auto-rotation;
all of it off under `prefers-reduced-motion`.
- **Agent UI, from current agent-UX guidance** (visible tool calls with inputs/outputs, a chronological action log,
calibrated confidence instead of long reasoning prose, explicit stop/retry controls):
- each answer opens with a compact **run log**: one row per step (Understood, SQL attempt n, Search, Writing) with
a status icon, duration and an expandable detail (filters, SQL text, rows, matches);
- honest signals are first-class: "keyword fallback" when the LLM router wasn't used, "repaired SQL" when a retry
happened, "no exact match, widened" when filters found nothing;
- **Stop** cancels the stream; **Retry** re-asks; **Copy SQL**; suggested follow-ups as chips.
- Light/dark contrast WCAG AA. Works at 360 px width.

## Pages

### `/` Landing
1. **Hero:** "Ask the Indian Ocean anything" over a rotating globe (canvas, orthographic d3-geo projection, drag to
spin) with every active float as a dot coloured by surface temperature; click a float to open its page.
Live counters: floats, profiles, measurements, newest data date.
2. **Try it:** a question box plus sample-question chips; submitting opens `/chat?q=…` and starts streaming.
3. **How it works:** a scroll-through of five steps with small diagrams: Argo floats dive → nightly pipeline (ERDDAP,
QC, 24 standard depths, descriptions, embeddings) → Postgres + pgvector → router (LLM with keyword fallback) picks
SQL or search and filters → answer with the step trace. Each step names the real component.
4. **Proof:** benchmark card (questions fully correct, routing / filters / answer checks, median latency) linking to
the per-question table; stack badges; GitHub link.

### `/chat`
- Streams `POST /query/stream` with `fetch` + a `ReadableStream` SSE parser (EventSource can't POST).
- **Live step trace** above each answer: Understood (router, path, analysis, filters as chips) → SQL attempt(s) with
rows and ms, or Search with match count → Writing. Steps appear as events arrive; the answer text streams in.
- Result views: per-analysis chart (depth profile lines, region bars, depth-layer bars, monthly lines, top floats,
BGC by zone), the SQL (collapsible, monospace), the data table; for searches, matching profiles as cards with a
mini map and links to float pages.
- Follow-ups: the last 3 answered turns go along as `history`. Clear 429 / network / cold-start messages.

### `/floats/[id]`
- Summary (WMO id, profiles, first/last date, sensors, region), trajectory map, latest profile (temperature,
salinity, oxygen vs depth), **T-S diagram** (all profiles, dots coloured by depth), **depth-time section**
(temperature as a heatmap, date × depth). Built from `/float/{id}`, `/profiles/float/{id}` and `/get_profiles`.

### `/compare?floats=a,b`
- Two floats side by side: overlaid T-S diagrams and mean temperature/salinity profiles. Entry from a float page.

## API changes

None planned; the existing endpoints cover every view. If a view needs a cheaper query (e.g. a float's full
depth-time grid in one call) it is added as a read-only endpoint with a test.

## Testing

- Vitest unit tests for the SSE parser, the step-trace reducer and the chart data shaping.
- CI job: `npm ci`, lint, type-check, unit tests, `next build`.
- Manual check in the browser pane against the live API (desktop and 375 px), with screenshots for the PR.

## Deployment (user steps, documented in DEPLOY.md)

Import the repo on Vercel with root directory `web`, set `NEXT_PUBLIC_API_URL`, then add the Vercel domain to
`ALLOWED_ORIGINS` on Render.

## Non-goals

Accounts, server-side chat history, retiring Streamlit and the launch video (Phase 4).

## Done criteria

- [ ] Landing page live on Vercel with real numbers, globe, how-it-works and the benchmark score.
- [ ] Chat streams steps and tokens from the live API, follow-ups work, charts render per analysis.
- [ ] Float page shows trajectory, T-S diagram and depth-time section; compare works for two floats.
- [ ] CI builds and tests the web app; Lighthouse performance and accessibility ≥ 90 on the landing page.
5 changes: 4 additions & 1 deletion floatchat/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ def chat_stream(prompt: str, system: str | None = None, temperature: float = 0.2
return
try:
resp = _post(_payload(prompt, system, temperature, max_tokens, json_mode=False, stream=True), stream=True)
for line in resp.iter_lines(decode_unicode=True):
# Read bytes and decode as UTF-8: event streams often have no charset, and requests would then fall back to
# ISO-8859-1 and garble characters such as "µ" or narrow spaces
for raw in resp.iter_lines():
line = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
if not line or not line.startswith("data: "):
continue
data = line[len("data: "):]
Expand Down
34 changes: 29 additions & 5 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,22 @@ Because the demo is public, the API limits each visitor to 10 questions a minute
day (falling back to data-only answers when the budget runs out), and caches answers until new data arrives.
Interactive API docs are at `http://localhost:8000/docs` while the backend is running.

### 5. Frontend: the Streamlit app (`app.py`)
### 5. Frontend: the web app (`web/`)

A Next.js site (TypeScript, Tailwind, Observable Plot, d3-geo) deployed on Vercel. It only displays results; all data
and AI logic stays in the API.

- **Landing page:** a live globe of every float coloured by surface temperature, the dataset's numbers, sample
questions, a scroll-through of how an answer is made, and the latest benchmark score.
- **Chat:** streams `/query/stream`. Each answer opens with a **run log** (what the router understood, each SQL
attempt with rows and timing, the search, who wrote the text), then the answer as it is written, then a chart,
table and SQL, or the matching profiles on a map. Follow-ups, Stop and Retry work.
- **Float pages:** trajectory, latest profile, a depth-time section (temperature, salinity or oxygen) and a
temperature-salinity diagram; a comparison page overlays two floats.
- Colours for data are the oceanographers' own cmocean scales; light and dark themes follow the system setting.

### 5b. The interim Streamlit app (`app.py`)


- **Overview page:** headline numbers, a live map of every float coloured by surface temperature, profiles per
region, and a **float explorer**. Click any float on the map to see its trajectory and latest depth profiles.
Expand All @@ -155,14 +170,15 @@ Interactive API docs are at `http://localhost:8000/docs` while the backend is ru
| `docker-compose.yml` | Runs PostgreSQL + pgvector, the API (which loads the data on first start) and the frontend. |
| `.github/workflows/` | `ci.yml` (lint, tests, container memory check), `refresh.yml` (daily data), `backfill.yml` (first load), `eval.yml` (weekly benchmark against the live API). |
| `eval/` | 30 benchmark questions and the runner that scores the API (see [How well it works](#-how-well-it-works)). |
| `DEPLOY.md` | Step-by-step free deployment: Neon, Render, GitHub Actions, cron-job.org and Groq. |
| `DEPLOY.md` | Step-by-step free deployment: Neon, Render, Vercel, GitHub Actions, cron-job.org and Groq. |
| `tests/` | Unit tests, plus integration tests against a real Postgres + pgvector (`tests/integration`). |

### Repository layout

```
FloatChat-AI/
├── app.py # Streamlit frontend
├── web/ # Next.js web app (landing, chat, float pages)
├── app.py # interim Streamlit frontend
├── api/ # FastAPI app: endpoints, rate limits, caches
├── floatchat/ # core: SQL agent, search, answers, embedder, LLM client, database
├── pipeline/ # ERDDAP → standard depths → descriptions/embeddings → Postgres
Expand Down Expand Up @@ -288,15 +304,23 @@ docker compose up -d db # PostgreSQL + pgvector on port 5433
python -m pipeline snapshot # load the bundled data (~30 s)
python -m pipeline create-reader # the API's read-only role (password from READER_PASSWORD)
uvicorn api.main:app --port 8000 # terminal 1: API
streamlit run app.py # terminal 2: frontend → http://localhost:8501
```

Then start the web app in a second terminal (Node.js 20.9+):

```bash
cd web
npm install
echo NEXT_PUBLIC_API_URL=http://localhost:8000 > .env.local
npm run dev # → http://localhost:3000
```

Run the tests with `pytest` (integration tests use the `db` container and are skipped if it isn't running),
and lint with `ruff check .`.

### Deploying your own copy

See [DEPLOY.md](DEPLOY.md): Neon (database), Render (API), Streamlit Community Cloud (web app), GitHub Actions
See [DEPLOY.md](DEPLOY.md): Neon (database), Render (API), Vercel (web app), GitHub Actions
(daily data), cron-job.org (keep-awake) and Groq (LLM), all on free tiers.

### Troubleshooting
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ def test_stream_yields_tokens(monkeypatch):
assert sent[0]["stream"] is True


def test_stream_decodes_utf8_bytes(monkeypatch):
# Event streams usually carry no charset; the bytes must still be read as UTF-8
text = "O₂ minimum 0.8 µmol/kg"
line = ("data: " + json.dumps({"choices": [{"delta": {"content": text}}]}, ensure_ascii=False)).encode("utf-8")
install(monkeypatch, [FakeResponse(lines=[line, b"data: [DONE]"])])
assert "".join(llm.chat_stream("q")) == text


def test_stream_yields_nothing_without_an_llm(monkeypatch):
monkeypatch.setattr(config, "LLM_BASE_URL", "")
assert list(llm.chat_stream("q")) == []
2 changes: 2 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# The FloatChat API the site talks to (the browser calls it directly)
NEXT_PUBLIC_API_URL=https://floatchat-ai-svjw.onrender.com
7 changes: 7 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
.next/
out/
next-env.d.ts
*.tsbuildinfo
.env*.local
.vercel
32 changes: 32 additions & 0 deletions web/app/(site)/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { CompareView } from "@/components/compare-view";
import { getFloats } from "@/lib/api";

export const metadata: Metadata = {
title: "Compare floats",
description: "Overlay two Argo floats' trajectories, T-S diagrams and mean profiles.",
};

export default async function ComparePage({ searchParams }: { searchParams: Promise<{ floats?: string }> }) {
const { floats: param } = await searchParams;
const floats = await getFloats().catch(() => []);
const ids = floats.sort((a, b) => b.n_profiles - a.n_profiles).map((f) => f.float_id);
const initial = (param ?? "").split(",").filter((id) => ids.includes(id)).slice(0, 2);
// Suggest a contrasting second float: the most active one from another region
if (initial.length === 1) {
const region = floats.find((f) => f.float_id === initial[0])?.region;
const other = floats.find((f) => f.region !== region);
if (other) initial.push(other.float_id);
}
return (
<div className="mx-auto max-w-[1240px] px-4 pb-20 pt-10 sm:px-6">
<h1 className="text-4xl font-semibold tracking-tighter md:text-5xl">Compare floats</h1>
<p className="mt-3 max-w-[60ch] leading-relaxed text-ink-2">
Two floats, side by side: where they drifted, the water they sampled and how their profiles differ.
</p>
<div className="mt-10">
<CompareView ids={ids} initial={initial} />
</div>
</div>
);
}
Loading
Loading