Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ datasight is an AI-powered data exploration tool. Users ask questions in natural
- **FastAPI + uvicorn** for the web server with SSE streaming
- **Svelte 5 + TypeScript + Tailwind CSS** frontend — built with Vite, served by FastAPI
- **LLM backends**: Anthropic (default), GitHub Models, Ollama — all via a common `LLMClient` abstraction in `datasight.llm`
- **Database backends**: DuckDB (default), SQLite, PostgreSQL, Flight SQL — all via `SqlRunner` implementations in `datasight.runner`
- **Database backends**: DuckDB (default), SQLite, PostgreSQL, Flight SQL, Spark Connect, Redash HTTP API — via `SqlRunner` / `RedashRunner` in `datasight.runner` / `datasight.redash_runner`
- **Click CLI** with commands: `run`, `ask`, `init`, `demo`, `generate`, `verify`, `profile`, `quality`, `doctor`, `export`, `log`

## Repository layout
Expand Down
17 changes: 16 additions & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ For help picking a provider, see [Choosing an LLM](../use/concepts/choosing-an-l

| Variable | Default | Description |
|----------|---------|-------------|
| `DB_MODE` | `duckdb` | Database type: `duckdb`, `sqlite`, `postgres`, `flightsql`, or `spark` |
| `DB_MODE` | `duckdb` | Database type: `duckdb`, `sqlite`, `postgres`, `flightsql`, `spark`, or `redash` |
| `DB_PATH` | `./database.duckdb` | Path to DuckDB or SQLite file (used when `DB_MODE=duckdb` or `sqlite`) |

#### PostgreSQL settings (when `DB_MODE=postgres`)
Expand Down Expand Up @@ -118,6 +118,21 @@ Requires the `spark` extra: `pip install 'datasight[spark]'`.
| `SPARK_TOKEN` | — | Optional bearer token for Spark Connect auth |
| `SPARK_MAX_RESULT_BYTES` | `104857600` | Client-side cap on Arrow result size (100 MiB default). Results above this are truncated to protect the web server on multi-TB backends. |

#### Redash API settings (when `DB_MODE=redash`)

Execute SQL through [Redash](https://redash.io/)’s HTTP API instead of connecting to a warehouse directly. Use a **user API key** with permission to run queries on the chosen data source.

| Variable | Default | Description |
|----------|---------|-------------|
| `REDASH_BASE_URL` | *(required)* | Redash server origin, e.g. `https://redash.example.com` (no trailing slash) |
| `REDASH_API_KEY` | *(required)* | User API key (`Authorization: Key …`) |
| `REDASH_DATA_SOURCE_ID` | *(required)* | Numeric Redash data source ID SQL should run against |
| `REDASH_SQL_DIALECT` | `postgres` | Warehouse dialect for prompts and sqlglot validation (`postgres`, `mysql`, `spark`, `bigquery`, `snowflake`, etc.). Must match the engine behind the Redash data source. |
| `REDASH_QUERY_TIMEOUT` | `120` | Seconds to wait for Redash job completion (same scale as other backends’ query timeouts). |
| `REDASH_POLL_INTERVAL` | `0.5` | Seconds between `GET /api/jobs/…` polls while a query is running. |

Schema introspection issues one Redash job per probe query; on large warehouses prefer narrowing with `schema.yaml`. Exported Python sessions cannot embed API secrets — replays need a direct warehouse connection or your own Redash client code.

### Other settings

| Variable | Default | Description |
Expand Down
58 changes: 47 additions & 11 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
import { paletteStore } from "$lib/stores/palette.svelte";
import { tidyStore } from "$lib/stores/tidy.svelte";
import { groundingStore } from "$lib/stores/grounding.svelte";
import { exitExploreSession, getProjectStatus } from "$lib/api/projects";
import {
exitExploreSession,
getProjectStatus,
waitForProjectAutoLoad,
} from "$lib/api/projects";
import { loadSettings, loadLlmConfig } from "$lib/api/settings";
import { loadSchema, loadQueries, loadRecipes } from "$lib/api/schema";
import {
Expand Down Expand Up @@ -70,6 +74,10 @@
let exportMode = $state(false);
let exportExcludeIndices = $state(new Set<number>());
let booting = $state(true);
/** After minimal bootstrap; server-side `--project-dir` load still running (`loading: true`). */
let awaitingAutoLoad = $state(false);
/** Shown under “Loading…” during slow startup (e.g. Redash schema introspection). */
let bootHint = $state("");
let fromLanding = $state(false);

function toggleTheme() {
Expand Down Expand Up @@ -267,26 +275,40 @@
loadSettings(),
loadLlmConfig(),
]);
booting = false;

if (status.loaded) {
let resolved = status;
if (status.loading) {
awaitingAutoLoad = true;
bootHint =
"Discovering database schema — remote backends can take several minutes.";
resolved = await waitForProjectAutoLoad(status);
bootHint = "";
awaitingAutoLoad = false;
}

if (resolved.loaded) {
sessionStore.projectLoaded = true;
sessionStore.currentProjectPath = status.path;
sessionStore.isEphemeralSession = status.is_ephemeral;
sessionStore.hasTimeSeries = Boolean(status.has_time_series);
if (status.sql_dialect) {
sessionStore.sqlDialect = status.sql_dialect;
sessionStore.currentProjectPath = resolved.path;
sessionStore.isEphemeralSession = resolved.is_ephemeral;
sessionStore.hasTimeSeries = Boolean(resolved.has_time_series);
if (resolved.sql_dialect) {
sessionStore.sqlDialect = resolved.sql_dialect;
}
if (status.tables) {
sessionStore.ephemeralTablesInfo = status.tables;
if (resolved.tables) {
sessionStore.ephemeralTablesInfo = resolved.tables;
}
await onProjectReady(status.path ?? undefined);
await onProjectReady(resolved.path ?? undefined);
// Restore previous conversation on reload
await maybeRestoreSession();
}
} catch {
// Server not running — show landing page
awaitingAutoLoad = false;
} finally {
bootHint = "";
booting = false;
awaitingAutoLoad = false;
}
});

Expand Down Expand Up @@ -546,8 +568,22 @@
<main class="flex flex-1 overflow-hidden min-h-0"
style="background: linear-gradient(180deg, color-mix(in srgb, var(--bg) 86%, var(--surface) 14%), var(--bg));">
{#if booting}
<div class="flex-1 flex items-center justify-center text-text-secondary">
<div
class="flex-1 flex flex-col items-center justify-center gap-2 text-text-secondary px-4 text-center"
>
<div class="text-sm">Loading...</div>
{#if bootHint}
<div class="text-xs max-w-md opacity-90">{bootHint}</div>
{/if}
</div>
{:else if awaitingAutoLoad}
<div
class="flex-1 flex flex-col items-center justify-center gap-2 text-text-secondary px-4 text-center"
>
<div class="text-sm">Connecting to project…</div>
{#if bootHint}
<div class="text-xs max-w-md opacity-90">{bootHint}</div>
{/if}
</div>
{:else if sessionStore.projectLoaded}
<Sidebar onOpenMeasureEditor={() => (measureEditorOpen = true)} />
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/lib/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { TableInfo } from "$lib/stores/schema.svelte";

export interface ProjectStatus {
loaded: boolean;
/** True while `--project-dir` auto-load is still introspecting in the background. */
loading?: boolean;
path: string | null;
name: string | null;
is_ephemeral: boolean;
Expand Down Expand Up @@ -40,6 +42,20 @@ export async function getProjectStatus(): Promise<ProjectStatus> {
return fetchJson<ProjectStatus>("/api/project");
}

/** Poll ``/api/project`` until auto-load introspection finishes or ``maxWaitMs`` elapses. */
export async function waitForProjectAutoLoad(
initial: ProjectStatus,
maxWaitMs: number = 3_600_000,
): Promise<ProjectStatus> {
let s = initial;
const deadline = Date.now() + maxWaitMs;
while (s.loading && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 500));
s = await getProjectStatus();
}
return s;
}

export async function loadRecentProjects(): Promise<RecentProject[]> {
const data = await fetchJson<{ projects: RecentProject[] }>(
"/api/projects/recent",
Expand Down
52 changes: 41 additions & 11 deletions frontend/src/lib/components/PlotlyChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@
};
}

const STALE_CHUNK_RELOAD_KEY = "datasight-stale-chunk-reload-once";

function isStaleDynamicImportError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
return (
msg.includes("Failed to fetch dynamically imported module") ||
msg.includes("error loading dynamically imported module")
);
}

/** After a frontend rebuild, Vite chunk hashes change; a long-lived tab may 404 the old plotly chunk. */
async function importPlotly() {
try {
const mod = await import("plotly.js-dist-min");
sessionStorage.removeItem(STALE_CHUNK_RELOAD_KEY);
return mod.default;
} catch (error) {
if (isStaleDynamicImportError(error) && !sessionStorage.getItem(STALE_CHUNK_RELOAD_KEY)) {
sessionStorage.setItem(STALE_CHUNK_RELOAD_KEY, "1");
location.reload();
return new Promise(() => {});
}
throw error;
}
}

function animationFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
Expand Down Expand Up @@ -136,7 +162,7 @@
if (cancelled) return;
isRendering = true;
try {
const Plotly = (await import("plotly.js-dist-min")).default;
const Plotly = await importPlotly();
if (cancelled) return;
const cloned = cloneSpec(renderSpec);
const layout = themedLayout(cloned.layout || {}, currentTheme());
Expand Down Expand Up @@ -166,9 +192,11 @@
render();
resizeObserver = new ResizeObserver(() => {
if (!hasRendered) return;
void import("plotly.js-dist-min").then(({ default: Plotly }) => {
if (!cancelled) Plotly.Plots?.resize(plotEl);
});
void importPlotly()
.then((Plotly) => {
if (!cancelled) Plotly.Plots?.resize(plotEl);
})
.catch(() => {});
});
resizeObserver.observe(chart);
themeObserver = new MutationObserver(() => void render());
Expand All @@ -182,13 +210,15 @@
resizeObserver?.disconnect();
themeObserver?.disconnect();
plotEl.removeAllListeners?.("plotly_click");
void import("plotly.js-dist-min").then(({ default: Plotly }) => {
try {
Plotly.purge(plotEl);
} catch {
// ignore cleanup failures
}
});
void importPlotly()
.then((Plotly) => {
try {
Plotly.purge(plotEl);
} catch {
// ignore cleanup failures
}
})
.catch(() => {});
};
});
</script>
Expand Down
5 changes: 5 additions & 0 deletions src/datasight/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ def sql_error_hint(error_msg: str, dialect: str = "duckdb") -> str:
hints.append(
"HINT: PostgreSQL syntax — use DATE_TRUNC, EXTRACT, TO_CHAR, col::type."
)
case "mysql":
hints.append(
"HINT: MySQL syntax — use DATE_FORMAT, YEAR/MONTH/DAY, STR_TO_DATE; "
"backticks for reserved names."
)
case "sqlite":
hints.append(
"HINT: SQLite syntax — use strftime(), date(), datetime(). "
Expand Down
39 changes: 38 additions & 1 deletion src/datasight/cli_commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@
from datasight.cli_helpers import format_epilog


async def _async_probe_select_one_and_close(sql_runner: object) -> None:
"""Run ``SELECT 1`` then release async HTTP clients on the same event loop.

``RedashRunner`` uses ``httpx.AsyncClient``. If we ``asyncio.run(run_sql)``
and then call sync ``close()`` on the runner, ``RedashRunner.close()`` may
call ``asyncio.run(aclose())`` on a client still bound to the *previous*
(now closed) loop, surfacing ``RuntimeError: Event loop is closed``.
Performing ``aclose()`` here avoids that.
"""
try:
run_sql = getattr(sql_runner, "run_sql")
await run_sql("SELECT 1 AS ok")
finally:
inner: object | None = sql_runner
seen: set[int] = set()
while inner is not None and id(inner) not in seen:
seen.add(id(inner))
aclose_fn = getattr(inner, "aclose", None)
if callable(aclose_fn):
await aclose_fn()
break
inner = getattr(inner, "_inner", None)
close_fn = getattr(sql_runner, "close", None)
if callable(close_fn):
close_fn()


@click.command(
epilog=format_epilog(
"""
Expand Down Expand Up @@ -98,6 +125,16 @@ def add_check(name: str, ok: bool, detail: str) -> None:
elif settings.database.mode == "spark":
db_ok = bool(settings.database.spark_remote)
db_detail = settings.database.spark_remote
elif settings.database.mode == "redash":
db_ok = bool(
settings.database.redash_base_url.strip()
and settings.database.redash_api_key.strip()
and settings.database.redash_data_source_id > 0
)
db_detail = (
f"{settings.database.redash_base_url.rstrip('/')}"
f" (data_source_id={settings.database.redash_data_source_id})"
)
add_check("Database config", db_ok, db_detail or settings.database.mode)

for name in ("schema_description.md", "queries.yaml"):
Expand All @@ -116,7 +153,7 @@ def add_check(name: str, ok: bool, detail: str) -> None:

try:
sql_runner = create_sql_runner_from_settings(settings.database, str(project_path))
asyncio.run(sql_runner.run_sql("SELECT 1 AS ok"))
asyncio.run(_async_probe_select_one_and_close(sql_runner))
add_check("Database connectivity", True, "SELECT 1")
except Exception as exc:
add_check("Database connectivity", False, str(exc))
Expand Down
34 changes: 33 additions & 1 deletion src/datasight/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from loguru import logger

from datasight.exceptions import ConfigurationError
from datasight.redash_runner import RedashRunner
from datasight.runner import (
DEFAULT_QUERY_TIMEOUT,
DEFAULT_SPARK_MAX_RESULT_BYTES,
CachingSqlRunner,
DuckDBRunner,
Expand Down Expand Up @@ -88,13 +90,18 @@ def create_sql_runner(
spark_remote: str = "sc://localhost:15002",
spark_token: str | None = None,
spark_max_result_bytes: int = DEFAULT_SPARK_MAX_RESULT_BYTES,
redash_base_url: str = "",
redash_api_key: str = "",
redash_data_source_id: int = 0,
redash_query_timeout: float = DEFAULT_QUERY_TIMEOUT,
redash_poll_interval: float = 0.5,
) -> SqlRunner:
"""Create the appropriate SqlRunner based on db_mode.

Parameters
----------
db_mode:
Database mode: "duckdb", "sqlite", "postgres", "flightsql", or "spark".
Database mode: "duckdb", "sqlite", "postgres", "flightsql", "spark", or "redash".
db_path:
Path to database file (for duckdb and sqlite modes).
flight_*:
Expand All @@ -104,6 +111,8 @@ def create_sql_runner(
spark_*:
Spark Connect parameters. ``spark_max_result_bytes`` caps how much
Arrow data is materialized client-side before truncation.
redash_*:
Redash REST API parameters (``DB_MODE=redash``).

Returns
-------
Expand Down Expand Up @@ -155,6 +164,24 @@ def create_sql_runner(
token=spark_token,
max_result_bytes=spark_max_result_bytes,
)
case "redash":
base = (redash_base_url or "").strip()
if not base:
raise ConfigurationError("REDASH_BASE_URL is required for Redash mode")
if not (redash_api_key or "").strip():
raise ConfigurationError("REDASH_API_KEY is required for Redash mode")
if redash_data_source_id <= 0:
raise ConfigurationError(
"REDASH_DATA_SOURCE_ID must be a positive integer for Redash mode"
)
logger.info(f"Connecting to Redash: {base} (data_source_id={redash_data_source_id})")
return RedashRunner(
base_url=base,
api_key=redash_api_key.strip(),
data_source_id=redash_data_source_id,
query_timeout=redash_query_timeout,
poll_interval=redash_poll_interval,
)
case _:
msg = f"Invalid database mode: {db_mode}"
raise ConfigurationError(msg)
Expand Down Expand Up @@ -203,6 +230,11 @@ def create_sql_runner_from_settings(
spark_remote=settings.spark_remote,
spark_token=settings.spark_token,
spark_max_result_bytes=settings.spark_max_result_bytes,
redash_base_url=settings.redash_base_url,
redash_api_key=settings.redash_api_key,
redash_data_source_id=settings.redash_data_source_id,
redash_query_timeout=settings.redash_query_timeout,
redash_poll_interval=settings.redash_poll_interval,
)
if sql_cache_max_bytes > 0:
runner = CachingSqlRunner(runner, max_bytes=sql_cache_max_bytes)
Expand Down
Loading
Loading