diff --git a/.dockerignore b/.dockerignore index 3524b3d..7abf076 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,4 @@ chroma_db legacy tests docs -components -styles -utils +web diff --git a/DEPLOY.md b/DEPLOY.md index 9553102..9a84ba1 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -62,7 +62,7 @@ You create every account and paste every secret yourself. Nothing secret goes in | Branch | `main` | | Region | **Frankfurt (EU Central)** | | Instance type | **Free** | - | Dockerfile Path (Advanced) | `Dockerfile.api` (the default `./Dockerfile` builds the Streamlit app instead) | + | Dockerfile Path (Advanced) | `Dockerfile.api` | | Docker Build Context | `.` | | Health Check Path (Advanced) | `/ping` | | Auto-Deploy | On Commit | @@ -140,7 +140,7 @@ without it, all visitors would share one rate limit. |---|---| | Repository | this repository | | Branch | `main` | - | Main file path | `app.py` | + | Main file path | `legacy/streamlit/app.py` | | App URL | a short name, e.g. `floatchat` | 4. Open **Advanced settings**: choose Python **3.11** or newer, and paste into **Secrets**: @@ -150,7 +150,7 @@ without it, all visitors would share one rate limit. FRONTEND_TOKEN = "" ``` -5. Click **Deploy**. Streamlit installs `requirements.txt` (frontend packages only) and starts the app in a few +5. Click **Deploy**. Streamlit installs `legacy/streamlit/requirements.txt` (frontend packages only) and starts the app in a few minutes. The sidebar should show green dots for PostgreSQL, the vector index and the LLM. ## Troubleshooting diff --git a/components/__pycache__/__init__.cpython-313.pyc b/components/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e465ca9..0000000 Binary files a/components/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/api_client.cpython-313.pyc b/components/__pycache__/api_client.cpython-313.pyc deleted file mode 100644 index 2925a17..0000000 Binary files a/components/__pycache__/api_client.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/chat_interface.cpython-313.pyc b/components/__pycache__/chat_interface.cpython-313.pyc deleted file mode 100644 index 5c0d86f..0000000 Binary files a/components/__pycache__/chat_interface.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/connection_monitor.cpython-313.pyc b/components/__pycache__/connection_monitor.cpython-313.pyc deleted file mode 100644 index 9126fe4..0000000 Binary files a/components/__pycache__/connection_monitor.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/data_fetcher.cpython-313.pyc b/components/__pycache__/data_fetcher.cpython-313.pyc deleted file mode 100644 index 274a127..0000000 Binary files a/components/__pycache__/data_fetcher.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/data_manager.cpython-313.pyc b/components/__pycache__/data_manager.cpython-313.pyc deleted file mode 100644 index 150e5d5..0000000 Binary files a/components/__pycache__/data_manager.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/data_sampler.cpython-313.pyc b/components/__pycache__/data_sampler.cpython-313.pyc deleted file mode 100644 index ab25eb2..0000000 Binary files a/components/__pycache__/data_sampler.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/data_transformer.cpython-313.pyc b/components/__pycache__/data_transformer.cpython-313.pyc deleted file mode 100644 index 260357b..0000000 Binary files a/components/__pycache__/data_transformer.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/error_handler.cpython-313.pyc b/components/__pycache__/error_handler.cpython-313.pyc deleted file mode 100644 index 52b6ac7..0000000 Binary files a/components/__pycache__/error_handler.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/error_system_integration.cpython-313.pyc b/components/__pycache__/error_system_integration.cpython-313.pyc deleted file mode 100644 index 714e818..0000000 Binary files a/components/__pycache__/error_system_integration.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/export_manager.cpython-313.pyc b/components/__pycache__/export_manager.cpython-313.pyc deleted file mode 100644 index 414bd64..0000000 Binary files a/components/__pycache__/export_manager.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/layout_manager.cpython-313.pyc b/components/__pycache__/layout_manager.cpython-313.pyc deleted file mode 100644 index 4d7867f..0000000 Binary files a/components/__pycache__/layout_manager.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/map_visualization.cpython-313.pyc b/components/__pycache__/map_visualization.cpython-313.pyc deleted file mode 100644 index 4fc1a26..0000000 Binary files a/components/__pycache__/map_visualization.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/performance_optimizer.cpython-313.pyc b/components/__pycache__/performance_optimizer.cpython-313.pyc deleted file mode 100644 index 427f26d..0000000 Binary files a/components/__pycache__/performance_optimizer.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/profile_visualizer.cpython-313.pyc b/components/__pycache__/profile_visualizer.cpython-313.pyc deleted file mode 100644 index 6f788d5..0000000 Binary files a/components/__pycache__/profile_visualizer.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/statistics_manager.cpython-313.pyc b/components/__pycache__/statistics_manager.cpython-313.pyc deleted file mode 100644 index d6b4fd9..0000000 Binary files a/components/__pycache__/statistics_manager.cpython-313.pyc and /dev/null differ diff --git a/components/__pycache__/user_feedback.cpython-313.pyc b/components/__pycache__/user_feedback.cpython-313.pyc deleted file mode 100644 index 96aef5b..0000000 Binary files a/components/__pycache__/user_feedback.cpython-313.pyc and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index 7b0e8e9..8f14a6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ # One-command local stack: docker compose up --build -# App: http://localhost:8501 API docs: http://localhost:8000/docs +# Web app: http://localhost:3000 API docs: http://localhost:8000/docs services: db: image: pgvector/pgvector:pg16 @@ -32,7 +32,7 @@ services: ADMIN_DATABASE_URL: postgresql+psycopg://floatchat:floatchat@db:5432/argo DATABASE_URL: postgresql+psycopg://floatchat_reader:local-reader-password@db:5432/argo READER_PASSWORD: local-reader-password - ALLOWED_ORIGINS: http://localhost:8501,http://localhost:3000 + ALLOWED_ORIGINS: http://localhost:3000 ports: - "8000:8000" extra_hosts: @@ -41,12 +41,15 @@ services: db: condition: service_healthy - frontend: - build: . + web: + build: + context: ./web + args: + NEXT_PUBLIC_API_URL: http://localhost:8000 # what your browser uses environment: - BACKEND_URL: http://backend:8000 + API_URL_INTERNAL: http://backend:8000 # what the web server uses inside Docker ports: - - "8501:8501" + - "3000:3000" depends_on: - backend diff --git a/docs/screenshots/web-chat-search.png b/docs/screenshots/web-chat-search.png new file mode 100644 index 0000000..2fd0fcc Binary files /dev/null and b/docs/screenshots/web-chat-search.png differ diff --git a/docs/screenshots/web-chat-sql.png b/docs/screenshots/web-chat-sql.png new file mode 100644 index 0000000..4615cc1 Binary files /dev/null and b/docs/screenshots/web-chat-sql.png differ diff --git a/docs/screenshots/web-float.png b/docs/screenshots/web-float.png new file mode 100644 index 0000000..0f38f73 Binary files /dev/null and b/docs/screenshots/web-float.png differ diff --git a/docs/screenshots/web-landing.png b/docs/screenshots/web-landing.png new file mode 100644 index 0000000..c7030b2 Binary files /dev/null and b/docs/screenshots/web-landing.png differ diff --git a/floatchat/answer.py b/floatchat/answer.py index c6bca77..6d362bb 100644 --- a/floatchat/answer.py +++ b/floatchat/answer.py @@ -135,7 +135,8 @@ def _analytical_answer(route: Route, agent: SqlAgent, writer: _Writer, step) -> writer.write(f"**{title}**\n\n{format_results_for_display(df, intent)}") insight = writer.llm( f"Question: {route.question}\n\nSQL result (first rows):\n{df.head(15).to_string(index=False)}\n\n" - "In 2-3 sentences, explain the main oceanographic pattern in this result.", + "In 2-3 sentences of plain prose, explain the main oceanographic pattern in this result. Do not use a list, " + "table or heading, and do not restate the rows one by one.", prefix="\n\n**What this means:** ", ) return { diff --git a/floatchat/config.py b/floatchat/config.py index d8e7c0f..b1f1ecc 100644 --- a/floatchat/config.py +++ b/floatchat/config.py @@ -36,7 +36,7 @@ def _sqlalchemy_url(value: str) -> str: FRONTEND_TOKEN = os.getenv("FRONTEND_TOKEN", "") ALLOWED_ORIGINS = [ origin.strip() - for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8501").split(",") + for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",") if origin.strip() ] diff --git a/floatchat/llm.py b/floatchat/llm.py index 12ff52a..2bb1234 100644 --- a/floatchat/llm.py +++ b/floatchat/llm.py @@ -27,6 +27,7 @@ def is_configured() -> bool: return True +# Reasoning models (such as gpt-oss) spend part of max_tokens thinking before they answer, so the limits leave room def _payload(prompt: str, system: str | None, temperature: float, max_tokens: int, json_mode: bool, stream: bool = False) -> dict: messages = [{"role": "system", "content": system}] if system else [] @@ -58,7 +59,7 @@ def _post(payload: dict, stream: bool = False): _sleep(min(wait, MAX_RETRY_WAIT)) -def chat(prompt: str, system: str | None = None, temperature: float = 0.2, max_tokens: int = 600, +def chat(prompt: str, system: str | None = None, temperature: float = 0.2, max_tokens: int = 1200, json_mode: bool = False) -> str | None: if not is_configured(): return None @@ -71,7 +72,7 @@ def chat(prompt: str, system: str | None = None, temperature: float = 0.2, max_t def chat_stream(prompt: str, system: str | None = None, temperature: float = 0.2, - max_tokens: int = 600) -> Iterator[str]: + max_tokens: int = 1200) -> Iterator[str]: """Yield the answer text as the LLM writes it; yields nothing if no LLM is available or the call fails.""" if not is_configured(): return diff --git a/floatchat/router.py b/floatchat/router.py index 36b6568..6dcf8db 100644 --- a/floatchat/router.py +++ b/floatchat/router.py @@ -74,7 +74,7 @@ def _prompt(question: str, history: list[dict]) -> str: def llm_route(question: str, history: list[dict], chat) -> Route | None: - reply = chat(_prompt(question, history or []), temperature=0.0, max_tokens=300, json_mode=True) + reply = chat(_prompt(question, history or []), temperature=0.0, max_tokens=600, json_mode=True) if not reply: return None try: diff --git a/.streamlit/config.toml b/legacy/streamlit/.streamlit/config.toml similarity index 100% rename from .streamlit/config.toml rename to legacy/streamlit/.streamlit/config.toml diff --git a/Dockerfile b/legacy/streamlit/Dockerfile similarity index 100% rename from Dockerfile rename to legacy/streamlit/Dockerfile diff --git a/legacy/streamlit/README.md b/legacy/streamlit/README.md new file mode 100644 index 0000000..4b250bf --- /dev/null +++ b/legacy/streamlit/README.md @@ -0,0 +1,15 @@ +# Retired Streamlit apps + +FloatChat's frontend is now the Next.js app in [`web/`](../../web). These are kept for reference: + +- `app.py`: the interim Streamlit app that served as the showcase before the web app (Phases 1 and 2). It still works + against the API: + + ```bash + pip install -r legacy/streamlit/requirements.txt + BACKEND_URL=http://localhost:8000 streamlit run legacy/streamlit/app.py + ``` + + On a hosted deployment it passes each visitor's IP with a shared `FRONTEND_TOKEN`, which the API still accepts. +- `streamlit_app.py`, `components/`, `styles/`, `utils/`: an earlier experimental dashboard + (see `README_DASHBOARD.md`), with its tests in `tests/dashboard/`. diff --git a/README_DASHBOARD.md b/legacy/streamlit/README_DASHBOARD.md similarity index 100% rename from README_DASHBOARD.md rename to legacy/streamlit/README_DASHBOARD.md diff --git a/app.py b/legacy/streamlit/app.py similarity index 100% rename from app.py rename to legacy/streamlit/app.py diff --git a/components/__init__.py b/legacy/streamlit/components/__init__.py similarity index 100% rename from components/__init__.py rename to legacy/streamlit/components/__init__.py diff --git a/components/api_client.py b/legacy/streamlit/components/api_client.py similarity index 100% rename from components/api_client.py rename to legacy/streamlit/components/api_client.py diff --git a/components/chat_interface.py b/legacy/streamlit/components/chat_interface.py similarity index 100% rename from components/chat_interface.py rename to legacy/streamlit/components/chat_interface.py diff --git a/components/connection_monitor.py b/legacy/streamlit/components/connection_monitor.py similarity index 100% rename from components/connection_monitor.py rename to legacy/streamlit/components/connection_monitor.py diff --git a/components/data_fetcher.py b/legacy/streamlit/components/data_fetcher.py similarity index 100% rename from components/data_fetcher.py rename to legacy/streamlit/components/data_fetcher.py diff --git a/components/data_manager.py b/legacy/streamlit/components/data_manager.py similarity index 100% rename from components/data_manager.py rename to legacy/streamlit/components/data_manager.py diff --git a/components/data_sampler.py b/legacy/streamlit/components/data_sampler.py similarity index 100% rename from components/data_sampler.py rename to legacy/streamlit/components/data_sampler.py diff --git a/components/data_transformer.py b/legacy/streamlit/components/data_transformer.py similarity index 100% rename from components/data_transformer.py rename to legacy/streamlit/components/data_transformer.py diff --git a/components/error_handler.py b/legacy/streamlit/components/error_handler.py similarity index 100% rename from components/error_handler.py rename to legacy/streamlit/components/error_handler.py diff --git a/components/error_system_integration.py b/legacy/streamlit/components/error_system_integration.py similarity index 100% rename from components/error_system_integration.py rename to legacy/streamlit/components/error_system_integration.py diff --git a/components/export_manager.py b/legacy/streamlit/components/export_manager.py similarity index 100% rename from components/export_manager.py rename to legacy/streamlit/components/export_manager.py diff --git a/components/layout_manager.py b/legacy/streamlit/components/layout_manager.py similarity index 100% rename from components/layout_manager.py rename to legacy/streamlit/components/layout_manager.py diff --git a/components/map_visualization.py b/legacy/streamlit/components/map_visualization.py similarity index 100% rename from components/map_visualization.py rename to legacy/streamlit/components/map_visualization.py diff --git a/components/performance_integration.py b/legacy/streamlit/components/performance_integration.py similarity index 100% rename from components/performance_integration.py rename to legacy/streamlit/components/performance_integration.py diff --git a/components/performance_optimizer.py b/legacy/streamlit/components/performance_optimizer.py similarity index 100% rename from components/performance_optimizer.py rename to legacy/streamlit/components/performance_optimizer.py diff --git a/components/profile_visualizer.py b/legacy/streamlit/components/profile_visualizer.py similarity index 100% rename from components/profile_visualizer.py rename to legacy/streamlit/components/profile_visualizer.py diff --git a/components/statistics_manager.py b/legacy/streamlit/components/statistics_manager.py similarity index 100% rename from components/statistics_manager.py rename to legacy/streamlit/components/statistics_manager.py diff --git a/components/streamlit_cache.py b/legacy/streamlit/components/streamlit_cache.py similarity index 100% rename from components/streamlit_cache.py rename to legacy/streamlit/components/streamlit_cache.py diff --git a/components/user_feedback.py b/legacy/streamlit/components/user_feedback.py similarity index 100% rename from components/user_feedback.py rename to legacy/streamlit/components/user_feedback.py diff --git a/dashboard_config.py b/legacy/streamlit/dashboard_config.py similarity index 100% rename from dashboard_config.py rename to legacy/streamlit/dashboard_config.py diff --git a/requirements.txt b/legacy/streamlit/requirements.txt similarity index 100% rename from requirements.txt rename to legacy/streamlit/requirements.txt diff --git a/requirements_dashboard.txt b/legacy/streamlit/requirements_dashboard.txt similarity index 100% rename from requirements_dashboard.txt rename to legacy/streamlit/requirements_dashboard.txt diff --git a/docs/screenshots/chat-rag.png b/legacy/streamlit/screenshots/chat-rag.png similarity index 100% rename from docs/screenshots/chat-rag.png rename to legacy/streamlit/screenshots/chat-rag.png diff --git a/docs/screenshots/chat-sql.png b/legacy/streamlit/screenshots/chat-sql.png similarity index 100% rename from docs/screenshots/chat-sql.png rename to legacy/streamlit/screenshots/chat-sql.png diff --git a/docs/screenshots/overview.png b/legacy/streamlit/screenshots/overview.png similarity index 100% rename from docs/screenshots/overview.png rename to legacy/streamlit/screenshots/overview.png diff --git a/streamlit_app.py b/legacy/streamlit/streamlit_app.py similarity index 100% rename from streamlit_app.py rename to legacy/streamlit/streamlit_app.py diff --git a/styles/__init__.py b/legacy/streamlit/styles/__init__.py similarity index 100% rename from styles/__init__.py rename to legacy/streamlit/styles/__init__.py diff --git a/styles/government_theme.py b/legacy/streamlit/styles/government_theme.py similarity index 100% rename from styles/government_theme.py rename to legacy/streamlit/styles/government_theme.py diff --git a/tests/dashboard/tasks.md b/legacy/streamlit/tests/dashboard/tasks.md similarity index 100% rename from tests/dashboard/tasks.md rename to legacy/streamlit/tests/dashboard/tasks.md diff --git a/tests/dashboard/test_api_client.py b/legacy/streamlit/tests/dashboard/test_api_client.py similarity index 100% rename from tests/dashboard/test_api_client.py rename to legacy/streamlit/tests/dashboard/test_api_client.py diff --git a/tests/dashboard/test_backend_system.py b/legacy/streamlit/tests/dashboard/test_backend_system.py similarity index 100% rename from tests/dashboard/test_backend_system.py rename to legacy/streamlit/tests/dashboard/test_backend_system.py diff --git a/tests/dashboard/test_chat_component.py b/legacy/streamlit/tests/dashboard/test_chat_component.py similarity index 100% rename from tests/dashboard/test_chat_component.py rename to legacy/streamlit/tests/dashboard/test_chat_component.py diff --git a/tests/dashboard/test_chat_interface.py b/legacy/streamlit/tests/dashboard/test_chat_interface.py similarity index 100% rename from tests/dashboard/test_chat_interface.py rename to legacy/streamlit/tests/dashboard/test_chat_interface.py diff --git a/tests/dashboard/test_connection_monitor.py b/legacy/streamlit/tests/dashboard/test_connection_monitor.py similarity index 100% rename from tests/dashboard/test_connection_monitor.py rename to legacy/streamlit/tests/dashboard/test_connection_monitor.py diff --git a/tests/dashboard/test_dashboard_setup.py b/legacy/streamlit/tests/dashboard/test_dashboard_setup.py similarity index 100% rename from tests/dashboard/test_dashboard_setup.py rename to legacy/streamlit/tests/dashboard/test_dashboard_setup.py diff --git a/tests/dashboard/test_data_manager.py b/legacy/streamlit/tests/dashboard/test_data_manager.py similarity index 100% rename from tests/dashboard/test_data_manager.py rename to legacy/streamlit/tests/dashboard/test_data_manager.py diff --git a/tests/dashboard/test_data_manager_component.py b/legacy/streamlit/tests/dashboard/test_data_manager_component.py similarity index 100% rename from tests/dashboard/test_data_manager_component.py rename to legacy/streamlit/tests/dashboard/test_data_manager_component.py diff --git a/tests/dashboard/test_data_sampler.py b/legacy/streamlit/tests/dashboard/test_data_sampler.py similarity index 100% rename from tests/dashboard/test_data_sampler.py rename to legacy/streamlit/tests/dashboard/test_data_sampler.py diff --git a/tests/dashboard/test_error_handler.py b/legacy/streamlit/tests/dashboard/test_error_handler.py similarity index 100% rename from tests/dashboard/test_error_handler.py rename to legacy/streamlit/tests/dashboard/test_error_handler.py diff --git a/tests/dashboard/test_error_system_integration.py b/legacy/streamlit/tests/dashboard/test_error_system_integration.py similarity index 100% rename from tests/dashboard/test_error_system_integration.py rename to legacy/streamlit/tests/dashboard/test_error_system_integration.py diff --git a/tests/dashboard/test_export_component.py b/legacy/streamlit/tests/dashboard/test_export_component.py similarity index 100% rename from tests/dashboard/test_export_component.py rename to legacy/streamlit/tests/dashboard/test_export_component.py diff --git a/tests/dashboard/test_export_manager.py b/legacy/streamlit/tests/dashboard/test_export_manager.py similarity index 100% rename from tests/dashboard/test_export_manager.py rename to legacy/streamlit/tests/dashboard/test_export_manager.py diff --git a/tests/dashboard/test_layout.py b/legacy/streamlit/tests/dashboard/test_layout.py similarity index 100% rename from tests/dashboard/test_layout.py rename to legacy/streamlit/tests/dashboard/test_layout.py diff --git a/tests/dashboard/test_map_component.py b/legacy/streamlit/tests/dashboard/test_map_component.py similarity index 100% rename from tests/dashboard/test_map_component.py rename to legacy/streamlit/tests/dashboard/test_map_component.py diff --git a/tests/dashboard/test_map_visualization.py b/legacy/streamlit/tests/dashboard/test_map_visualization.py similarity index 100% rename from tests/dashboard/test_map_visualization.py rename to legacy/streamlit/tests/dashboard/test_map_visualization.py diff --git a/tests/dashboard/test_performance_optimizer.py b/legacy/streamlit/tests/dashboard/test_performance_optimizer.py similarity index 100% rename from tests/dashboard/test_performance_optimizer.py rename to legacy/streamlit/tests/dashboard/test_performance_optimizer.py diff --git a/tests/dashboard/test_profile_component.py b/legacy/streamlit/tests/dashboard/test_profile_component.py similarity index 100% rename from tests/dashboard/test_profile_component.py rename to legacy/streamlit/tests/dashboard/test_profile_component.py diff --git a/tests/dashboard/test_profile_visualizer.py b/legacy/streamlit/tests/dashboard/test_profile_visualizer.py similarity index 100% rename from tests/dashboard/test_profile_visualizer.py rename to legacy/streamlit/tests/dashboard/test_profile_visualizer.py diff --git a/tests/dashboard/test_query_direct.py b/legacy/streamlit/tests/dashboard/test_query_direct.py similarity index 100% rename from tests/dashboard/test_query_direct.py rename to legacy/streamlit/tests/dashboard/test_query_direct.py diff --git a/tests/dashboard/test_statistics_integration.py b/legacy/streamlit/tests/dashboard/test_statistics_integration.py similarity index 100% rename from tests/dashboard/test_statistics_integration.py rename to legacy/streamlit/tests/dashboard/test_statistics_integration.py diff --git a/tests/dashboard/test_statistics_manager.py b/legacy/streamlit/tests/dashboard/test_statistics_manager.py similarity index 100% rename from tests/dashboard/test_statistics_manager.py rename to legacy/streamlit/tests/dashboard/test_statistics_manager.py diff --git a/tests/dashboard/test_user_feedback.py b/legacy/streamlit/tests/dashboard/test_user_feedback.py similarity index 100% rename from tests/dashboard/test_user_feedback.py rename to legacy/streamlit/tests/dashboard/test_user_feedback.py diff --git a/utils/__init__.py b/legacy/streamlit/utils/__init__.py similarity index 100% rename from utils/__init__.py rename to legacy/streamlit/utils/__init__.py diff --git a/utils/dashboard_utils.py b/legacy/streamlit/utils/dashboard_utils.py similarity index 100% rename from utils/dashboard_utils.py rename to legacy/streamlit/utils/dashboard_utils.py diff --git a/pyproject.toml b/pyproject.toml index aa6a40a..bf43b90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.ruff] line-length = 120 target-version = "py311" -extend-exclude = ["legacy", "components", "styles", "utils", "tests/dashboard", "streamlit_app.py", "dashboard_config.py", "app.py"] +extend-exclude = ["legacy", "web"] [tool.ruff.lint] select = ["E", "F", "I", "B"] diff --git a/readme.md b/readme.md index 813fce3..daf63f0 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,12 @@ # 🌊 FloatChat: talk to the ocean -**Ask questions about real ocean data in plain English, and get answers with maps and charts.** +**Ask questions about a year of real ocean data in plain English. FloatChat writes the SQL or searches the +profiles, shows every step it took, and draws the charts.** -![FloatChat overview](docs/screenshots/overview.png) +![FloatChat landing page](docs/screenshots/web-landing.png) + +**Live API:** [floatchat-ai-svjw.onrender.com/docs](https://floatchat-ai-svjw.onrender.com/docs) · +**Benchmark:** 29/30 questions fully correct ([details](eval/latest.md)) · **Deploy your own:** [DEPLOY.md](DEPLOY.md) **Contents:** [What FloatChat is](#-what-floatchat-is) · [How it's built](#-how-its-built) · [How well it works](#-how-well-it-works) · [Run it on your machine](#-run-it-on-your-machine) · @@ -43,28 +47,37 @@ becoming data engineers. | You ask… | FloatChat… | |---|---| -| *"What is the average temperature at different depths?"* | spots a statistical question → runs SQL on PostgreSQL → answers with a temperature/salinity vs depth chart and the SQL it used | -| *"Show me salinity profiles near the equator in March 2024"* | spots a descriptive question → reads "equator" and "March 2024" as filters → searches its vector index of profile descriptions → shows the matching profiles on a map and plots them | -| *"Which floats measured low oxygen in the Arabian Sea?"* | limits the search to oxygen-measuring floats in the Arabian Sea → lists them with their oxygen minimum and depth | +| *"What is the average temperature at different depths in the Bay of Bengal?"* | the router sees a statistical question about one region → runs the depth-average SQL with a region filter → explains the pattern, with the chart, the data and the SQL | +| *"now only the Arabian Sea"* | understands a follow-up → keeps the analysis, swaps the region | +| *"Show me salinity profiles near the equator in March 2026"* | sees a descriptive question → reads "equator", "March" and "2026" as filters → searches its vector index of profile descriptions → shows the matching profiles on a map | +| *"How many profiles were recorded in the Arabian Sea?"* | no template fits → the LLM writes SQL, checked as read-only, and repaired from the database error if it fails | + +Every answer opens with a **run log**: what was understood, each SQL attempt with its rows and timing, the search, +and who wrote the text, so every number can be checked against the query that produced it. -| Statistical question → SQL + chart | Descriptive question → search + map & profiles | +| Statistical question → SQL, chart and data | Descriptive question → search and map | |---|---| -| ![SQL answer](docs/screenshots/chat-sql.png) | ![RAG answer](docs/screenshots/chat-rag.png) | +| ![SQL answer with run log](docs/screenshots/web-chat-sql.png) | ![Search answer with map](docs/screenshots/web-chat-search.png) | + +Each float has its own page: trajectory, latest profile, a depth-time section and a temperature-salinity diagram. + +![Float page](docs/screenshots/web-float.png) ### The data in this demo -The repo ships with **real Argo observations** from the **Indian Ocean, January–March 2024**, downloaded from the -[Ifremer ERDDAP](https://erddap.ifremer.fr/erddap/) server: +All data are **real Argo observations** from the **Indian Ocean**, downloaded from the +[Ifremer ERDDAP](https://erddap.ifremer.fr/erddap/) server, keeping only values that passed Argo's quality control: -- **437 floats**, **3,898 profiles**, **88,361 measurements** from the surface down to 2,000 m, keeping only values - that passed Argo's quality control -- **Biogeochemical (BGC) floats** that also measure dissolved oxygen (52 floats), chlorophyll (57), nitrate (32) - and pH (18). The oxygen data shows the Arabian Sea's oxygen minimum zone clearly. -- Each profile is labelled with a region: Arabian Sea, Bay of Bengal, Equatorial, Southern and Eastern Indian Ocean, - Red Sea & Gulf of Aden +| | Live demo | Bundled snapshot (local setup) | +|---|---|---| +| Period | rolling last 12 months, refreshed nightly | January to March 2024 | +| Floats | about 660 (107 measuring oxygen) | 437 (52 measuring oxygen) | +| Profiles | about 20,000 | 3,898 | +| Measurements | about 455,000 | 88,361 | -The **live demo** keeps a rolling **last 12 months** of the same region (about 660 floats and 20,000 profiles), -refreshed every night by GitHub Actions. You can download a different period or ocean region with one command (see +BGC floats also measure chlorophyll, nitrate and pH; the oxygen data shows the Arabian Sea's oxygen minimum zone +clearly. Each profile is labelled with a region: Arabian Sea, Bay of Bengal, Equatorial, Southern and Eastern Indian +Ocean, Red Sea & Gulf of Aden. You can download a different period or ocean region with one command (see [Using other data](#using-other-data)). --- @@ -73,9 +86,9 @@ refreshed every night by GitHub Actions. You can download a different period or ``` ┌─────────────┐ HTTP ┌──────────────── API (FastAPI) ─────────────────────────────────────┐ - │ Streamlit │ ─────► │ POST /query, /query/stream (live steps + tokens) │ - │ (app.py) │ ◄───── │ router (LLM, keyword fallback): SQL or search? which filters? │ - └─────────────┘ JSON │ ├─ SQL: template or LLM-written SQL, repaired from DB errors ─► PG │ + │ Next.js │ ─────► │ POST /query, /query/stream (live steps + tokens) │ + │ (web/) │ ◄───── │ router (LLM, keyword fallback): SQL or search? which filters? │ + └─ Vercel ────┘ JSON │ ├─ SQL: template or LLM-written SQL, repaired from DB errors ─► PG │ / SSE │ └─ search: pgvector + filters ─► matching profiles │ │ LLM (e.g. Groq) explains the result · every step is reported │ │ rate limits · LLM budget · answer cache · query log │ @@ -148,7 +161,10 @@ and AI logic stays in the API. 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`) +The Streamlit app that served as the interim UI is retired to `legacy/streamlit/` (it still runs; see its README). + +
+What the interim Streamlit app did - **Overview page:** headline numbers, a live map of every float coloured by surface temperature, profiles per @@ -162,12 +178,14 @@ and AI logic stays in the API. - Follow-up questions work: the last three turns are sent along, so *"now only the Arabian Sea"* keeps the analysis. - The sidebar shows whether the database, vector index and LLM are connected. +
+ ### 6. Packaging, deployment and tests | Component | What it does | |---|---| -| `Dockerfile.api`, `Dockerfile` | The API image (with the embedding model built in) and the Streamlit image. | -| `docker-compose.yml` | Runs PostgreSQL + pgvector, the API (which loads the data on first start) and the frontend. | +| `Dockerfile.api`, `web/Dockerfile` | The API image (with the embedding model built in) and the web app image (Next.js standalone server). | +| `docker-compose.yml` | Runs PostgreSQL + pgvector, the API (which loads the data on first start) and the web app. | | `.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, Vercel, GitHub Actions, cron-job.org and Groq. | @@ -178,7 +196,6 @@ and AI logic stays in the API. ``` FloatChat-AI/ ├── 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 @@ -187,9 +204,8 @@ FloatChat-AI/ ├── data/ # bundled Argo snapshot + embedding cache ├── tests/ # unit/ and integration/ ├── docs/ # screenshots, design specs and plans -├── Dockerfile.api, Dockerfile, docker-compose.yml, requirements*.txt, .env.example, DEPLOY.md -├── streamlit_app.py, components/, styles/, utils/ # experimental dashboard (WIP, see README_DASHBOARD.md) -└── legacy/ # early prototype scripts, kept for reference +├── Dockerfile.api, docker-compose.yml, requirements*.txt, .env.example, DEPLOY.md +└── legacy/ # the retired Streamlit apps and early prototype scripts, kept for reference ``` --- @@ -234,7 +250,7 @@ full per-question table is in [`eval/latest.md`](eval/latest.md). - *(optional)* a free **[Groq API key](https://console.groq.com/keys)**, so the AI can write explanations You don't need to download any data, models or databases by hand; Docker fetches everything. The app uses ports -**8501** (app), **8000** (API) and **5433** (database). +**3000** (web app), **8000** (API) and **5433** (database). ### Option A: Docker (recommended) @@ -271,16 +287,16 @@ retired that model, choose any chat model listed in the Groq console. docker compose up --build ``` -The first run takes a few minutes while Docker downloads Python packages and builds the image. The backend then -loads the data in about 30 seconds. You're ready when the log shows `Uvicorn running on http://0.0.0.0:8000`. +The first run takes a few minutes while Docker downloads packages and builds the images. The backend then loads the +data in about 30 seconds. You're ready when the log shows `Uvicorn running on http://0.0.0.0:8000`. **4. Open the app** -- App: **http://localhost:8501** +- Web app: **http://localhost:3000** - API docs: http://localhost:8000/docs -The sidebar should show green dots for PostgreSQL and the vector index. The LLM dot is green if your key works and -amber if no LLM is configured. +Ask a question. Without an LLM key the run log says *keyword fallback* and the answer is built from the data alone; +with a key, the router and the explanations come from the LLM. **Stopping and restarting** @@ -297,7 +313,7 @@ which listens on port 5433 and matches the settings in `.env.example`. ```bash python -m venv .venv .venv\Scripts\activate # macOS/Linux: source .venv/bin/activate -pip install -r requirements-dev.txt # API, frontend and test tools +pip install -r requirements-dev.txt # API and test tools cp .env.example .env # add your LLM key if you have one docker compose up -d db # PostgreSQL + pgvector on port 5433 @@ -327,10 +343,10 @@ See [DEPLOY.md](DEPLOY.md): Neon (database), Render (API), Vercel (web app), Git | Problem | Fix | |---|---| -| `port is already allocated` | Something else is using 8501, 8000 or 5433. Stop it, or change the left-hand port in `docker-compose.yml`. | +| `port is already allocated` | Something else is using 3000, 8000 or 5433. Stop it, or change the left-hand port in `docker-compose.yml`. | | App says it can't reach the API | The backend is still loading data; wait for `Uvicorn running…` in the logs (`docker compose logs -f backend`). | -| LLM dot is red: *missing or invalid LLM_API_KEY* | Check the key in `.env`, then run `docker compose up -d backend`. | -| LLM dot is red: *cannot connect* | Check `LLM_BASE_URL`. If you use Ollama on your own machine while the app runs in Docker, use `http://host.docker.internal:11434/v1`. | +| The run log always says *keyword fallback* | The LLM isn't reachable: `http://localhost:8000/health` shows why. Check the key in `.env`, then run `docker compose up -d backend`. | +| `/health` says *cannot connect* to the LLM | Check `LLM_BASE_URL`. If you use Ollama on your own machine while the app runs in Docker, use `http://host.docker.internal:11434/v1`. | | You want a clean start | `docker compose down -v`, then `docker compose up --build`. | --- @@ -342,7 +358,7 @@ Questions to try: - What is the average temperature at different depths? - Compare surface temperature across ocean regions - Show average salinity by depth range -- Show me salinity profiles near the equator in March 2024 +- Show me salinity profiles near the equator in March 2026 (March 2024 on the bundled snapshot) - Which floats measured low oxygen in the Arabian Sea? - Average dissolved oxygen by ocean zone - Show the monthly temperature trend @@ -350,11 +366,12 @@ Questions to try: Tips: -- Words like *average, compare, trend, count, summary* trigger the SQL path. Descriptive questions search the - profiles; naming a region, month or "oxygen" narrows the search. -- **Demo links:** `http://localhost:8501/?q=Average%20dissolved%20oxygen%20by%20ocean%20zone` opens the chat with that - question already asked. They're handy in presentations. -- The **Data & export** tab under an answer downloads its profiles as CSV, NetCDF or ASCII. +- Statistics (averages, comparisons, trends, counts) go to SQL; questions about particular profiles or floats go to + search. Naming a region, month, year or sensor narrows either one. +- Follow-ups work: *"now only the Bay of Bengal"*, *"and just in March"*, *"only the ones measuring oxygen"*. +- **Demo links:** `/chat?q=Average%20dissolved%20oxygen%20by%20ocean%20zone` opens the chat with that question + already asked. They're handy in presentations. +- Search results and float pages download their profiles as CSV, NetCDF or ASCII. ### Using other data @@ -373,12 +390,13 @@ New data needs new embeddings, which takes a few minutes on a CPU. The region fi ## 🗺️ Limitations & roadmap - The bundled local data is a three-month snapshot; the deployed version keeps a rolling 12 months, refreshed daily. -- Without an LLM, deciding between SQL and search falls back to keywords, which miss free phrasings (see the evaluation). -- Next: a custom web app (Next.js) with a landing page that streams each answer's steps live from `/query/stream`. -- Region boundaries are approximate boxes. -- `streamlit_app.py` and `components/` are an experimental, feature-heavy dashboard that still uses mock data in - places. They are not part of the demo (see `README_DASHBOARD.md`). -- `legacy/` holds early prototype scripts that are no longer used. +- Without an LLM, deciding between SQL and search falls back to keywords, which miss free phrasings (see the + evaluation). The benchmark's one remaining miss is a borderline question routed to SQL instead of search. +- Region boundaries are approximate boxes, and the data covers the Indian Ocean only. +- Free hosting: the API sleeps when idle (a keep-awake ping covers this) and the LLM has a daily budget, after which + answers are built from the data alone. +- Ideas: more ocean basins, comparing a float with the regional average, and saved conversations. +- `legacy/` holds the retired Streamlit apps and early prototype scripts. ## 🙏 Acknowledgments diff --git a/requirements-dev.txt b/requirements-dev.txt index e040dd3..909895d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,4 @@ -r requirements-api.txt --r requirements.txt pytest>=8 ruff==0.17.4 httpx==0.28.1 diff --git a/styles/__pycache__/__init__.cpython-313.pyc b/styles/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 4019518..0000000 Binary files a/styles/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/styles/__pycache__/government_theme.cpython-313.pyc b/styles/__pycache__/government_theme.cpython-313.pyc deleted file mode 100644 index 05d9960..0000000 Binary files a/styles/__pycache__/government_theme.cpython-313.pyc and /dev/null differ diff --git a/utils/__pycache__/__init__.cpython-313.pyc b/utils/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index eb1826e..0000000 Binary files a/utils/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/utils/__pycache__/dashboard_utils.cpython-313.pyc b/utils/__pycache__/dashboard_utils.cpython-313.pyc deleted file mode 100644 index 0f15665..0000000 Binary files a/utils/__pycache__/dashboard_utils.cpython-313.pyc and /dev/null differ diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..b9fcf66 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.env*.local +Dockerfile +.dockerignore diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..04ecdee --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,23 @@ +# FloatChat web app (Next.js standalone server) +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# The URL browsers use to reach the API is baked into the client bundle at build time +ARG NEXT_PUBLIC_API_URL=http://localhost:8000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0 +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/app/(site)/floats/page.tsx b/web/app/(site)/floats/page.tsx index c2d49a2..99d90e7 100644 --- a/web/app/(site)/floats/page.tsx +++ b/web/app/(site)/floats/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { connection } from "next/server"; import { FloatIndex } from "@/components/float-index"; import { getFloats } from "@/lib/api"; @@ -10,6 +11,7 @@ export const metadata: Metadata = { export default async function FloatsPage() { const floats = await getFloats().catch(() => []); + if (!floats.length) await connection(); // render per request when prerendering couldn't reach the API return (

Floats

diff --git a/web/app/(site)/page.tsx b/web/app/(site)/page.tsx index aefddda..17a2bc3 100644 --- a/web/app/(site)/page.tsx +++ b/web/app/(site)/page.tsx @@ -1,5 +1,6 @@ import { ArrowRight, ArrowUpRight } from "@phosphor-icons/react/dist/ssr"; import Link from "next/link"; +import { connection } from "next/server"; import { AskBox } from "@/components/ask-box"; import { Globe } from "@/components/globe"; import { HowItWorks } from "@/components/how-it-works"; @@ -125,6 +126,8 @@ const STACK = [ export default async function Home() { const [stats, floats, benchmark] = await Promise.all([load(getStats), load(getFloats), load(getBenchmark)]); + // No data (the API was unreachable while prerendering, as in a Docker build): render on each request instead + if (!stats || !floats) await connection(); return ( <> diff --git a/web/components/chat.tsx b/web/components/chat.tsx index 84d62be..412ae68 100644 --- a/web/components/chat.tsx +++ b/web/components/chat.tsx @@ -15,6 +15,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { formatDate, formatMs, formatPosition } from "@/lib/format"; import { applyEvent, + compactAnalytical, currentPhase, failRun, filterChips, @@ -27,6 +28,7 @@ import { import { RateLimitedError, streamQuery } from "@/lib/sse"; import type { ProfileMatch, QueryResult } from "@/lib/types"; import { AnswerMarkdown } from "./answer-markdown"; +import { DownloadMenu } from "./download-menu"; import { MiniMap } from "./mini-map"; import { DataTable, ResultChart } from "./result-charts"; import { RunLog, SqlBlock } from "./run-log"; @@ -107,10 +109,14 @@ function SemanticResult({ result }: { result: QueryResult }) { if (!profiles.length) return null; return (
+
+ p.profile_id)} name="floatchat_profiles" note="these profiles" /> +
({ lat: p.lat, lon: p.lon, label: `Float ${p.float_id}, ${p.date}` }))} label="Map of the matching profiles" height={280} + minSpan={16} />
    {profiles.map((p) => { @@ -190,6 +196,8 @@ function Turn({ run, now, onRetry, onAsk, isLast }: { const [copied, setCopied] = useState(false); const phase = currentPhase(run); const waking = run.status === "streaming" && run.steps.length === 0 && now - run.startedAt > 4000; + // Once done, an analytical answer shows its title and explanation; the rows live in the chart and table + const compact = run.status === "done" && run.result?.query_type === "analytical" ? compactAnalytical(run.answer) : null; return (
    @@ -209,7 +217,14 @@ function Turn({ run, now, onRetry, onAsk, isLast }: { ) : null} - {run.answer && } + {compact ? ( + <> + + + + ) : ( + run.answer && + )} {run.status === "error" && (
    diff --git a/web/components/download-menu.tsx b/web/components/download-menu.tsx new file mode 100644 index 0000000..1c6a0fa --- /dev/null +++ b/web/components/download-menu.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { DownloadSimple } from "@phosphor-icons/react"; +import { useState } from "react"; +import { downloadProfiles, type ExportFormat } from "@/lib/client-api"; + +const FORMATS: { format: ExportFormat; label: string }[] = [ + { format: "csv", label: "CSV" }, + { format: "netcdf", label: "NetCDF" }, + { format: "ascii", label: "ASCII" }, +]; + +/** Download buttons for a set of profiles (CSV, NetCDF, ASCII). */ +export function DownloadMenu({ profileIds, name, note }: { profileIds: number[]; name: string; note?: string }) { + const [busy, setBusy] = useState(null); + const [error, setError] = useState(false); + if (!profileIds.length) return null; + return ( +
    + + Download {note ?? `${Math.min(profileIds.length, 50)} profiles`} + + {FORMATS.map(({ format, label }) => ( + + ))} + {error && The download failed; try again.} +
    + ); +} diff --git a/web/components/float-views.tsx b/web/components/float-views.tsx index 9b6a1b6..440e1f6 100644 --- a/web/components/float-views.tsx +++ b/web/components/float-views.tsx @@ -8,6 +8,7 @@ import { sample } from "@/lib/colors"; import { formatDate, parseDate } from "@/lib/format"; import { latestProfile, latestProfileIds, VARIABLES, type Variable } from "@/lib/profiles"; import type { Measurement, TrackPoint } from "@/lib/types"; +import { DownloadMenu } from "./download-menu"; import { ProfileChart, SectionChart, TSDiagram } from "./float-charts"; import { MiniMap } from "./mini-map"; @@ -69,7 +70,7 @@ export function FloatViews({ id, track }: { id: string; track: TrackPoint[] }) {
    - +
    -

    Compare with another float

    +

    Take it further

    - Overlay two floats' T-S diagrams and mean profiles to see how their water differs. + Download the measurements, or overlay another float's T-S diagram and mean profiles to see how their + water differs.

    + (null); @@ -30,10 +33,13 @@ export function MiniMap({ if (!land || !points.length) return null; const lats = points.map((p) => p.lat); const lons = points.map((p) => p.lon); - // Pad by a quarter of the spread (at least 2 degrees) so small trajectories fill the map - const pad = Math.max(2, 0.25 * Math.max(Math.max(...lons) - Math.min(...lons), Math.max(...lats) - Math.min(...lats))); - const [w, e] = [Math.min(...lons) - pad, Math.max(...lons) + pad]; - const [s, n] = [Math.max(-89, Math.min(...lats) - pad), Math.min(89, Math.max(...lats) + pad)]; + // Pad by a fifth of the spread (at least 0.75 degrees) so small trajectories fill the map + const pad = Math.max(0.75, 0.2 * Math.max(Math.max(...lons) - Math.min(...lons), Math.max(...lats) - Math.min(...lats))); + let [w, e] = [Math.min(...lons) - pad, Math.max(...lons) + pad]; + let [s, n] = [Math.min(...lats) - pad, Math.max(...lats) + pad]; + if (e - w < minSpan) [w, e] = [(w + e) / 2 - minSpan / 2, (w + e) / 2 + minSpan / 2]; + if (n - s < minSpan * 0.6) [s, n] = [(s + n) / 2 - minSpan * 0.3, (s + n) / 2 + minSpan * 0.3]; + [s, n] = [Math.max(-89, s), Math.min(89, n)]; // d3 reads polygons on the sphere: the ring must run clockwise or it means "everything but the box" const box = { type: "Polygon" as const, coordinates: [[[w, s], [w, n], [e, n], [e, s], [w, s]]] }; return Plot.plot({ @@ -60,7 +66,7 @@ export function MiniMap({ ], }); }, - [land, points, line, height], + [land, points, line, height, minSpan], ); return ( diff --git a/web/lib/client-api.ts b/web/lib/client-api.ts index 6a0b48a..6136a68 100644 --- a/web/lib/client-api.ts +++ b/web/lib/client-api.ts @@ -13,6 +13,23 @@ export async function fetchProfiles(profileIds: number[], signal?: AbortSignal): return response.json(); } +export type ExportFormat = "csv" | "netcdf" | "ascii"; +const EXTENSIONS: Record = { csv: "csv", netcdf: "nc", ascii: "txt" }; + +/** Download up to 50 profiles in a file format (the API's /export limit), saved by the browser. */ +export async function downloadProfiles(profileIds: number[], format: ExportFormat, name: string): Promise { + const response = await fetch(`${API_URL}/export`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ format, profile_ids: profileIds.slice(-50) }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const url = URL.createObjectURL(await response.blob()); + const link = Object.assign(document.createElement("a"), { href: url, download: `${name}.${EXTENSIONS[format]}` }); + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + export async function fetchTrack(id: string, signal?: AbortSignal): Promise { const response = await fetch(`${API_URL}/profiles/float/${encodeURIComponent(id)}`, { signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); diff --git a/web/lib/run.test.ts b/web/lib/run.test.ts index db9a0b3..be6b298 100644 --- a/web/lib/run.test.ts +++ b/web/lib/run.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyEvent, currentPhase, filterChips, historyFrom, newRun, stopRun } from "./run"; +import { applyEvent, compactAnalytical, currentPhase, filterChips, historyFrom, newRun, stopRun } from "./run"; import type { QueryResult, Step } from "./types"; const understood: Step = { step: "understood", path: "analytical", intent: "avg_by_depth", @@ -50,6 +50,18 @@ describe("run reducer", () => { }); }); +describe("compactAnalytical", () => { + it("keeps the title and the explanation", () => { + const answer = "**Averages by depth**\n\n- **5 m**: 28 °C\n- **10 m**: 27 °C\n\n**What this means:** Warm on top."; + expect(compactAnalytical(answer)).toEqual({ title: "**Averages by depth**", insight: "**What this means:** Warm on top." }); + }); + + it("returns null without an explanation", () => { + expect(compactAnalytical("**Averages by depth**\n\n- **5 m**: 28 °C")).toBeNull(); + expect(compactAnalytical("**T**\n\n**What this means:**")).toBeNull(); + }); +}); + describe("filterChips", () => { it("names filters in reading order", () => { expect(filterChips({ region: "Bay of Bengal", month_num: 3, year: 2026, has_oxygen: true, has_ph: true })).toEqual( diff --git a/web/lib/run.ts b/web/lib/run.ts index 76b9916..b95cb4d 100644 --- a/web/lib/run.ts +++ b/web/lib/run.ts @@ -65,6 +65,20 @@ export function historyFrom(runs: Run[], turns = 3) { })); } +/** + * A finished analytical answer is "**Title**", the rows as a list, then "**What this means:** ...". The rows are + * already in the chart and the data table, so the compact view keeps the title and the explanation. + * Returns null when there is no explanation (for example when the LLM was unavailable). + */ +export function compactAnalytical(answer: string): { title: string; insight: string } | null { + const marker = "**What this means:**"; + const at = answer.indexOf(marker); + if (at === -1) return null; + const insight = answer.slice(at).trim(); + if (insight.length <= marker.length) return null; + return { title: answer.split("\n")[0].trim(), insight }; +} + const SENSOR_LABELS: Record = { has_oxygen: "oxygen", has_chlorophyll: "chlorophyll", diff --git a/web/next.config.ts b/web/next.config.ts index b72dd64..666a572 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -2,7 +2,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, + output: "standalone", // a small self-contained server for the Docker image poweredByHeader: false, + devIndicators: false, }; export default nextConfig;