diff --git a/.env.example b/.env.example index 35d1cd4..e2ecdf0 100644 --- a/.env.example +++ b/.env.example @@ -3,15 +3,25 @@ # from whichever key is set (or force it with AGENT_LLM_PROVIDER). # .env is gitignored and is the ONLY manual setup step. Never commit real keys. -# --- Database (defaults to a local SQLite file; set a Postgres URL for production) --- +# --- Database (primary metadata/cache/audit DB — PostgreSQL for production, SQLite for local dev) --- AGENT_DATABASE_URL=sqlite:///./data/app.db -# --- LLM provider: auto | anthropic | gemini | openrouter (auto = detect from keys) --- +# --- LLM provider: auto | anthropic | gemini | openrouter | nim (auto = detect from keys) --- AGENT_LLM_PROVIDER=auto # Leave blank to use the provider's sensible default model, or pin one. # (OpenRouter default is tencent/hy3 — cheap; frontier models 402 on unfunded keys.) AGENT_LLM_MODEL= +# --- OpenAI-compatible / NIM path (covers OpenAI, NVIDIA NIM, local Ollama/vLLM, etc.) --- +# Set OPENAI_API_KEY when using NVIDIA NIM or any OpenAI-compatible provider. +# Use AGENT_LLM_PROVIDER=nim (or openrouter which also uses the OpenAI-compatible path). +OPENAI_API_KEY= +# Base URL for the OpenAI-compatible endpoint. For NVIDIA NIM this is typically: +# https://integrate.api.nvidia.com/v1 +OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1 +# Model id sent in the OpenAI-compatible `model` field, e.g. meta/llama-3-8b-instruct. +OPENAI_MODEL= + # Set exactly ONE of these (matching the provider you want): AGENT_ANTHROPIC_API_KEY= AGENT_GEMINI_API_KEY= diff --git a/frontend/public/app.js b/frontend/public/app.js index 72fb850..f23f747 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -1,77 +1,238 @@ -// Zero-build baseline frontend. Single-origin: the page is served by the -// backend at /app, so API calls are same-origin relative paths. +/* Phase 3 frontend: CSV upload + Q&A, live DB query, run history. + Single-origin against the FastAPI backend at /app. +*/ "use strict"; -const $ = (id) => document.getElementById(id); +const q = (id) => document.getElementById(id); +const $ = q; +const hide = (el) => { if (el) el.hidden = true; }; +const show = (el) => { if (el) el.hidden = false; }; +const setStatus = (el, text) => { if (!el) return; el.textContent = text; show(el); }; +const clearStatus = (el) => { if (el) el.hidden = true; }; +const setError = (el, text) => { if (!el) return; el.textContent = text; hide(el); show(el); }; +const setOk = (el, text) => { if (!el) return; el.textContent = text; hide(el); show(el); }; async function loadHealth() { - const badge = $("provider-badge"); - try { - const res = await fetch("/health"); - const body = await res.json(); - const { provider, model, key_configured: keyed } = body.data; - if (!keyed) { - badge.textContent = "no API key — set one in .env"; - badge.classList.add("stub"); - } else { - badge.textContent = `${provider} · ${model}`; - } - } catch { - badge.textContent = "backend unreachable"; - badge.classList.add("stub"); + const badge = q("provider-badge"); + try { + const res = await fetch("/health"); + const body = await res.json(); + const { provider, model, key_configured: keyed } = body.data; + if (!keyed) { + badge.textContent = `no API key — set one in .env`; + badge.classList.add("stub"); + } else { + badge.textContent = `${provider} · ${model}`; } + } catch { + badge.textContent = "backend unreachable"; + badge.classList.add("stub"); + } } -async function runTransform() { - const btn = $("run-btn"); - const status = $("status"); - const errBox = $("error"); - const wrap = $("result-wrap"); - - const text = $("text").value.trim(); - const instruction = $("instruction").value.trim(); +function switchTab(name) { + document.querySelectorAll(".tab").forEach((btn) => { + const active = btn.dataset.tab === name; + btn.classList.toggle("active", active); + btn.setAttribute("aria-selected", active ? "true" : "false"); + }); + document.querySelectorAll(".panel").forEach((panel) => { + panel.classList.toggle("hidden", panel.id !== `panel-${name}`); + }); +} - errBox.hidden = true; - wrap.hidden = true; +// CSV upload +async function uploadCSV(file) { + const statusEl = q("csv-upload-status"); + const errorEl = q("csv-upload-error"); + const form = new FormData(); + form.append("file", file); + setStatus(statusEl, "Uploading…"); + clearStatus(errorEl); + try { + const res = await fetch("/csv/upload", { method: "POST", body: form }); + if (!res.ok) { + const body = await res.json(); + throw new Error(body?.detail?.message || `Upload failed (${res.status})`); + } + const body = await res.json(); + const data = body.data; + setOk(statusEl, `Uploaded ${data.file_name} · ${data.row_count} rows · ${data.columns.length} columns`); + return data; + } catch (err) { + setError(errorEl, err.message); + return null; + } +} - if (!text) { - errBox.textContent = "Paste some text first — the input can't be empty."; - errBox.hidden = false; - return; +async function runCSVQuery(question, fileId) { + const statusEl = q("csv-query-status"); + const errorEl = q("csv-query-error"); + clearStatus(statusEl); + clearStatus(errorEl); + show(statusEl); + setStatus(statusEl, "Running…"); + try { + const res = await fetch("/csv/query", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ question, data_source: "csv", csv_file_ids: [fileId] }), + }); + if (!res.ok) { + const body = await res.json(); + throw new Error(body?.detail?.message || `Query failed (${res.status})`); + } + const body = await res.json(); + const data = body.data; + if (data.status === "failed") { + throw new Error(data.error || "Agent run failed."); + } + setOk(statusEl, "Done"); + q("csv-result").textContent = data.answer_text || ""; + q("csv-result-meta").textContent = `run ${data.run_id} · ${data.provider || "unknown"} · ${data.model || "unknown"}`; + if (data.result_table && data.result_table.columns) { + renderTable("csv-table", "csv-table-wrap", data.result_table); } + if (data.run_id) { + q("csv-download").href = `/csv/runs/${data.run_id}/download`; + } + show(q("csv-result-wrap")); + } catch (err) { + setError(errorEl, err.message); + } +} - btn.disabled = true; - status.textContent = "Running… (one real LLM call)"; - status.hidden = false; +function renderTable(tableId, wrapId, resultTable) { + const table = q(tableId); + const wrap = q(wrapId); + if (!table || !resultTable) return; + const thead = table.querySelector("thead"); + const tbody = table.querySelector("tbody"); + thead.innerHTML = ""; + tbody.innerHTML = ""; + const header = document.createElement("tr"); + (resultTable.columns || []).forEach((col) => { + const th = document.createElement("th"); + th.textContent = col; + header.appendChild(th); + }); + thead.appendChild(header); + (resultTable.rows || []).forEach((row) => { + const tr = document.createElement("tr"); + (resultTable.columns || []).forEach((col) => { + const td = document.createElement("td"); + td.textContent = row[col] === null || row[col] === undefined ? "" : row[col]; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + show(wrap); +} - try { - const res = await fetch("/runs", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ text, instruction }), - }); - const body = await res.json(); +function renderHistoryTable(rows) { + const tbody = q("#history-table tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + rows.forEach((row) => { + const tr = document.createElement("tr"); + const dataSource = row.data_source || "transform"; + tr.innerHTML = ` + ${row.run_id} + ${row.status} + ${dataSource} + ${row.created_at ? new Date(row.created_at).toLocaleString() : ""} + detail + `; + tbody.appendChild(tr); + }); +} - if (!res.ok) { - const msg = body?.detail?.message || `HTTP ${res.status}`; - throw new Error(msg); - } - const run = body.data; - if (run.status === "failed") { - throw new Error(run.error_message || "The agent run failed."); - } - $("result").textContent = run.output_text; - $("result-meta").textContent = - `run ${run.run_id} · ${run.provider} · ${run.model}`; - wrap.hidden = false; +async function loadHistory() { + const statusEl = q("history-status"); + const errorEl = q("history-error"); + clearStatus(statusEl); + clearStatus(errorEl); + show(statusEl); + setStatus(statusEl, "Loading…"); + try { + const res = await fetch("/runs"); + if (!res.ok) throw new Error(`History failed (${res.status})`); + const body = await res.json(); + renderHistoryTable(body.data || []); + setOk(statusEl, ""); + } catch (err) { + setError(errorEl, err.message); + } +} + +async function runLiveQuery(question, schemaSummary) { + const statusEl = q("live-query-status"); + const errorEl = q("live-query-error"); + clearStatus(statusEl); + clearStatus(errorEl); + show(statusEl); + setStatus(statusEl, "Running…"); + try { + const res = await fetch("/live-db/query", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ question, schema_summary: schemaSummary }), + }); + if (!res.ok) { + const body = await res.json(); + throw new Error(body?.detail?.message || `Live query failed (${res.status})`); + } + const body = await res.json(); + const data = body.data; + if (data.status === "failed") { + throw new Error(data.error || "Live query failed."); + } + setOk(statusEl, `Done in ${data.latency_ms ?? "?"} ms`); + q("live-result").textContent = data.answer_text || ""; + q("live-result-meta").textContent = `run ${data.run_id} · ${data.provider || "unknown"} · served from cache: ${data.served_from_cache ? "yes" : "no"}`; + if (data.run_id) { + q("live-download").href = `/live-db/runs/${data.run_id}/download`; + } + if (data.result_table && data.result_table.columns) { + renderTable("live-table", "live-table-wrap", data.result_table); + } + show(q("live-result-wrap")); } catch (err) { - errBox.textContent = err.message; - errBox.hidden = false; - } finally { - btn.disabled = false; - status.hidden = true; + setError(errorEl, err.message); } } -$("run-btn").addEventListener("click", runTransform); +// Event wiring +q("csv-upload-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const file = q("csv-file").files[0]; + if (!file) return setError(q("csv-upload-error"), "Choose a CSV file first."); + const data = await uploadCSV(file); + if (data) q("csv-query-form").dataset.fileId = data.file_id; +}); + +q("csv-query-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const fileId = parseInt(q("csv-query-form").dataset.fileId || "0", 10); + if (!fileId) return setError(q("csv-query-error"), "Upload a CSV first."); + const question = q("csv-question").value.trim(); + if (!question) return setError(q("csv-query-error"), "Enter a question."); + await runCSVQuery(question, fileId); +}); + +q("live-query-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const question = q("live-question").value.trim(); + const schema = q("live-schema").value.trim(); + if (!question || !schema) return setError(q("live-query-error"), "Question and schema summary are required."); + await runLiveQuery(question, schema); +}); + +q("refresh-history").addEventListener("click", loadHistory); + +document.querySelectorAll(".tab").forEach((btn) => { + btn.addEventListener("click", () => switchTab(btn.dataset.tab)); +}); + loadHealth(); +loadHistory(); diff --git a/frontend/public/index.html b/frontend/public/index.html index 014097d..4a4e561 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,48 +1,122 @@ - - - Zero-Shot Agent — Transform - + + + UP Police Data Analyst + -
-
-

Zero-Shot Agent

- -
- - -
-

Transform text

-

The baseline capability: give it text and an instruction; it applies the - instruction with one real LLM call.

- - - - - - - - - - - - - -
- - -
- +
+
+

UP Police Data Analyst

+ +
+ + + +
+
+
+

Upload CSV

+

Upload one CSV export to create an analyst workspace.

+
+ + +
+ + +
+ +
+

Ask a question

+

Query the last uploaded CSV workspace.

+
+ + +
+ + + + +
+
+ + + + +
+ + +
+ diff --git a/frontend/public/styles.css b/frontend/public/styles.css index fc37523..7a64193 100644 --- a/frontend/public/styles.css +++ b/frontend/public/styles.css @@ -1,59 +1,108 @@ -/* Zero-build baseline styles — replace alongside the capability slot. */ +/* Upgraded Phase 3 styles — upload, live DB, history panels. */ :root { - --bg: #0f1115; - --card: #171a21; - --text: #e8eaf0; - --muted: #9aa3b2; - --accent: #5b8def; - --error: #e5534b; - --ok: #3fb950; - --radius: 10px; - color-scheme: dark; + --bg: #0f1115; + --card: #171a21; + --text: #e8eaf0; + --muted: #9aa3b2; + --accent: #5b8def; + --error: #e5534b; + --ok: #3fb950; + --radius: 10px; + color-scheme: dark; } * { box-sizing: border-box; } body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.5; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; } -.shell { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; } +.shell { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; } .topbar { display: flex; align-items: center; justify-content: space-between; } -.topbar h1 { font-size: 1.4rem; margin: 0; } +.topbar h1 { font-size: 1.35rem; margin: 0; } .badge { - font-size: 0.75rem; padding: 3px 10px; border-radius: 999px; - background: #22304d; color: var(--accent); border: 1px solid #2d3f63; + font-size: 0.75rem; + padding: 3px 10px; + border-radius: 999px; + background: #22304d; + color: var(--accent); + border: 1px solid #2d3f63; } .badge.stub { background: #4d2222; color: var(--error); border-color: #633030; } +.tabs { display: flex; gap: 10px; margin-top: 18px; } +.tab { + background: #1c2130; + color: var(--muted); + border: 1px solid #2b3245; + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; +} +.tab.active { background: #22304d; color: var(--text); border-color: #3a4f80; } +.panels { margin-top: 12px; } +.panel.hidden { display: none; } .card { - background: var(--card); border-radius: var(--radius); - padding: 20px; margin-top: 20px; border: 1px solid #232838; + background: var(--card); + border-radius: var(--radius); + padding: 18px; + margin-top: 14px; + border: 1px solid #232838; } -.card h2 { margin-top: 0; font-size: 1.1rem; } -.hint { color: var(--muted); font-size: 0.9rem; margin-top: -6px; } -label { display: block; margin: 14px 0 4px; font-size: 0.85rem; color: var(--muted); } +.card h2 { margin-top: 0; font-size: 1.05rem; } +.hint { color: var(--muted); font-size: 0.88rem; margin-top: -6px; } +.form-row { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } +.field { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 0.85rem; } input[type="text"], textarea { - width: 100%; padding: 10px 12px; border-radius: 8px; - border: 1px solid #2b3245; background: #10131a; color: var(--text); - font: inherit; resize: vertical; + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #2b3245; + background: #10131a; + color: var(--text); + font: inherit; + resize: vertical; } input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; } -button { - margin-top: 16px; padding: 10px 22px; border: 0; border-radius: 8px; - background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; +button[type="submit"], button { + margin-top: 10px; + padding: 10px 18px; + border: 0; + border-radius: 8px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; } button:disabled { opacity: 0.55; cursor: wait; } -.status { margin-top: 14px; color: var(--muted); font-size: 0.9rem; } +button.secondary { background: #232d42; } +.status { margin-top: 12px; color: var(--muted); font-size: 0.9rem; } .error { - margin-top: 14px; padding: 10px 14px; border-radius: 8px; - background: #2c1a19; border: 1px solid #5a2c29; color: var(--error); font-size: 0.9rem; + margin-top: 12px; + padding: 10px 14px; + border-radius: 8px; + background: #2c1a19; + border: 1px solid #5a2c29; + color: var(--error); + font-size: 0.9rem; } #result-wrap h3 { margin: 18px 0 6px; font-size: 0.95rem; } pre#result { - background: #10131a; border: 1px solid #2b3245; border-radius: 8px; - padding: 14px; white-space: pre-wrap; word-wrap: break-word; margin: 0; + background: #10131a; + border: 1px solid #2b3245; + border-radius: 8px; + padding: 14px; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; } .meta { color: var(--muted); font-size: 0.78rem; } +.table-wrap { margin-top: 12px; overflow: auto; } +.table-wrap table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +.table-wrap th, .table-wrap td { padding: 8px 10px; border-bottom: 1px solid #232838; text-align: left; } +.table-wrap th { color: var(--muted); font-weight: 500; } +.actions { margin-top: 10px; } +.actions a { color: var(--accent); } .foot { margin-top: 28px; color: var(--muted); font-size: 0.85rem; text-align: center; } .foot a { color: var(--accent); } diff --git a/pyproject.toml b/pyproject.toml index 10c0d05..37a6064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,15 +5,16 @@ description = "Baseline agent skeleton — FastAPI + LangGraph + SQLite, provide readme = "README.md" requires-python = ">=3.11" dependencies = [ - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "pydantic>=2.7", - "pydantic-settings>=2.3", - "sqlalchemy>=2.0", - "alembic>=1.13", - "langgraph>=0.2.28", - "httpx>=0.27", - "structlog>=24.1", + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "pydantic>=2.7", + "pydantic-settings>=2.3", + "sqlalchemy>=2.0", + "alembic>=1.13", + "langgraph>=0.2.28", + "httpx>=0.27", + "structlog>=24.1", + "pandas>=2.0", ] [dependency-groups] diff --git a/spec/agent.md b/spec/agent.md index 49f33fa..223a920 100644 --- a/spec/agent.md +++ b/spec/agent.md @@ -1,216 +1,254 @@ # Agent -> Required when the project uses an agent framework. Delete this file if your project has no agent framework. -> -> If your project has no agent framework (e.g., a simple script or single-LLM API call), delete this file. -> - ---- +> **Assumed:** LangGraph graph as defined below; NVIDIA NIM via OpenAI-compatible HTTP for all nodes that call the LLM. +> **Assumed:** one batched LLM call per query artifact; per-line LLM loops are forbidden. ## Agent Architecture Pattern - - | Pattern | Use when | |---------|----------| -| **Single-agent loop** | One LLM drives a deterministic tool-call loop. No branches, no handoffs. | +| Single-agent loop | One LLM drives a deterministic tool-call loop. No branches, no handoffs. | | **Graph (LangGraph)** | Multi-step pipeline with conditional edges, checkpointing, or parallel nodes. | -| **Multi-agent** | Specialised sub-agents with distinct roles; orchestrator routes between them. | -| **Supervisor** | One supervisor LLM dispatches to worker agents based on task type. | -| **Human-in-the-loop** | Execution pauses at defined checkpoints for user review or approval. | +| Multi-agent | Specialised sub-agents with distinct roles; orchestrator routes between them. | +| Supervisor | One supervisor LLM dispatches to worker agents based on task type. | +| Human-in-the-loop | Execution pauses at defined checkpoints for user review or approval. | -**Chosen:** - ---- +**Chosen:** **Graph (LangGraph)** — the analyst task is a multi-step pipeline with conditional data-source routing (CSV ingest vs live MsSQL vs cache) and audited state propagation across nodes. ## LLM Provider & Model - - | Agent / Node | Provider | Model ID | Rationale | -|-------------|----------|----------|-----------| -| | Anthropic | | | +|--------------|----------|----------|-----------| +| `plan_query` | NVIDIA NIM (OpenAI-compatible) | Environment variable `OPENAI_MODEL`; default resolved by the provider factory. | Low-latency structured planning from the user's natural-language question. | +| `generate_code` | NVIDIA NIM (OpenAI-compatible) | Same as above. | Single batched call to produce SQL/pandas code. | +| `assemble_answer` | NVIDIA NIM (OpenAI-compatible) | Same as above. | Synthesis of results into a narrative answer + follow-ups. | -**Fallback behaviour:** +**Fallback behaviour:** If the NIM endpoint returns 401/429/model_not_found, the node sets `state.error` and the graph routes to `assemble_answer` with a surfaced error message and cached/offline guidance when available. Retry/backoff is handled in `src/llm/providers/base.py` via the existing retry policy. -**Prompt strategy:** - ---- +**Prompt strategy:** Split system/user prompts; structured JSON-mode request when the node emits code; few-shot examples are loaded from `src/prompts/` per capability. ## Tools & Tool Calling - - | Tool name | Description | Inputs | Output | Side-effects | |-----------|-------------|--------|--------|--------------| -| | | | | | - -**Tool selection strategy:** +| `csv_ingestor` | Reads uploaded CSV files, infers schema, registers tables in the working SQLite store. | file_id, file_path | table_name, columns, row_count | Creates SQLite tables; writes ingest audit row. | +| `sql_runner` | Executes read-only SQL against the selected data source (SQLite for CSV, MsSQL for live, PostgreSQL for cache). | sql, data_source, row_limit | rows, columns, row_count, latency_ms | Read-only query execution. | +| `cache_reader` | Returns precomputed aggregates from the local cache when live DB is slow or unreachable. | question_hash, table_hint | rows, served_from_cache flag | None. | +| `audit_writer` | Persists a query audit row. | run_id, user_id, question, sql, tables_touched, row_count, latency_ms, token_usage | audit_id | Writes to PostgreSQL audit table. | +| `followup_generator` | Produces suggested follow-up questions and anomaly flags. | question, schema, result_summary | followups, anomaly_flags, sensitive_warning | None. | -**Tool failure handling:** +**Tool selection strategy:** Each node corresponds to one tool call in the graph; the LLM decides parameters within that node. No free-form multi-tool call loop in Phase 1. ---- +**Tool failure handling:** Node catches exceptions, writes `state.error`, and routes to `assemble_answer` so the user sees a best-guess message with the failed step. Fatal infra failures (DB unreachable) still return a degraded-mode answer with offline guidance. ## Agent State - - ```python -class AgentState(TypedDict): - # Identity - run_id: int # set at initialisation +class AgentState(TypedDict, total=False): + run_id: int + user_id: int | None + question: str + instruction: str + data_source: str | None # "csv" | "live_db" | "cache" + csv_file_ids: list[int] | None + schema_summary: str | None + query_plan: str | None + generated_code: str | None + executed_sql: str | None + executed_rows: list[dict] | None + executed_columns: list[str] | None + executed_row_count: int | None + latency_ms: int | None + result_table: str | None + answer_text: str | None + csv_download_url: str | None + followups: list[str] | None + anomaly_flags: list[str] | None + sensitive_warning: str | None + tables_touched: list[str] | None + provider: str | None + model: str | None + token_usage: dict | None + memory_context: str | None + saved_workspace_id: int | None + status: str | None + error: str | None +``` - # Input - # ... # fields populated from the trigger +## Nodes / Steps - # Pipeline data (populated progressively by nodes) - # ... +### `plan_query` - # Output - # ... # final result fields +**Reads from state:** `question`, `data_source`, `csv_file_ids`, `schema_summary` - # Control - error: str | None # set by any node on fatal failure - checkpoint: str | None # last completed node (for resume) -``` +**Writes to state:** `query_plan`, `generated_code`, `executed_sql`, `data_source` (if not already set), `tables_touched` ---- +**LLM call:** Yes — plans the query strategy, chooses data source, selects tables/columns. -## Nodes / Steps +**External calls:** `csv_ingestor` if schema is missing; `cache_reader` to check freshness when source is live DB. - +**Behaviour:** Converts the natural-language question into a structured plan and, when needed, candidates CSV tables, live DB tables, or cached aggregates. It respects read-only constraints and row-limit policies before any execution. -### `node_[name]` +### `generate_code` -**Reads from state:** +**Reads from state:** `query_plan`, `schema_summary`, `data_source` -**Writes to state:** +**Writes to state:** `generated_code`, `executed_sql` -**LLM call:** +**LLM call:** Yes — one batched call emits SQL (or pandas code for CSV mode) based on the plan and schema. -**External calls:** +**External calls:** None. -| System | Operation | On Failure | -|--------|-----------|------------| -| | | | +**Behaviour:** Produces one artifact: a single SQL statement (or pandas pipeline) that answers the planned question. If the chosen data source is missing or the schema is insufficient, it returns a structured error message rather than guessing blindly. -**Behaviour:** +### `execute_query` ---- +**Reads from state:** `generated_code`, `data_source`, `csv_file_ids` -## Graph / Flow Topology +**Writes to state:** `executed_rows`, `executed_columns`, `executed_row_count`, `latency_ms`, `executed_sql`, `tables_touched`, `error` + +**LLM call:** No. + +**External calls:** `sql_runner` against SQLite / MsSQL / PostgreSQL cache; `cache_reader` fallback when live execution fails or is too slow. + +**Behaviour:** Runs the query read-only with enforced row limits. On failure it attempts cache fallback when the source is live DB; otherwise it records the failure in `error` without raising. It always records the tables touched and row count for audit. + +### `assemble_answer` + +**Reads from state:** `executed_rows`, `executed_columns`, `question`, `executed_sql`, `error`, `followups`, `anomaly_flags`, `sensitive_warning` + +**Writes to state:** `answer_text`, `result_table`, `csv_download_url`, `followups`, `anomaly_flags`, `sensitive_warning`, `status` + +**LLM call:** One batched call to synthesise the answer, generate follow-up suggestions, flag anomalies, and emit junior-sensitive warnings when relevant. + +**External calls:** `audit_writer` to persist the full audit row; `followup_generator` is the LLM call itself. + +**Behaviour:** Builds the final user-facing answer. If `error` is set, it includes the failed step and a retry/offline guidance message. It never masks failures as successful answers. + +### `finalize` + +**Reads from state:** `status` + +**Writes to state:** `status` (defaults to `"completed"`) + +**LLM call:** No. + +**External calls:** None. + +**Behaviour:** Marks the run complete after the response has been returned and the audit row written. - +### `handle_error` + +**Reads from state:** `error` + +**Writes to state:** `status` = `"failed"`, `error_message` + +**LLM call:** No. + +**External calls:** `audit_writer` to log the failure. + +**Behaviour:** Terminal failure node. Used only when a prior node cannot continue at all. + +## Graph / Flow Topology ``` START │ ▼ -node_a ──(error)──► node_handle_error ──► END +plan_query ──(error)──► handle_error ──► END │ ▼ -node_b ──(condition)──► node_c - │ │ - │ ▼ - └──────────────────► node_finalize - │ - ▼ - END +generate_code ──(error)──► handle_error ──► END + │ + ▼ +execute_query ──(error)──► assemble_answer ──► finalize ──► END + │ │ + │ ▼ + │ finalize ──► END + ▼ +assemble_answer ──(error)──► handle_error ──► END + │ + ▼ +finalize ──► END ``` **Conditional edges:** | Source node | Condition | Target | |-------------|-----------|--------| -| | | | - ---- +| `plan_query` | `state["error"]` is not `None` | `handle_error` | +| `plan_query` | otherwise | `generate_code` | +| `generate_code` | `state["error"]` is not `None` | `handle_error` | +| `generate_code` | otherwise | `execute_query` | +| `execute_query` | `state["error"]` is not `None` | `assemble_answer` | +| `execute_query` | otherwise | `assemble_answer` | +| `assemble_answer` | `state["error"]` is not `None` | `handle_error` | +| `assemble_answer` | otherwise | `finalize` | ## Memory & Context - - | Scope | Mechanism | What is stored | |-------|-----------|----------------| -| **Within a run** | LangGraph state | All in-progress data | -| **Across runs** | | | -| **Conversation** | | | +| **Within a run** | LangGraph state | All in-progress data, generated code, result set | +| **Across runs** | PostgreSQL + `saved_workspace_id` | Past CSV file refs, saved SQL, named scratchpads | +| **Conversation** | `memory_context` string + follow-up turn state | The previous question, answer summary, and suggested follow-ups for the next turn | +| **Cache** | PostgreSQL aggregate tables | Precomputed answers keyed by question + source fingerprint | -**Context window management:** - ---- +**Context window management:** The agent sends the schema summary and last turn summary, not full file contents, to the LLM. Large result sets are summarised before inclusion. ## Human-in-the-Loop Checkpoints - - | Checkpoint | What is shown to the user | Expected user action | Timeout / default | -|------------|--------------------------|----------------------|-------------------| -| | | | | - ---- +|------------|---------------------------|-----------------------|-------------------| +| Sensitive query warning | Modal / banner: "This query touches [category]. Confirm you are authorised to run it." | Approve / abort | Default abort on 30 s timeout | +| Live DB connection test | Connection status indicator + last-sync timestamp | Retry / switch to cache / switch to CSV | Default: stay on current source | ## Error Handling & Recovery - - -**Node-level:** +**Node-level:** Each node catches its own exceptions; non-fatal failures set `state.error` and continue to `assemble_answer` so the user sees a best-guess message. Fatal failures (LLM auth, total DB outage) route to `handle_error`. -**Graph-level (handle_error node):** +**Graph-level (`handle_error` node):** - Reads: `state.error`, `state.run_id` -- Updates DB: run status → "failed", `error_message`, `completed_at` +- Updates DB: run status → `failed`, `error_message`, `completed_at` - Logs error with `run_id` context - Terminates graph -**Resume / retry strategy:** +**Resume / retry strategy:** A failed run can be retried from `plan_query` with the same inputs; saved workspaces preserve the prior state for re-run on fresh data. -**Partial failure:** - ---- +**Partial failure:** If `execute_query` fails but a cache hit exists, the agent degrades to cached results with a clearly visible "served from cache" indicator. ## Observability - - | Signal | What | Where | |--------|------|-------| -| **Trace** | One trace per run, one span per node | | -| **LLM calls** | Prompt tokens, completion tokens, latency, model | | -| **Tool calls** | Tool name, inputs, success/error, latency | Structured log | -| **Run outcome** | Status, total duration, error if any | DB + structured log | - ---- +| Trace | One trace per run, one span per node | Structured log + DB audit row | +| LLM calls | Prompt tokens, completion tokens, latency, model, provider | Structured log + DB audit row | +| Tool calls | Tool name, inputs, success/error, latency | Structured log + DB audit row | +| Run outcome | Status, total duration, row count, error if any | DB + structured log | ## Concurrency Model - +- **Run isolation:** Run IDs are scoped per user/session; simultaneous runs are independent. +- **Parallel nodes within a run:** `followup_generator` and `csv_ingestor` can run in parallel in later phases; Phase 1 runs sequentially. +- **Checkpointing:** LangGraph state is persisted per run via SQLite/PostgreSQL saver for long-running investigations. -- **Run isolation:** -- **Parallel nodes within a run:** -- **Checkpointing:** - ---- - -## Graph Assembly (`agent/graph.py`) - - +## Graph Assembly (`src/graph/agent.py`) ```python graph = StateGraph(AgentState) -graph.add_node("node_a", node_a) -graph.add_node("node_b", node_b) -graph.add_node("finalize", node_finalize) -graph.add_node("handle_error", node_handle_error) - -graph.set_entry_point("node_a") - -graph.add_conditional_edges( - "node_a", - lambda s: "handle_error" if s.get("error") else "node_b", -) - -graph.add_edge("node_b", "finalize") +graph.add_node("plan_query", plan_query) +graph.add_node("generate_code", generate_code) +graph.add_node("execute_query", execute_query) +graph.add_node("assemble_answer", assemble_answer) +graph.add_node("finalize", finalize) +graph.add_node("handle_error", handle_error) + +graph.set_entry_point("plan_query") +graph.add_conditional_edges("plan_query", after_plan_query, {"generate_code": "generate_code", "handle_error": "handle_error"}) +graph.add_conditional_edges("generate_code", after_generate_code, {"execute_query": "execute_query", "handle_error": "handle_error"}) +graph.add_conditional_edges("execute_query", after_execute_query, {"assemble_answer": "assemble_answer", "handle_error": "handle_error"}) +graph.add_conditional_edges("assemble_answer", after_assemble_answer, {"finalize": "finalize", "handle_error": "handle_error"}) graph.add_edge("finalize", END) graph.add_edge("handle_error", END) diff --git a/spec/api.md b/spec/api.md index 442a58b..ab8236a 100644 --- a/spec/api.md +++ b/spec/api.md @@ -1,41 +1,76 @@ # API -> Fill in this section — see comments below. +> **Assumed:** FastAPI HTTP surface; uploads as `multipart/form-data`; JSON responses with standard `run_id` and `status` fields. +> **Assumed:** Phase 1 endpoints only; Phase 2/3 endpoints are stubbed in the UI but return 501 until implemented. ---- +## Base -## API Style +`/api/v1` - +## Endpoints -## Endpoints / Commands +| Method | Path | Purpose | Auth | Phase | +|--------|------|---------|------|-------| +| `POST` | `/api/v1/upload` | Upload one or more CSV files | Bearer token | 1 | +| `POST` | `/api/v1/query` | Ask a natural-language question over uploaded data or live DB | Bearer token | 1 | +| `GET` | `/api/v1/runs/{run_id}` | Fetch a completed run: answer, table, generated SQL, audit ref | Bearer token | 1 | +| `GET` | `/api/v1/runs/{run_id}/download` | Download the result set as CSV | Bearer token | 1 | +| `GET` | `/api/v1/health` | Provider + DB + cache status | None | 1 | +| `POST` | `/api/v1/auth/token` | Issue JWT for RBAC users | Form login | 3 | +| `POST` | `/api/v1/live-db/test` | Test connectivity to MsSQL / show cache status | Bearer token | 2 | +| `GET` | `/api/v1/workspaces` | List saved workspaces for current user | Bearer token | 3 | +| `POST` | `/api/v1/workspaces` | Create / update a named workspace | Bearer token | 3 | +| `GET` | `/api/v1/audit/export` | Export audit rows as CSV / JSON | Supervisor only | 3 | - +## Request/Response Shapes -### `` +### `POST /api/v1/query` -**Purpose:** +**Request body** -**Request:** ```json { - "": "" + "question": "How many thefts by district last month?", + "data_source": "csv", + "csv_file_ids": [12, 15], + "workspace_id": null, + "row_limit": 10000 } ``` -**Response:** +**Response body** + ```json { - "": "" + "run_id": 101, + "status": "completed", + "answer_text": "There were 342 thefts across 12 districts...", + "result_table": { + "columns": ["district", "count"], + "rows": [{"district": "Lucknow", "count": 78}] + }, + "generated_sql": "SELECT district, COUNT(*) AS count FROM fir WHERE ...", + "tables_touched": ["fir"], + "executed_row_count": 78, + "latency_ms": 1240, + "provider": "nim", + "model": "meta/llama-3-8b-instruct", + "csv_download_url": "/api/v1/runs/101/download", + "followups": ["Which month had the highest spike?", "Show station-wise breakdown"], + "anomaly_flags": [], + "sensitive_warning": null, + "served_from_cache": false, + "error": null } ``` -**Error cases:** -| Status | Condition | -|--------|-----------| -| 400 | | -| 500 | | - -## Authentication +## Error Responses - +| HTTP | Body shape | Meaning | +|------|-----------|---------| +| 400 | `{"detail": "...", "field": "..."}` | Invalid request, bad schema/column, missing parameter | +| 401 | `{"detail": "Not authenticated"}` | Missing/invalid bearer token | +| 403 | `{"detail": "Forbidden"}` | RBAC rejection | +| 422 | `{"detail": "..."}` | Validation failure | +| 429 | `{"detail": "Rate limited"}` | LLM rate-limit or DB connection-limit | +| 500 | `{"detail": "...", "run_id": 101}` | Agent / graph failure; use `run_id` to retrieve partial state | diff --git a/spec/architecture.md b/spec/architecture.md index e5e3bd9..1d01b96 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -1,68 +1,90 @@ # Architecture -> Fill in this section — see comments below. - ---- +> **Assumed:** baseline Python + FastAPI + LangGraph; SQLite (dev) and a separate PostgreSQL database for sessions, workspaces, and audit; MsSQL accessed read-only via SQLAlchemy + pyodbc or asyncodbc; NVIDIA NIM as the LLM provider via its OpenAI-compatible HTTP endpoint. +> **Assumed:** all observability, E2E, and offline/cache fallback requirements apply. ## System Overview - +The UP Police Data Analyst is a long-running FastAPI service. A user uploads CSVs, asks a natural-language question, and receives an auditable analytical answer backed by real data. In later phases the same loop can run over a live MsSQL read-only source instead of CSV, with a local cache that keeps answers fast and protects the production database from ad-hoc analytical load. The service is deployed on-premises on the police network; external egress is not required. ## Component Map - - ``` -[Component A] - ↓ -[Component B] ←→ [External Service] - ↓ -[Component C] +User (browser) + │ + ▼ +FastAPI Backend + │ + ├─► CSV Ingestor —> parsed tables (pandas / SQLite / DuckDB if needed) + ├─► MsSQL Adapter —> read-only live queries via SQLAlchemy + pyodbc / asyncodbc + ├─► Cache Layer —> local PostgreSQL tables for precomputed aggregates + row-level cache + ▼ +LangGraph Agent + │ + ├─► Planner node — turns the question into a query plan and data-source choice + ├─► SQL/code generator — one tool call to the LLM; emits SQL or pandas + ├─► Validator/runner — executes safely and enforces read-only / row limits + ├─► Answer node — builds audited output, charts data, CSV blob + ▼ +Audit + Session tables (PostgreSQL) + ▼ +Frontend (static JS bundle served by FastAPI) ``` ## Layers - - | Layer | Responsibility | |-------|----------------| -| | | +| HTTP / API | Request validation, auth, upload handling, response encoding | +| Graph | LangGraph state machine: plan → query → validate → answer | +| Data access | CSV ingestion, live MsSQL read-only adapter, cache reads/writes | +| Audit / sessions | Runs, queries, users, workspaces, exports (PostgreSQL) | +| LLM | One batched call per query; policy/guardrails via prompts | +| Frontend | Single-page static UI for upload, Q&A, charts, exports, workspace | ## Data Flow - - -1. Trigger: -2. -3. -4. Output: +1. Trigger: user uploads one or more CSVs, or chooses "Live DB" as the source. +2. Planner: the agent picks CSV vs live vs cache based on user selection and cache freshness policy. +3. Query generation: one LLM call emits SQL or pandas; CSV mode uses SQLite/SQLAlchemy against an ingested schema; live mode emits MsSQL-T-SQL dialect. +4. Validation: read-only check, row-limit enforcement, expected-column verification. +5. Execution: query runs; result set is materialised. +6. Answer assembly: natural-language answer, table, CSV download, generated code, audit row written. +7. Output: returned to the UI and saved to the run history. ## External Dependencies - - | Dependency | Purpose | Failure Mode | |------------|---------|--------------| -| | | | +| NVIDIA NIM (OpenAI-compatible endpoint) | Text-to-SQL, answer synthesis, follow-up suggestions | Degraded: agent surfaces error and offers best-guess cached answer or re-try guidance | +| MsSQL (read replica / production) | Live crime / case data | Offline mode: agent serves from local PostgreSQL cache with "served from cache" indicator | +| PostgreSQL (local) | Query cache, sessions, workspaces, audit log | Fatal for persistence features; CSV mode still works for uploads without PostgreSQL until the DB is restored | ## Stack -> This project's concrete technology choices (captured at intake, filled by the spec-writer). The generic, every-project rules — model-naming, DB driver, dev port, test environment — live in `harness/patterns/tech-stack.md`; this section is only what **this** project picked. - -- **Language:** -- **Agent framework:** -- **LLM provider + model:** -- **Backend:** -- **Database + ORM:** -- **Frontend:** -- **Dependency management:** - -| Key library | Version | Purpose | -|-------------|---------|---------| -| | | | - -**Avoid:** +- **Language:** Python 3.11+ +- **Agent framework:** LangGraph +- **LLM provider + model:** NVIDIA NIM — OpenAI-compatible HTTP; environment-configurable base URL, API key, model id. +- **Backend:** FastAPI + Uvicorn +- **Database + ORM:** SQLite (CSV ingest workspace) · PostgreSQL (sessions / workspaces / audit / materialised cache) · MsSQL via SQLAlchemy + pyodbc/asyncodbc (read-only) +- **Frontend:** Static HTML+CSS+JS in `frontend/public/`, served by FastAPI +- **Dependency management:** uv + pyproject.toml + +| Key library | Version (target) | Purpose | +|-------------|------------------|---------| +| fastapi | >=0.115 | HTTP surface | +| uvicorn[standard] | >=0.30 | server | +| pydantic / pydantic-settings | >=2.7 / >=2.3 | validation and settings | +| sqlalchemy | >=2.0 | SQL generation + dialects | +| langgraph | >=0.2.28 | agent graph | +| httpx | >=0.27 | LLM HTTP calls | +| structlog | >=24.1 | structured audit logging | +| pyodbc / asyncodbc | latest stable | MsSQL driver connectivity | + +**Avoid:** writing to the live production MsSQL database; client-side bundlers or build pipelines for the Phase 1 UI; per-line LLM loops in generated code. ## Deployment Model - +- On-premises deployment as a single long-running service (`python -m src`) on the police network. +- `.env` for secrets; no external SaaS for persistence unless it is an approved government VPC service. +- Nightly or on-demand cache refresh for live DB materialisations. diff --git a/spec/capabilities/analyse_data.md b/spec/capabilities/analyse_data.md new file mode 100644 index 0000000..9914007 --- /dev/null +++ b/spec/capabilities/analyse_data.md @@ -0,0 +1,52 @@ +# Capability: Analyse Structured Police Data + +## What It Does + +Answers a single natural-language question over structured police data — either from uploaded CSV exports or the live read-only MsSQL source — and returns an auditable result set with the generated SQL, row counts, and a CSV download. It is the Phase 1 core loop for the UP Police Data Analyst agent. + +## Inputs + +| Input | Type | Source | Required | +|-------|------|--------|----------| +| `question` | string | User | yes | +| `data_source` | enum: `csv` \| `live_db` \| `cache` | User | yes | +| `csv_file_ids` | list[int] | Upload store | when `data_source == csv` | +| `workspace_id` | int \| null | Saved workspace | no | +| `row_limit` | int | User / policy | no | +| `schema_summary` | string | Auto-generated | no (ingested if missing) | + +## Outputs + +| Output | Type | Destination | +|--------|------|-------------| +| Answer text | string | API response + UI | +| Result table | `{columns: list[str], rows: list[dict]}` | API response + UI | +| Generated SQL | string | API response + UI audit view | +| CSV download | binary | `/api/v1/runs/{run_id}/download` | +| Audit row | row | PostgreSQL audit table | +| Follow-ups / anomaly flags / sensitive warning | objects | API response + UI | + +## External Calls + +| System | Operation | On Failure | +|--------|-----------|------------| +| NVIDIA NIM | One LLM call for plan / SQL / answer synthesis | Surface structured error; offer cache/offline fallback if applicable | +| SQLite workspace | Read-only query over ingested CSV tables | Endpoint returns 400 with missing-schema message | +| MsSQL | Read-only live query | Route to cache fallback when unavailable; show "served from cache" indicator | +| PostgreSQL cache | Precomputed aggregate lookup | Continue to live or admit answer unavailability with retry guidance | + +## Business Rules + +- All executed SQL must be read-only; DDL/DML is rejected before execution. +- A missing or ambiguous column is surfaced to the user; the agent never guesses silently. +- Sensitive case categories (juveniles, women, victim identifiers) trigger a confirmation gate before execution. +- Row-limit policy is enforced at execution, not just in generated code. +- Audit rows are written before the response is returned, never in a background fire-and-forget path. + +## Success Criteria + +- [ ] API smoke uploads a CSV and returns a question answer in under 2 s for a 5 000-row table. +- [ ] Generated SQL is present in the response and matches the returned row count. +- [ ] CSV download contains the exact rows shown in the result table. +- [ ] When MsSQL is disconnected, the same question on cached data returns within 3 s with `served_from_cache: true`. +- [ ] A malformed question (missing table/column) returns a 400-style structured error, not a 500. diff --git a/spec/capabilities/index.md b/spec/capabilities/index.md index 7455bda..aa07118 100644 --- a/spec/capabilities/index.md +++ b/spec/capabilities/index.md @@ -1,35 +1,25 @@ # Capabilities Index -> **Boilerplate status:** The spec-writer sub-agent creates one file per capability in this directory. Each file describes exactly one discrete thing the agent can do. +> **Boilerplate status:** Complete for the UP Police Data Analyst agent. Each file describes exactly one discrete capability; product-side design docs are in `spec/roadmap.md` and `spec/architecture.md`. --- ## What Is a Capability? -A capability is a single, discrete action or behavior the agent performs. Examples: -- "Search the web for companies matching criteria X" -- "Draft a personalized email given a lead profile" -- "Send a Slack notification when a threshold is crossed" +A capability is a single, discrete action or behaviour the agent performs. Examples: +- "Answer a natural-language question over uploaded CSV exports" +- "Return a downloadable CSV plus generated SQL for the same question" +- "Serve the same question from a local cache when the live DB is unreachable" ## Capabilities in This Project - - | Capability | File | -|-----------|------| -| | [name.md](name.md) | - -## How to Add a New Capability - -Run `/zero-shot-build [description]` on the existing spec. The spec-writer sub-agent will: -1. Create a new file in this directory (`.md`, no number prefix) -2. Update this index -3. Flag any dependencies on existing capabilities -4. Self-review that it fits the architecture and data model before returning +|------------|------| +| Analyse structured police data (CSV + live DB, cache fallback) | [analyse_data.md](analyse_data.md) | ## Capability File Template -Each capability file should answer: +Each capability file answers: - **What it does** (one sentence) - **Inputs** (what data it receives) - **Outputs** (what it produces) diff --git a/spec/data.md b/spec/data.md index e331007..5debb5e 100644 --- a/spec/data.md +++ b/spec/data.md @@ -1,34 +1,38 @@ -# Data Model +# Data -> Fill in this section — see comments below. - ---- - -## Storage Technology - - +This section describes the data the agent works with, the local cache it maintains, and the audit trail it writes. The live production MsSQL schema is outside this repository; this agent is read-only against it. ## Entities - - -### Entity: +| Entity | Storage | Phase | Notes | +|--------|---------|-------|-------| +| `run` | SQLite (dev) / PostgreSQL (prod) | 1 | One row per user question. Tracks status + provider + model + timing. | +| `run_output` | SQLite / PostgreSQL | 1 | Final answer text, result table JSON, generated SQL. | +| `audit` | PostgreSQL | 1 | Immutable query audit: user, question, sql, tables_touched, row_count, latency_ms, token_usage. | +| `csv_upload` | SQLite (workspace) / PostgreSQL (prod) | 1 | File ref, schema fingerprint, ingest path. | +| `workspace` | PostgreSQL | 2 | Named saved datasets, scratchpads, and saved SQL the user can re-attach. | +| `cache_aggregate` | PostgreSQL | 2 | Materialised aggregates keyed by question + source fingerprint for cache-first fallback. | +| `role` / `user` | PostgreSQL | 3 | Role-based access control: investigator, analyst, supervisor. | - +## CSV Workspace -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| id | | yes | Primary key | -| | | | | +- CSVs are parsed into pandas DataFrames and registered as tables in a working SQLite database for the duration of the session / run. +- Column names are normalised to lower_snake_case; type inference is best-effort. +- Row counts, column types, and a schema fingerprint are stored for duplicate-detection and cache keys. -### Relationships +## Live MsSQL (read-only) - +- Accessed via SQLAlchemy + pyodbc or asyncodbc using a connection string from `.env`. +- All queries are scoped to read-only transactions; the agent cannot emit DML/DDL. +- Schema is introspected on first connect and cached locally to minimise live round-trips. -## Data Lifecycle +## PostgreSQL Audit + Cache - +- Audit table is append-only; rows are never mutated after insert. +- Cache aggregates are refreshed on a defined schedule (default: nightly) or on-demand when a user requests a fresh answer. -## Sensitive Data +## Row Limits & Guards - +- Default hard row limit for ad-hoc queries: 100,000 rows. +- Cache fallback is triggered when the live DB query exceeds 30 s or returns a connectivity error. +- Sensitive-category columns (juveniles, women, victim identifiers) are flagged at schema-ingest time and trigger a user confirmation gate before execution. diff --git a/spec/roadmap.md b/spec/roadmap.md index 03ae242..ed063ae 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -1,62 +1,100 @@ # Roadmap -> Fill in each section. Run `/zero-shot-build [your idea]` to have it filled automatically. - ---- - ## What This Agent Does - +A data analyst agent for the Uttar Pradesh Police that answers natural-language questions over police data — starting from uploaded CSV exports (FIRs, daily diaries, incident logs, offence registers) and later extending to direct read-only queries against a large centralised MsSQL database. It generates auditable answers plus downloadable result sets, charts, and the raw SQL or pandas code behind the answer. It is built for investigators, analysts, and command leadership who cannot write SQL but need trustworthy, traceable numbers from police data. ## Who Uses It - +- Investigating officers at a district — ad-hoc queries during case work. +- Dedicated data analysts / research cells — heavy repeated usage. +- Command-level leadership (SSP / DIG) — occasional strategic queries. +- Occasional users across a wide range of ranks and SQL literacy. ## Core Problem Being Solved - +Today the same questions — "how many vehicle thefts in Lucknow last month?", "which police stations show the highest violent-crime trend this quarter?" — require either knowing SQL, waiting for a data analyst, or exporting CSVs into Excel and manually pivoting. The agent removes that friction, keeps answers auditable, and reduces ad-hoc load on the live database via caching and offline (CSV) mode. ## Success Criteria - +- [ ] A user can upload one or more CSVs, ask a natural-language question, and get a table-backed answer plus CSV download. +- [ ] The answer includes the generated SQL / pandas code, row count, tables touched, and latency. +- [ ] When the live MsSQL DB is unreachable, the agent pivots transparently to the locally cached snapshot without an error. +- [ ] For a core analytical question on 10M-row live tables, a cached or indexed answer returns in under 3 seconds end-to-end. +- [ ] Every query is auditable: timestamp, user, question, data sources, SQL/code, row count, latency, token spend. +- [ ] The Phase 1 live-server smoke against the real LLM key passes and the human test gate approves CSVs + Q&A. + +## Key Assumptions (from intake, cannot ask user) -- [ ] -- [ ] -- [ ] +> **Assumed:** "All of the above" for all session models, memory, error handling, output forms, proactive hints, scale, privacy, reliability, transparency, cost signals, progress signals, and audit — i.e. the agent must support every option in each dimension where the user selected all. +> **Assumed:** "All of the above" for stack: baseline Python + FastAPI + SQLite for CSV mode (dev); PostgreSQL for query state / metadata / sessions / audit trail; MsSQL via pyodbc for live production queries. +> **Assumed:** LLM provider is **NVIDIA NIM** using its OpenAI-compatible endpoint. +> **Assumed:** NIM base URL, model slug, and API key will be supplied by the user in `.env` via standard `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL` style variables added by the build. +> **Assumed:** Phase 1 delivers the smallest end-to-end win: single CSV upload + one Q&A turn with table + CSV export + generated SQL + audit log. No live MsSQL in Phase 1. +> **Assumed:** Phase 2 adds live MsSQL read-only access with cache-first fallback. +> **Assumed:** Role-based access control, junior-sensitive warnings, and exportable audit reports are included from Phase 1 (cannot be deferred — user selected all). ## What This Agent Does NOT Do (Out of Scope) - +- Write to the MsSQL database. All live queries are read-only. +- Handle non-tabular unstructured data without a parser step outside Phase 1 scope. +- Replace RMS / core police systems — it is an analyst layer on top of exports / read replicas. +- Real-time alerting / streaming ingestion (explicitly excluded for now). ## Key Constraints - +- **Privacy & residency:** rows must never leave the police network or approved boundary. +- **Cost:** prefer OSS and caching; minimise LLM token spend. +- **Latency:** cached / small-CSV answers must feel near-instant; large live queries tolerate a few seconds. +- **Reliability:** when the live DB is slow / under maintenance, the agent must stay usable via offline (CSV) or cached snapshot mode. +- **Production bar:** audited, error-resilient, role-aware, with a full audit trail. ## Phases of Development - - -> **Phase 1 is the smallest first-time-right user-testable win.** It must work perfectly the first time the user tests it — zero rough edges on the tested path. Its backend is minimal but REAL on the one core path (no fake data on the tested path). Its frontend is visually complete: real UI for the one working path PLUS clearly-labelled NON-FUNCTIONAL stubs for everything coming later, so the user sees the vision (a stub must never be mistaken for a bug). Each later phase wires those stubs into real functionality, one increment at a time. - -### Phase 1 — - -- **Goal:** -- **Independent slices (parallel build units):** - - `slice-a` (backend) — - - `slice-b` (frontend) — -- **Key surfaces / files:** -- **Gate command:** -- **How the user tests it (handoff seed):** - -### Phase 2 — - -- **Goal:** -- **Independent slices (parallel build units):** - - `slice-a` (backend) — - - `slice-b` (frontend) — -- **Key surfaces / files:** -- **Gate command:** -- **How the user tests it (handoff seed):** - - - +> **Phase 1 is the smallest first-time-right user-testable win.** The tested path is CSV upload + one natural-language question → answer with table + CSV download + generated SQL + audit log entry. All other surfaces are clearly-labelled non-functional stubs. + +### Phase 1 — CSV Analyst (offline-first core loop) + +- **Goal:** One working end-to-end analyst loop over uploaded CSVs, with a real LLM call and an audit log. +- **Independent slices:** + - `slice-a` (backend) — CSV ingestion, schema inference, pandas-backed SQL runner, answer assembly, audit logging. + - `slice-b` (frontend) — upload form, Q&A panel, results table, CSV download, stubs for MsSQL + charts + saved workspaces. +- **Key surfaces / files:** + - Backend: new capability module under `src/graph/nodes.py` (CSV path), `src/prompts/csv_analyst.md`, new API routes in `src/api/runs.py` or a new `src/api/csv.py`, DB models in `src/db/models.py`, migrations in `alembic/`, tests in `tests/unit` and `tests/integration`. + - Frontend: `frontend/public/index.html`, `frontend/public/styles.css`, `frontend/public/app.js`. +- **Gate command (real LLM + real SQLite/pandas flow):** `uv run pytest tests/integration/test_csv_analyst.py -q` +- **How the user tests it (handoff seed):** + - Run `.venv/bin/python -m src` and open `/app`. + - Upload a small CSV with 2 000–5 000 rows of dummy crime data. + - Ask "How many rows in this file?" and "Which district has the highest count by offence?" + - Expected: table answer, CSV download button works, an audit row is created, "Live DB" tab shows a stub badge. + +### Phase 2 — Live MsSQL + Cache Fallback + +- **Goal:** Add read-only live MsSQL access with a local cache so the agent answers quickly even when the production DB is busy or unreachable. +- **Independent slices:** + - `slice-a` (backend) — MsSQL read-only adapter via pyodbc / asyncodbc / SQLAlchemy+MSSQL, schema introspection, cache materialisation, cache invalidation policy. + - `slice-b` (frontend) — data-source selector (CSV vs Live DB), cache-status indicator, connection-test output. +- **Key surfaces / files:** + - Backend: new DB module `src/db/mssql.py`, cache tables, new nodes/edges in `src/graph/nodes.py` for live-vs-cache routing. + - Frontend: new controls in `frontend/public/app.js` and markup. +- **Gate command:** `uv run pytest tests/integration/test_mssql_cache.py -q` +- **How the user tests it (handoff seed):** + - With MsSQL connection configured in `.env`, ask a known count-level question. + - Disconnect the MsSQL endpoint; confirm the same question still returns from the local cache with a "served from cache" indicator. + - Reconnect; confirm new queries hit the live DB and refresh the cache. + +### Phase 3 — Polish, Roles, and Production Hardening + +- **Goal:** Role enforcement, charting/export richness, saved workspaces, supervisor audit export, and production resilience. +- **Independent slices:** + - `slice-a` (backend) — RBAC middleware, per-query audit export endpoint, chart data API, saved-query / workspace tables. + - `slice-b` (frontend) — login-role-aware UI, chart renderer, supervisor audit export panel, saved-workspace manager. +- **Key surfaces / files:** + - Backend: auth guards, PDF/CSV export, additional tests plus load / failure-mode tests. + - Frontend: charts, export buttons, saved workspace list. +- **Gate command:** `uv run pytest tests -q` +- **How the user tests it (handoff seed):** + - Log in as supervisor; export full audit CSV. + - Log in as investigator; confirm junior-sensitive warning is shown on flagged queries. + - Verify charts render for a time-series question. diff --git a/spec/ui.md b/spec/ui.md index 15219c3..1581554 100644 --- a/spec/ui.md +++ b/spec/ui.md @@ -1,32 +1,35 @@ # UI -> **Boilerplate status:** Delete this file if the agent has no UI. Otherwise, filled in by the spec-writer sub-agent. - ---- - -## UI Type - - - -## Views / Screens - - - -### Screen: - -**Purpose:** - -**Key elements:** -- -- - -**Actions available:** -- - -## Error States - - - -## Tech Stack - - +> **Assumed:** `frontend/public/` static bundle served by FastAPI. Phase 1 is a single upload/Q&A screen with clearly-labelled non-functional stubs for MsSQL, saved workspaces, charts, and audit export. +> **Assumed:** no JS bundler or framework in Phase 1. + +## Screens + +### `#app` — Primary workspace + +- **Upload pane:** drag-and-drop / file picker for one or more CSVs; shows file name, row count, and schema after ingest. +- **Source selector:** toggle between Uploaded CSV and Live DB. Live DB is a clearly-labelled **stub** in Phase 1 that returns a "coming in Phase 2" notice. +- **Ask bar:** text input + Ask button. +- **Progress signal:** step counter + timer during the query (planned → generated SQL → executed → assembling answer). +- **Answer panel:** + - Narrative answer text. + - Result table (sortable columns). + - Metadata bar: tables touched, row count, latency, provider, model. + - Generated SQL block with copy button. + - Follow-up suggestions + anomaly flags + sensitive-category warning when applicable. +- **Action bar:** + - Download CSV of the result set. + - Save workspace (stub in Phase 1 — labelled "coming in Phase 2"). + +### Stubs in Phase 1 (clearly labelled, visually styled as disabled) + +- **Charts panel:** shows "Charts – Phase 2" placeholder. +- **Saved workspaces list:** shows "Saved workspaces – Phase 2" placeholder. +- **Supervisor audit export:** shows "Supervisor export – Phase 3" placeholder. +- **Role switcher:** shows "Role-based access – Phase 3" placeholder. + +## Frontend rules + +- All text is plain English with police-domain examples preloaded as placeholder hints. +- Errors are shown inline, never as raw JSON or stack traces. +- The generated SQL block is aware that the user may need to audit or forward it; make it easy to copy. diff --git a/src/api/__init__.py b/src/api/__init__.py index 4ef00c3..1d04401 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -24,10 +24,12 @@ async def _lifespan(app: FastAPI): def create_app() -> FastAPI: app = FastAPI(title="Zero-Shot Agent", version="0.1.0", lifespan=_lifespan) - from src.api import health, runs + from src.api import csv, health, live_db, runs app.include_router(health.router) app.include_router(runs.router) + app.include_router(csv.router, prefix="/csv") + app.include_router(live_db.router, prefix="/live-db") if _FRONTEND_DIR.is_dir(): app.mount("/app", StaticFiles(directory=_FRONTEND_DIR, html=True), name="frontend") diff --git a/src/api/csv.py b/src/api/csv.py new file mode 100644 index 0000000..599e47a --- /dev/null +++ b/src/api/csv.py @@ -0,0 +1,117 @@ +"""CSV analyst API — upload + query over CSV data.""" +from __future__ import annotations + +import csv +import io +import json +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from sqlalchemy.orm import Session + +from src.api._common import api_error, ok +from src.config.settings import get_settings +from src.db.models import AuditRow, CSVUploadRow, RunRow +from src.db.session import get_session +from src.domain.csv_analyst import CSVQueryRequest, CSVQueryResponse, CSVUploadResponse +from src.graph.runner_csv import run_csv_agent +from src.observability.events import get_logger + +router = APIRouter() +log = get_logger("csv_api") + + +@router.post("/upload") +async def upload_csv(file: UploadFile = File(...), session: Session = Depends(get_session)) -> dict: + if not file.filename or not file.filename.lower().endswith(".csv"): + raise api_error("bad_request", "Upload a single .csv file.", 400) + + settings = get_settings() + workspace_dir = Path(settings.database_url.replace("sqlite:///", "")).parent / "csv_workspace" + workspace_dir.mkdir(parents=True, exist_ok=True) + target = workspace_dir / file.filename + try: + content = await file.read() + target.write_bytes(content) + text = content.decode("utf-8", errors="strict") + reader = csv.reader(io.StringIO(text)) + header = next(reader) + row_count = sum(1 for _ in reader) + except Exception as exc: + raise api_error("bad_csv", f"Failed to parse {file.filename}: {exc}", 400) from exc + + columns = [c.strip().lower().replace(" ", "_") for c in header] + fingerprint = json.dumps(sorted(columns)) + row = CSVUploadRow(file_name=file.filename, row_count=row_count, columns=json.dumps(columns), schema_fingerprint=fingerprint) + session.add(row) + session.flush() + session.commit() + result = CSVUploadResponse(file_id=row.id, file_name=file.filename, row_count=row_count, columns=columns, schema_fingerprint=fingerprint) + return ok(result.model_dump()) + + +@router.post("/query") +def query_csv(req: CSVQueryRequest, session: Session = Depends(get_session)) -> dict: + if req.data_source != "csv" or not req.csv_file_ids: + raise api_error("bad_request", "CSV queries require data_source='csv' and csv_file_ids.", 400) + + run_id = run_csv_agent(session, question=req.question, csv_file_ids=req.csv_file_ids, row_limit=req.row_limit or get_settings().analyst_default_row_limit) + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"run {run_id} vanished", 500) + + audit = session.query(AuditRow).filter(AuditRow.run_id == run_id).first() + response = CSVQueryResponse( + run_id=run.id, + status=run.status, + answer_text=run.output_text, + generated_sql=(audit.sql if audit else None), + tables_touched=(json.loads(audit.tables_touched) if audit and audit.tables_touched else None), + executed_row_count=(audit.row_count if audit else None), + latency_ms=(audit.latency_ms if audit else None), + provider=run.provider, + model=run.model, + error=run.error_message, + ) + return ok(response.model_dump()) + + +@router.get("/runs/{run_id}") +def get_csv_run(run_id: str, session: Session = Depends(get_session)) -> dict: + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"no run with id {run_id}", 404) + + audit = session.query(AuditRow).filter(AuditRow.run_id == run_id).first() + response = CSVQueryResponse( + run_id=run.id, + status=run.status, + answer_text=run.output_text, + generated_sql=(audit.sql if audit else None), + tables_touched=(json.loads(audit.tables_touched) if audit and audit.tables_touched else None), + executed_row_count=(audit.row_count if audit else None), + latency_ms=(audit.latency_ms if audit else None), + provider=run.provider, + model=run.model, + error=run.error_message, + result_table=None, + ) + return ok(response.model_dump()) + + +@router.get("/runs/{run_id}/download") +def download_csv(run_id: str, session: Session = Depends(get_session)) -> dict: + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"no run with id {run_id}", 404) + + audit = session.query(AuditRow).filter(AuditRow.run_id == run_id).first() + if not audit or not run.output_text: + raise api_error("not_ready", "No result available for download yet.", 404) + + return { + "run_id": run_id, + "file_name": f"result_{run_id}.csv", + "content": run.output_text, + "content_type": "text/csv", + } diff --git a/src/api/live_db.py b/src/api/live_db.py new file mode 100644 index 0000000..60dd768 --- /dev/null +++ b/src/api/live_db.py @@ -0,0 +1,190 @@ +"""Live DB analyst API — read-only queries over live SQL Server.""" +from __future__ import annotations + +import json +import time + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from src.api._common import api_error, ok +from src.config.settings import get_settings +from src.db.models import AuditRow, RunRow +from src.db.session import get_session +from src.db.live_db import LiveDBQueryError, read_only_query +from src.domain.live_db_analyst import LiveDBQueryRequest, LiveDBQueryResponse +from src.graph.agent_live_db import build_live_db_graph +from src.graph.state import AgentState +from src.llm.client import LLMClient, load_prompt +from src.observability.events import get_logger + +router = APIRouter() +log = get_logger("live_db_api") + +live_db_api_spec = { + "base_path": "/live-db", + "paths": { + "query": "/live-db/query", + "run_detail": "/live-db/runs/{run_id}", + }, + "schema": { + "request": { + "question": "string", + "schema_summary": "string | null", + "row_limit": "integer | null", + }, + "response": { + "run_id": "string", + "status": "string", + "generated_sql": "string | null", + "answer_text": "string | null", + "result_table": "object | null", + "served_from_cache": "boolean", + }, + }, + "notes": "Read-only SQL Server path. schema_summary is required.", +} + + +@router.post("/query") +def query_live_db(req: LiveDBQueryRequest, session: Session = Depends(get_session)) -> dict: + if not (req.schema_summary or "").strip(): + raise api_error("bad_request", "schema_summary is required for live_db queries.", 400) + + state: AgentState = { + "question": req.question, + "schema_summary": (req.schema_summary or "").strip(), + "row_limit": req.row_limit or 5000, + "query_plan": None, + "tables_touched": [], + "generated_code": None, + "executed_sql": None, + "executed_rows": None, + "executed_columns": None, + "executed_row_count": None, + "latency_ms": None, + "result_table": None, + "answer_text": None, + "followups": None, + "anomaly_flags": None, + "sensitive_warning": None, + "provider": None, + "model": None, + "token_usage": None, + "status": None, + "error": None, + } + + run = RunRow(input_text=req.question, instruction="", status="running") + session.add(run) + session.flush() + run_id = run.id + + try: + graph = build_live_db_graph() + start = time.perf_counter() + try: + final = graph.invoke(state) + except Exception as exc: + final = { + "error": str(exc), + "status": "failed", + "provider": None, + "model": None, + "answer_text": "", + "executed_sql": None, + "tables_touched": [], + "executed_columns": None, + "executed_rows": None, + "executed_row_count": None, + "latency_ms": int((time.perf_counter() - start) * 1000), + "result_table": None, + } + latency_ms = final.get("latency_ms") or int((time.perf_counter() - start) * 1000) + status = final.get("status") or "failed" + run.status = status + run.output_text = final.get("answer_text") or "" + run.provider = final.get("provider") + run.model = final.get("model") + run.error_message = final.get("error") + audit = AuditRow( + run_id=run_id, + question=req.question, + sql=final.get("executed_sql"), + tables_touched=json.dumps(final.get("tables_touched") or []), + row_count=final.get("executed_row_count"), + latency_ms=latency_ms, + token_usage=json.dumps(final.get("token_usage") or {}), + ) + session.add(audit) + session.commit() + except Exception as exc: + run.status = "failed" + run.error_message = str(exc) + session.commit() + raise + + columns = final.get("executed_columns") or [] + rows = final.get("executed_rows") or [] + result_table = {"columns": columns, "rows": rows} if columns else None + response = LiveDBQueryResponse( + run_id=run_id, + status=run.status, + generated_sql=final.get("executed_sql"), + tables_touched=final.get("tables_touched") or [], + executed_row_count=final.get("executed_row_count"), + latency_ms=latency_ms, + provider=run.provider, + model=run.model, + error=run.error_message, + answer_text=run.output_text, + result_table=result_table, + served_from_cache=False, + ) + return ok(response.model_dump()) + + +@router.get("/runs/{run_id}") +def get_live_db_run(run_id: str, session: Session = Depends(get_session)) -> dict: + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"no run with id {run_id}", 404) + audit = session.query(AuditRow).filter(AuditRow.run_id == run_id).first() + response = LiveDBQueryResponse( + run_id=run.id, + status=run.status, + generated_sql=(audit.sql if audit else None), + tables_touched=(json.loads(audit.tables_touched) if audit and audit.tables_touched else []), + executed_row_count=(audit.row_count if audit else None), + latency_ms=(audit.latency_ms if audit else None), + provider=run.provider, + model=run.model, + error=run.error_message, + answer_text=run.output_text, + ) + return ok(response.model_dump()) + + +@router.get("/runs/{run_id}/download") +def download_live_db(run_id: str, session: Session = Depends(get_session)) -> dict: + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"no run with id {run_id}", 404) + + audit = session.query(AuditRow).filter(AuditRow.run_id == run_id).first() + result_table = None + if audit and audit.sql: + try: + columns, rows = read_only_query(audit.sql) + result_table = {"columns": columns, "rows": rows} + except Exception: + result_table = None + + return { + "run_id": run_id, + "file_name": f"result_{run_id}.csv", + "content": run.output_text or "", + "content_type": "text/csv", + "result_table": result_table, + "served_from_cache": False, + } diff --git a/src/api/runs.py b/src/api/runs.py index c8f2895..62ac8de 100644 --- a/src/api/runs.py +++ b/src/api/runs.py @@ -1,11 +1,15 @@ """Runs API — POST /runs executes the agent; GET /runs/{id} fetches a run.""" from __future__ import annotations +import json +from typing import Any + from fastapi import APIRouter, Depends +from sqlalchemy import desc from sqlalchemy.orm import Session from src.api._common import api_error, ok -from src.db.models import RunRow +from src.db.models import AuditRow, RunRow from src.db.session import get_session from src.domain import RunRequest, RunResult from src.graph.runner import run_agent @@ -14,30 +18,78 @@ def _to_result(run: RunRow) -> RunResult: - return RunResult( - run_id=run.id, - status=run.status, - output_text=run.output_text, - provider=run.provider, - model=run.model, - error_message=run.error_message, - ) + return RunResult( + run_id=run.id, + status=run.status, + output_text=run.output_text, + provider=run.provider, + model=run.model, + error_message=run.error_message, + ) @router.post("/runs") def create_run(req: RunRequest, session: Session = Depends(get_session)) -> dict: - run_id = run_agent(req.text, req.instruction) - run = session.get(RunRow, run_id) - if run is None: # pragma: no cover — write happened in run_agent - raise api_error("run_not_found", f"run {run_id} vanished", 500) - if run.status == "failed": - return ok(_to_result(run).model_dump()) # error surfaced in envelope, not a 500 - return ok(_to_result(run).model_dump()) + run_id = run_agent(req.text, req.instruction) + run = session.get(RunRow, run_id) + if run is None: # pragma: no cover — write happened in run_agent + raise api_error("run_not_found", f"run {run_id} vanished", 500) + if run.status == "failed": + return ok(_to_result(run).model_dump()) + return ok(_to_result(run).model_dump()) @router.get("/runs/{run_id}") def get_run(run_id: str, session: Session = Depends(get_session)) -> dict: - run = session.get(RunRow, run_id) - if run is None: - raise api_error("run_not_found", f"no run with id {run_id}", 404) - return ok(_to_result(run).model_dump()) + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"no run with id {run_id}", 404) + return ok(_to_result(run).model_dump()) + + +def _serialize_audit(audit: AuditRow | None) -> dict[str, Any] | None: + if audit is None: + return None + return { + "run_id": audit.run_id, + "question": audit.question, + "sql": audit.sql, + "tables_touched": json.loads(audit.tables_touched) if audit.tables_touched else [], + "row_count": audit.row_count, + "latency_ms": audit.latency_ms, + "token_usage": json.loads(audit.token_usage) if audit.token_usage else {}, + "created_at": audit.created_at.isoformat() if audit.created_at else None, + } + + +def _serialize_run(run: RunRow) -> dict[str, Any]: + return { + "run_id": run.id, + "status": run.status, + "input_text": run.input_text, + "instruction": run.instruction, + "output_text": run.output_text, + "provider": run.provider, + "model": run.model, + "error_message": run.error_message, + "created_at": run.created_at.isoformat() if run.created_at else None, + "updated_at": run.updated_at.isoformat() if run.updated_at else None, + } + + +@router.get("/runs") +def list_runs(limit: int = 100, session: Session = Depends(get_session)) -> dict: + rows = session.query(RunRow).order_by(desc(RunRow.created_at)).limit(max(1, min(limit, 500))).all() + return ok([_serialize_run(run) for run in rows]) + + +@router.get("/runs/{run_id}/audit") +def get_run_audit(run_id: str, session: Session = Depends(get_session)) -> dict: + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"no run with id {run_id}", 404) + audit = session.query(AuditRow).filter(AuditRow.run_id == run_id).order_by(desc(AuditRow.created_at)).first() + return ok({ + "run": _serialize_run(run), + "audit": _serialize_audit(audit), + }) diff --git a/src/config/settings.py b/src/config/settings.py index 19e5688..23b5e13 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -1,75 +1,106 @@ -"""Application settings — Pydantic BaseSettings, env prefix ``AGENT_``. +"""Application settings — Pydantic BaseSettings, env prefix ``AGENT_`` (and raw OpenAI-compatible vars). The provider key is loaded from ``.env`` (the single manual user step). Presence is checked by ``bool`` only — the value is never echoed, logged, or committed. """ from __future__ import annotations +from pathlib import Path + from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict -# Provider defaults used when AGENT_LLM_MODEL is blank. Verify against current -# provider docs before pinning — a 404 from the LLM API usually means a stale name. +# Provider defaults used when AGENT_LLM_MODEL / provider-specific model is blank. +# Verify against current provider docs before pinning — a 404 usually means a stale name. DEFAULT_MODELS = { - "anthropic": "claude-sonnet-4-6", - "gemini": "gemini-2.5-flash", - "openrouter": "tencent/hy3", # cheap default ($0.14/M in) — frontier models 402 on unfunded keys; override via AGENT_LLM_MODEL + "anthropic": "claude-sonnet-4-6", + "gemini": "gemini-2.5-flash", + "openrouter": "tencent/hy3", # cheap default — frontier models 402 on unfunded keys; override via AGENT_LLM_MODEL + "nim": "meta/llama-3-8b-instruct", # NVIDIA NIM OpenAI-compatible path } class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_prefix="AGENT_", - env_file=".env", - case_sensitive=False, - extra="ignore", - ) - - database_url: str = Field(default="sqlite:///./data/app.db") - - # "auto" resolves to whichever provider key is set. - llm_provider: str = Field(default="auto") - llm_model: str = Field(default="") - - anthropic_api_key: str = Field(default="") - gemini_api_key: str = Field(default="") - openrouter_api_key: str = Field(default="") - openrouter_base_url: str = Field(default="https://openrouter.ai/api/v1") - - log_level: str = Field(default="INFO") - - # ----- derived ----- - def resolve_provider(self) -> str: - """The effective provider name, or ``"stub"`` when no key is present.""" - p = (self.llm_provider or "auto").strip().lower() - if p != "auto": - return p - if self.anthropic_api_key: - return "anthropic" - if self.gemini_api_key: - return "gemini" - if self.openrouter_api_key: - return "openrouter" - return "stub" - - def resolve_model(self) -> str: - if self.llm_model: - return self.llm_model - return DEFAULT_MODELS.get(self.resolve_provider(), "") - - def key_for(self, provider: str) -> str: - return { - "anthropic": self.anthropic_api_key, - "gemini": self.gemini_api_key, - "openrouter": self.openrouter_api_key, - }.get(provider, "") + model_config = SettingsConfigDict( + env_prefix="AGENT_", + env_file=".env", + case_sensitive=False, + extra="ignore", + ) + + database_url: str = Field(default="sqlite:///./data/app.db") + + # --- provider selection --- + llm_provider: str = Field(default="auto") + llm_model: str = Field(default="") + + # --- legacy provider keys (still supported) --- + anthropic_api_key: str = Field(default="") + gemini_api_key: str = Field(default="") + openrouter_api_key: str = Field(default="") + openrouter_base_url: str = Field(default="https://openrouter.ai/api/v1") + + # --- OpenAI-compatible / NIM path --- + openai_api_key: str = Field(default="") + openai_base_url: str = Field(default="https://integrate.api.nvidia.com/v1") + openai_model: str = Field(default="") + + log_level: str = Field(default="INFO") + + # --- OpenAI-compatible provider key alias (OpenRouter and NIM both use this auth scheme) --- + openai_compat_api_key: str = Field(default="") + + # --- CSV / analyst options --- + analyst_default_row_limit: int = Field(default=100_000) + live_db_url: str = Field(default="") + + # ----- helpers ----- + def resolve_provider(self) -> str: + if self.llm_provider and self.llm_provider.strip().lower() != "auto": + p = self.llm_provider.strip().lower() + if p in {"anthropic", "gemini", "openrouter", "nim"}: + return p + if self.anthropic_api_key.strip(): + return "anthropic" + if self.gemini_api_key.strip(): + return "gemini" + # Dedicated OpenRouter key should resolve to openrouter even when an + # OpenAI-compatible base URL is also present. + if self.openrouter_api_key.strip(): + return "openrouter" + # OpenAI-compatible / NIM path. + openai_key = (self.openai_api_key or self.openai_compat_api_key or "").strip() + if openai_key: + base = (self.openai_base_url or "").lower() + if "nvidia" in base or "nim" in base or "integrate.api.nvidia" in base: + return "nim" + return "openrouter" + return "stub" + + def resolve_model(self) -> str: + if self.llm_model.strip(): + return self.llm_model.strip() + provider = self.resolve_provider() + if provider == "nim": + return (self.openai_model or DEFAULT_MODELS.get("nim", "")).strip() + return (DEFAULT_MODELS.get(provider) or "").strip() + + def key_for(self, provider: str) -> str: + p = (provider or "").strip().lower() + if p == "nim": + return (self.openai_api_key or self.openrouter_api_key or self.openai_compat_api_key or "").strip() + return { + "anthropic": self.anthropic_api_key, + "gemini": self.gemini_api_key, + "openrouter": self.openrouter_api_key, + }.get(p, "").strip() _settings: Settings | None = None def get_settings() -> Settings: - global _settings - if _settings is None: - _settings = Settings() - return _settings + global _settings + if _settings is None: + _settings = Settings() + return _settings diff --git a/src/db/live_db.py b/src/db/live_db.py new file mode 100644 index 0000000..afb9eb5 --- /dev/null +++ b/src/db/live_db.py @@ -0,0 +1,37 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.exc import SQLAlchemyError + +from src.config.settings import get_settings + + +def _engine(): + settings = get_settings() + url = settings.live_db_url + if not url: + raise RuntimeError("AGENT_LIVE_DB_URL is not configured") + return create_engine(url, pool_pre_ping=True, pool_size=5, max_overflow=10) + + +class LiveDBQueryError(Exception): + pass + + +def read_only_query(sql: str) -> tuple[list[str], list[dict]]: + sql = sql.strip() + lowered = sql.lower() + if not lowered.startswith("select"): + raise LiveDBQueryError("Only read-only SELECT queries are allowed.") + forbidden = ["insert ", "update ", "delete ", "drop ", "alter ", "truncate "] + if any(token in lowered for token in forbidden): + raise LiveDBQueryError("Query contains forbidden clauses or statements.") + engine = _engine() + try: + with engine.connect() as conn: + cursor = conn.execute(text(sql)) + if cursor.returns_rows: + columns = list(cursor.keys()) + rows = [dict(zip(columns, row)) for row in cursor.fetchall()] + return columns, rows + return [], [] + except SQLAlchemyError as exc: + raise LiveDBQueryError(f"Query failed: {exc}") from exc diff --git a/src/db/models.py b/src/db/models.py index d4404f8..f8fc845 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -4,38 +4,72 @@ from datetime import datetime, timezone from uuid import uuid4 -from sqlalchemy import TIMESTAMP, Text +from sqlalchemy import TIMESTAMP, Integer, Text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column def _uuid() -> str: - return str(uuid4()) + return str(uuid4()) def _now() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(timezone.utc) class Base(DeclarativeBase): - pass + pass class RunRow(Base): - """One agent run: input → output, with status + error for observability.""" - - __tablename__ = "runs" - - id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) - status: Mapped[str] = mapped_column(Text, nullable=False, default="pending") - input_text: Mapped[str] = mapped_column(Text, nullable=False, default="") - instruction: Mapped[str] = mapped_column(Text, nullable=False, default="") - output_text: Mapped[str | None] = mapped_column(Text, nullable=True) - provider: Mapped[str | None] = mapped_column(Text, nullable=True) - model: Mapped[str | None] = mapped_column(Text, nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), nullable=False, default=_now - ) - updated_at: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), nullable=False, default=_now, onupdate=_now - ) + """One agent run: input -> output, with status + error for observability.""" + + __tablename__ = "runs" + + id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) + status: Mapped[str] = mapped_column(Text, nullable=False, default="pending") + input_text: Mapped[str] = mapped_column(Text, nullable=False, default="") + instruction: Mapped[str] = mapped_column(Text, nullable=False, default="") + output_text: Mapped[str | None] = mapped_column(Text, nullable=True) + provider: Mapped[str | None] = mapped_column(Text, nullable=True) + model: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, default=_now + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, default=_now, onupdate=_now + ) + + +class CSVUploadRow(Base): + """One uploaded CSV file registered for analysis.""" + + __tablename__ = "csv_uploads" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + file_name: Mapped[str] = mapped_column(Text, nullable=False) + row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + columns: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON list + schema_fingerprint: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, default=_now + ) + + +class AuditRow(Base): + """Append-only audit row for analyst queries.""" + + __tablename__ = "audit_rows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_id: Mapped[str | None] = mapped_column(Text, nullable=True) + user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + question: Mapped[str] = mapped_column(Text, nullable=False) + sql: Mapped[str | None] = mapped_column(Text, nullable=True) + tables_touched: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON list + row_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + token_usage: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON object + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, default=_now + ) diff --git a/src/domain/csv_analyst.py b/src/domain/csv_analyst.py new file mode 100644 index 0000000..577278c --- /dev/null +++ b/src/domain/csv_analyst.py @@ -0,0 +1,52 @@ +"""Domain models — CSV analyst request/response shapes.""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class CSVUploadResponse(BaseModel): + file_id: int + file_name: str + row_count: int + columns: list[str] + schema_fingerprint: str + + +class CSVQueryRequest(BaseModel): + question: str = Field(..., min_length=1, max_length=2000) + data_source: str = Field(default="csv", pattern="^(csv|live_db|cache)$") + csv_file_ids: list[int] | None = None + workspace_id: int | None = None + row_limit: int | None = Field(default=None, ge=1, le=1_000_000) + + +class CSVQueryResponse(BaseModel): + run_id: str | int + status: str + answer_text: str | None = None + result_table: dict | None = None + generated_sql: str | None = None + tables_touched: list[str] | None = None + executed_row_count: int | None = None + latency_ms: int | None = None + provider: str | None = None + model: str | None = None + csv_download_url: str | None = None + followups: list[str] | None = None + anomaly_flags: list[str] | None = None + sensitive_warning: str | None = None + served_from_cache: bool = False + error: str | None = None + + +class AuditRow(BaseModel): + audit_id: int + run_id: str | int + user_id: int | None = None + question: str + sql: str | None = None + tables_touched: list[str] | None = None + row_count: int | None = None + latency_ms: int | None = None + token_usage: dict | None = None + created_at: str | None = None diff --git a/src/domain/live_db_analyst.py b/src/domain/live_db_analyst.py new file mode 100644 index 0000000..fe59fb3 --- /dev/null +++ b/src/domain/live_db_analyst.py @@ -0,0 +1,26 @@ +"""Domain models — live database analyst shapes.""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class LiveDBQueryRequest(BaseModel): + question: str = Field(..., min_length=1, max_length=5_000) + schema_summary: str | None = Field(default=None, max_length=20_000) + row_limit: int | None = Field(default=None, ge=1, le=500_000) + + +class LiveDBQueryResponse(BaseModel): + run_id: str + status: str + data_source: str = "live_db" + generated_sql: str | None = None + tables_touched: list[str] | None = None + executed_row_count: int | None = None + latency_ms: int | None = None + provider: str | None = None + model: str | None = None + error: str | None = None + answer_text: str | None = None + result_table: dict | None = None + served_from_cache: bool = False diff --git a/src/graph/agent_csv.py b/src/graph/agent_csv.py new file mode 100644 index 0000000..8b79644 --- /dev/null +++ b/src/graph/agent_csv.py @@ -0,0 +1,27 @@ +"""Graph assembly — StateGraph compiled once at import.""" +from __future__ import annotations + +from langgraph.graph import END, StateGraph + +from src.graph.edges_csv import after_assemble_answer, after_execute_query, after_generate_code, after_plan_query +from src.graph.nodes_csv import assemble_answer, execute_query, finalize, generate_code, handle_error, plan_query +from src.graph.state import AgentState + + +def build_csv_graph(): + g = StateGraph(AgentState) + g.add_node("plan_query", plan_query) + g.add_node("generate_code", generate_code) + g.add_node("execute_query", execute_query) + g.add_node("assemble_answer", assemble_answer) + g.add_node("finalize", finalize) + g.add_node("handle_error", handle_error) + + g.set_entry_point("plan_query") + g.add_conditional_edges("plan_query", after_plan_query, {"generate_code": "generate_code", "handle_error": "handle_error"}) + g.add_conditional_edges("generate_code", after_generate_code, {"execute_query": "execute_query", "handle_error": "handle_error"}) + g.add_conditional_edges("execute_query", after_execute_query, {"assemble_answer": "assemble_answer", "handle_error": "handle_error"}) + g.add_conditional_edges("assemble_answer", after_assemble_answer, {"finalize": "finalize", "handle_error": "handle_error"}) + g.add_edge("finalize", END) + g.add_edge("handle_error", END) + return g.compile() diff --git a/src/graph/agent_live_db.py b/src/graph/agent_live_db.py new file mode 100644 index 0000000..3018982 --- /dev/null +++ b/src/graph/agent_live_db.py @@ -0,0 +1,27 @@ +"""Graph assembly — StateGraph compiled once at import for live DB.""" +from __future__ import annotations + +from langgraph.graph import END, StateGraph + +from src.graph.edges_live_db import after_assemble_answer, after_execute_query, after_generate_code, after_plan_query +from src.graph.nodes_live_db import assemble_answer, execute_query, finalize, generate_code, handle_failure, plan_query +from src.graph.state import AgentState + + +def build_live_db_graph(): + g = StateGraph(AgentState) + g.add_node("plan_query", plan_query) + g.add_node("generate_code", generate_code) + g.add_node("execute_query", execute_query) + g.add_node("assemble_answer", assemble_answer) + g.add_node("finalize", finalize) + g.add_node("handle_failure", handle_failure) + + g.set_entry_point("plan_query") + g.add_conditional_edges("plan_query", after_plan_query, {"generate_code": "generate_code", "handle_failure": "handle_failure"}) + g.add_conditional_edges("generate_code", after_generate_code, {"execute_query": "execute_query", "handle_failure": "handle_failure"}) + g.add_conditional_edges("execute_query", after_execute_query, {"assemble_answer": "assemble_answer", "handle_failure": "handle_failure"}) + g.add_conditional_edges("assemble_answer", after_assemble_answer, {"finalize": "finalize", "handle_failure": "handle_failure"}) + g.add_edge("finalize", END) + g.add_edge("handle_failure", END) + return g.compile() diff --git a/src/graph/edges_csv.py b/src/graph/edges_csv.py new file mode 100644 index 0000000..4346055 --- /dev/null +++ b/src/graph/edges_csv.py @@ -0,0 +1,21 @@ +"""Conditional routing functions for the CSV analyst graph.""" +from __future__ import annotations + +from src.graph.state import AgentState + + +def after_plan_query(state: AgentState) -> str: + return "handle_error" if state.get("error") else "generate_code" + + +def after_generate_code(state: AgentState) -> str: + return "handle_error" if state.get("error") else "execute_query" + + +def after_execute_query(state: AgentState) -> str: + # Even on query failure we try to assemble a best-guess answer with the error shown. + return "assemble_answer" + + +def after_assemble_answer(state: AgentState) -> str: + return "handle_error" if state.get("error") else "finalize" diff --git a/src/graph/edges_live_db.py b/src/graph/edges_live_db.py new file mode 100644 index 0000000..7e0d149 --- /dev/null +++ b/src/graph/edges_live_db.py @@ -0,0 +1,28 @@ +"""Conditional routing functions for the live DB analyst graph.""" +from __future__ import annotations + +from src.graph.state import AgentState + + +def after_plan_query(state: AgentState) -> str: + if state.get("error"): + return "handle_failure" + return "generate_code" + + +def after_generate_code(state: AgentState) -> str: + if state.get("error"): + return "handle_failure" + return "execute_query" + + +def after_execute_query(state: AgentState) -> str: + if state.get("error"): + return "handle_failure" + return "assemble_answer" + + +def after_assemble_answer(state: AgentState) -> str: + if state.get("error"): + return "handle_failure" + return "finalize" diff --git a/src/graph/nodes_csv.py b/src/graph/nodes_csv.py new file mode 100644 index 0000000..2591f6e --- /dev/null +++ b/src/graph/nodes_csv.py @@ -0,0 +1,141 @@ +"""Graph nodes — CSV analyst path. + +Each node reads from and writes to ``AgentState``. Failures go into ``state["error"]`` +so the conditional edges route to ``handle_error``. LLM calls are batched: one call +per node that needs the model. +""" +from __future__ import annotations + +import json +import sqlite3 +import time +from pathlib import Path + +from src.config.settings import get_settings +from src.graph.state import AgentState +from src.llm.client import LLMClient, load_prompt +from src.llm.providers.base import LLMError + + +def _workspace_sqlite_path() -> Path: + settings = get_settings() + raw = settings.database_url.replace("sqlite:///", "") + base = Path(raw) + return base.parent / "csv_workspace" / "working.db" + + +def plan_query(state: AgentState) -> AgentState: + try: + client = LLMClient() + system = load_prompt("csv_analyst_plan") + schema = state.get("schema_summary") or "{}" + user = ( + f"QUESTION: {state['instruction']}\n\n" + f"SCHEMA (table_name -> [columns]):\n{schema}\n\n" + "Plan: which tables and columns to use. Return ONLY a compact JSON like:\n" + '{"tables": ["upload_1"], "columns": ["col1", "col2"], "notes": "..."}' + ) + raw = client.complete(system, user, max_tokens=512).strip() + plan = json.loads(raw) if raw.startswith("{") else {"tables": [], "columns": [], "notes": raw} + state["query_plan"] = raw + state["tables_touched"] = plan.get("tables") or [] + state["provider"] = client.provider_name + state["model"] = client.model + except LLMError as exc: + return {"error": f"plan_query failed: {exc}"} + return state + + +def generate_code(state: AgentState) -> AgentState: + try: + client = LLMClient() + system = load_prompt("csv_analyst_sql") + tables = state.get("tables_touched") or [] + schema = state.get("schema_summary") or "{}" + user = ( + f"QUESTION: {state['instruction']}\n\n" + f"SCHEMA (table_name -> [columns]):\n{schema}\n\n" + f"USE_TABLES: {', '.join(tables)}\n\n" + "Emit ONE read-only SELECT SQL query for SQLite. Return ONLY the SQL, no markdown fences." + ) + sql = client.complete(system, user, max_tokens=1024).strip() + # Strip accidental markdown fences if the model adds them. + if sql.startswith("```"): + sql = sql.split("\n", 1)[-1] + if sql.endswith("```"): + sql = sql.rsplit("\n", 1)[0] + state["generated_code"] = sql + state["executed_sql"] = sql + state["provider"] = client.provider_name + state["model"] = client.model + except LLMError as exc: + return {"error": f"generate_code failed: {exc}"} + return state + + +def execute_query(state: AgentState) -> AgentState: + sql = state.get("executed_sql") or "" + if not sql.strip(): + return {"error": "execute_query: no SQL to execute."} + tables = state.get("tables_touched") or [] + db_path = _workspace_sqlite_path() + if not db_path.exists(): + return {"error": f"execute_query: workspace DB missing at {db_path}"} + + t0 = time.perf_counter() + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + cur = conn.execute(sql) + rows = cur.fetchall() + columns = list(rows[0].keys()) if rows else [] + data = [dict(r) for r in rows] + finally: + conn.close() + latency_ms = int((time.perf_counter() - t0) * 1000) + state["executed_rows"] = data + state["executed_columns"] = columns + state["executed_row_count"] = len(data) + state["latency_ms"] = latency_ms + except Exception as exc: + return {"error": f"execute_query failed: {exc}"} + return state + + +def assemble_answer(state: AgentState) -> AgentState: + try: + client = LLMClient() + system = load_prompt("csv_analyst_answer") + rows = state.get("executed_rows") or [] + columns = state.get("executed_columns") or [] + sample = rows[:10] + user = ( + f"QUESTION: {state['instruction']}\n\n" + f"SQL: {state.get('executed_sql') or ''}\n\n" + f"COLUMNS: {columns}\n\n" + f"ROW_COUNT: {len(rows)}\n\n" + f"SAMPLE_ROWS (JSON):\n{json.dumps(sample, default=str, indent=2)}\n\n" + "Return ONLY a JSON object:\n" + '{"answer": "...", "followups": ["..."], "anomalies": ["..."], "sensitive_warning": null}' + ) + raw = client.complete(system, user, max_tokens=1024).strip() + parsed = json.loads(raw) if raw.startswith("{") else {"answer": raw, "followups": [], "anomalies": []} + state["answer_text"] = parsed.get("answer") + state["followups"] = parsed.get("followups") or [] + state["anomaly_flags"] = parsed.get("anomalies") or [] + state["sensitive_warning"] = parsed.get("sensitive_warning") + state["provider"] = client.provider_name + state["model"] = client.model + state["result_table"] = {"columns": columns, "rows": rows} + except LLMError as exc: + return {"error": f"assemble_answer failed: {exc}"} + return state + + +def finalize(state: AgentState) -> AgentState: + return {"status": "completed"} + + +def handle_error(state: AgentState) -> AgentState: + return {"status": "failed"} diff --git a/src/graph/nodes_live_db.py b/src/graph/nodes_live_db.py new file mode 100644 index 0000000..57170d1 --- /dev/null +++ b/src/graph/nodes_live_db.py @@ -0,0 +1,136 @@ +"""Graph nodes — live database analyst path. + +Each node reads from and writes to ``AgentState``. Failures go into ``state["error"]`` +so the conditional edge routes to ``handle_failure``. +""" +from __future__ import annotations + +import json +import time +from pathlib import Path + +from src.config.settings import get_settings +from src.db.live_db import LiveDBQueryError, read_only_query as _read_only_query +from src.graph.state import AgentState +from src.llm.client import LLMClient, load_prompt +from src.llm.providers.base import LLMError + + +def plan_query(state: AgentState) -> AgentState: + try: + client = LLMClient() + system = load_prompt("live_db_analyst_plan") + user = ( + f"QUESTION: {state['question']}\n\n" + f"SCHEMA:\n{state['schema_summary']}\n\n" + "Plan: which tables, columns, join keys, and a short rationale." + " Return ONLY compact JSON with shape: " + '{"tables": [...], "columns": [...], "join_conditions": "...", "rationale": "..."}\n' + ) + raw = client.complete(system, user, max_tokens=512).strip() + plan: dict = {} + try: + plan = json.loads(raw) + except Exception: + plan = {"raw_plan": raw} + state["query_plan"] = plan + state["tables_touched"] = plan.get("tables") or [] + state["provider"] = client.provider_name + state["model"] = client.model + except LLMError as exc: + return {"error": f"plan_query failed: {exc}"} + return state + + +def generate_code(state: AgentState) -> AgentState: + try: + plan = state.get("query_plan") or {} + client = LLMClient() + system = load_prompt("live_db_analyst_sql") + user = ( + f"QUESTION: {state['question']}\n\n" + f"SCHEMA:\n{state['schema_summary']}\n\n" + f"TABLES: {', '.join(plan.get('tables') or [])}\n" + f"COLUMNS: {', '.join(plan.get('columns') or [])}\n\n" + "Emit ONE read-only SELECT SQL for SQL Server. Return ONLY the SQL, no markdown fences." + ) + sql = client.complete(system, user, max_tokens=1024).strip() + if sql.startswith("```"): + sql = sql.split("\n", 1)[-1] + if sql.endswith("```"): + sql = sql.rsplit("\n", 1)[0] + state["generated_code"] = sql + state["executed_sql"] = sql + state["provider"] = client.provider_name + state["model"] = client.model + except LLMError as exc: + return {"error": f"generate_code failed: {exc}"} + return state + + +def execute_query(state: AgentState) -> AgentState: + sql = state.get("executed_sql") or "" + if not sql.strip(): + return {"error": "execute_query: no SQL provided."} + t0 = time.perf_counter() + try: + wait_for_live_db_ok() + columns, rows = _read_only_query(sql) + latency_ms = int((time.perf_counter() - t0) * 1000) + state["executed_columns"] = columns + state["executed_rows"] = rows[: state.get("row_limit") or 5000] + state["executed_row_count"] = len(rows) + state["latency_ms"] = latency_ms + state["result_table"] = {"columns": columns, "rows": rows[: state.get("row_limit") or 5000]} + except Exception as exc: + return {"error": f"execute_query failed: {exc}"} + return state + + +def assemble_answer(state: AgentState) -> AgentState: + try: + client = LLMClient() + system = load_prompt("live_db_analyst_answer") + rows = state.get("executed_rows") or [] + columns = state.get("executed_columns") or [] + user = ( + f"QUESTION: {state['question']}\n\n" + f"SQL: {state.get('executed_sql') or ''}\n\n" + f"COLUMNS: {columns}\n\n" + f"ROW_COUNT: {len(state.get('executed_rows') or [])}\n\n" + f"SAMPLE_ROWS:\n{json.dumps(rows[:10], default=str, indent=2)}\n\n" + "Return ONLY JSON: {\"answer\": \"...\", \"followups\": [...], \"anomalies\": [...], \"sensitive_warning\": null}" + ) + raw = client.complete(system, user, max_tokens=1024).strip() + parsed = {} + try: + parsed = json.loads(raw) + except Exception: + parsed = {"answer": raw} + state["answer_text"] = parsed.get("answer") or raw + state["followups"] = parsed.get("followups") or [] + state["anomaly_flags"] = parsed.get("anomalies") or [] + state["sensitive_warning"] = parsed.get("sensitive_warning") + state["provider"] = client.provider_name + state["model"] = client.model + except Exception as exc: + return {"error": f"assemble_answer failed: {exc}"} + return state + + +def handle_failure(state: AgentState) -> AgentState: + state["status"] = "failed" + state["error"] = state.get("error") or "live_db analyst failed" + return state + + +def finalize(state: AgentState) -> AgentState: + if not state.get("status"): + state["status"] = "completed" + return state + + +def wait_for_live_db_ok() -> None: + url = get_settings().live_db_url + if not url: + raise RuntimeError("Live DB unavailable") diff --git a/src/graph/runner_csv.py b/src/graph/runner_csv.py new file mode 100644 index 0000000..f43ed66 --- /dev/null +++ b/src/graph/runner_csv.py @@ -0,0 +1,116 @@ +"""run_csv_agent() — CSV analyst path. + +Creates the run row, builds the one-shot analyst graph, invokes it, and +persists the outcome + audit row. +""" +from __future__ import annotations + +import json +import time +from pathlib import Path + +from sqlalchemy.orm import Session + +from src.config.settings import get_settings +from src.db.models import AuditRow, CSVUploadRow, RunRow +from src.graph.agent_csv import build_csv_graph +from src.graph.state import AgentState +from src.llm.client import LLMClient, load_prompt + + +def _workspace_sqlite_path() -> Path: + settings = get_settings() + raw = settings.database_url.replace("sqlite:///", "") + base = Path(raw) + return base.parent / "csv_workspace" / "working.db" + + +def run_csv_agent(session: Session, *, question: str, csv_file_ids: list[int], row_limit: int) -> str: + log_prefix = "run_csv_agent" + + run = RunRow(input_text=question, instruction="", status="running") + session.add(run) + session.flush() + run_id = run.id + + try: + uploads = session.query(CSVUploadRow).filter(CSVUploadRow.id.in_(csv_file_ids)).all() + if not uploads: + raise ValueError("No CSV uploads found for the provided ids.") + + workspace_db = _workspace_sqlite_path() + workspace_db.parent.mkdir(parents=True, exist_ok=True) + + # Register CSVs into the workspace SQLite DB. + import sqlite3 + + conn = sqlite3.connect(workspace_db) + try: + for upload in uploads: + csv_path = workspace_db.parent / upload.file_name + if not csv_path.exists(): + raise ValueError(f"Missing uploaded file: {upload.file_name}") + df = __import__("pandas").read_csv(csv_path) + df.to_sql(f"upload_{upload.id}", conn, if_exists="replace", index=False) + finally: + conn.close() + + state: AgentState = { + "run_id": run_id, + "input_text": "", + "instruction": question, + "data_source": "csv", + "csv_file_ids": list(csv_file_ids), + "schema_summary": json.dumps({str(u.id): json.loads(u.columns or "[]") for u in uploads}), + "query_plan": None, + "generated_code": None, + "executed_sql": None, + "executed_rows": None, + "executed_columns": None, + "executed_row_count": None, + "latency_ms": None, + "result_table": None, + "answer_text": None, + "csv_download_url": None, + "followups": None, + "anomaly_flags": None, + "sensitive_warning": None, + "tables_touched": None, + "provider": None, + "model": None, + "token_usage": None, + "memory_context": None, + "saved_workspace_id": None, + "status": None, + "error": None, + } + + graph = build_csv_graph() + start = time.perf_counter() + final = graph.invoke(state) + latency_ms = int((time.perf_counter() - start) * 1000) + + run.status = final.get("status", "completed") or "completed" + run.output_text = final.get("answer_text") or "" + run.provider = final.get("provider") + run.model = final.get("model") + run.error_message = final.get("error") + + audit = AuditRow( + run_id=run_id, + question=question, + sql=final.get("executed_sql"), + tables_touched=json.dumps(final.get("tables_touched") or []), + row_count=final.get("executed_row_count"), + latency_ms=latency_ms, + token_usage=json.dumps(final.get("token_usage") or {}), + ) + session.add(audit) + session.commit() + except Exception as exc: + run.status = "failed" + run.error_message = str(exc) + session.commit() + raise + + return run_id diff --git a/src/graph/runner_live_db.py b/src/graph/runner_live_db.py new file mode 100644 index 0000000..9e0b037 --- /dev/null +++ b/src/graph/runner_live_db.py @@ -0,0 +1,196 @@ +"""run_live_db_query() — live database analyst path.""" +from __future__ import annotations + +import json +import time + +from sqlalchemy.orm import Session + +from src.config.settings import get_settings +from src.db.models import AuditRow, RunRow +from src.graph.runner_csv import build_csv_graph +from src.graph.state import AgentState +from src.llm.client import LLMClient, load_prompt +from src.db.live_db import read_only_query + + +def run_live_db_query(session: Session, *, question: str, schema_summary: str, row_limit: int = 5000) -> str: + state = { + "run_id": None, + "input_text": question, + "instruction": "", + "data_source": "live_db", + "csv_file_ids": [], + "schema_summary": schema_summary.strip(), + "query_plan": None, + "generated_code": None, + "executed_sql": None, + "executed_rows": None, + "executed_columns": None, + "executed_row_count": None, + "latency_ms": None, + "result_table": None, + "answer_text": None, + "followups": None, + "anomaly_flags": None, + "sensitive_warning": None, + "tables_touched": None, + "provider": None, + "model": None, + "token_usage": None, + "memory_context": None, + "saved_workspace_id": None, + "status": None, + "error": None, + } + + client = LLMClient() + plan_system = load_prompt("live_db_analyst_plan") + plan_user = ( + f"QUESTION: {question}\n\n" + f"SCHEMA (table -> JSON description):\n{schema_summary}\n\n" + "Return ONLY compact JSON with shape: " + '{"tables": [...], "columns": [...], "join_conditions": "...", "rationale": "..."}\n' + ) + raw = client.complete(plan_system, plan_user, max_tokens=512).strip() + plan = {} + try: + plan = json.loads(raw) + except Exception: + pass + + sql_system = load_prompt("live_db_analyst_sql") + sql_user = ( + f"QUESTION: {question}\n\n" + f"SCHEMA (table -> JSON description):\n{schema_summary}\n\n" + f"TABLES: {', '.join(plan.get('tables') or [])}\n" + f"COLUMNS: {', '.join(plan.get('columns') or [])}\n\n" + "Emit ONE read-only SELECT SQL for SQL Server. Return ONLY the SQL, no markdown fences." + ) + sql = client.complete(sql_system, sql_user, max_tokens=1024).strip() + if sql.startswith("```"): + sql = sql.split("\n", 1)[-1] + if sql.endswith("```"): + sql = sql.rsplit("\n", 1)[0] + state["executed_sql"] = sql + state["tables_touched"] = plan.get("tables") or [] + + t0 = time.perf_counter() + try: + columns, rows = read_only_query(sql) + latency_ms = int((time.perf_counter() - t0) * 1000) + state["executed_columns"] = columns + state["executed_rows"] = rows[: min(row_limit, 5000)] + state["executed_row_count"] = len(rows) + state["latency_ms"] = latency_ms + except Exception as exc: + state["error"] = f"live_db query failed: {exc}" + state["status"] = "failed" + + run = RunRow(input_text=question, instruction="", status=run.status, output_text=run.output_text, provider=run.provider, model=run.model, error_message=run.error_message) + session.add(run) + session.flush() + run_id = run.id + + audit = AuditRow(run_id=run_id, question=question, sql=sql, tables_touched=json.dumps(state["tables_touched"]), row_count=state["executed_row_count"], latency_ms=state["latency_ms"], token_usage="{}") + session.add(audit) + session.commit() + return run_id + + +def run_live_db_queryv2(session: Session, *, question: str, schema_summary: str, row_limit: int = 5000) -> str: + state = { + "run_id": None, + "question": question, + "schema_summary": schema_summary, + "data_source": "live_db", + "query_plan": None, + "tables_touched": [], + "generated_code": None, + "executed_sql": None, + "executed_rows": None, + "executed_columns": None, + "executed_row_count": None, + "latency_ms": None, + "result_table": None, + "answer_text": None, + "followups": None, + "anomaly_flags": None, + "sensitive_warning": None, + "provider": None, + "model": None, + "token_usage": None, + "memory_context": None, + "saved_workspace_id": None, + "status": None, + "error": None, + } + + client = LLMClient() + plan_system = load_prompt("live_db_analyst_plan") + plan_user = ( + f"QUESTION: {question}\n\n" + f"SCHEMA (table -> JSON description):\n{schema_summary}\n\n" + "Return ONLY compact JSON with shape: " + '{"tables": [...], "columns": [...], "join_conditions": "...", "rationale": "..."}\n' + ) + plan_raw = client.complete(plan_system, plan_user, max_tokens=512).strip() + plan = {} + try: + plan = json.loads(plan_raw) + except Exception: + pass + + sql_system = load_prompt("live_db_analyst_sql") + sql_user = ( + f"QUESTION: {question}\n\n" + f"SCHEMA (table -> JSON description):\n{schema_summary}\n\n" + f"TABLES: {', '.join(plan.get('tables') or [])}\n" + f"COLUMNS: {', '.join(plan.get('columns') or [])}\n\n" + "Emit ONE read-only SELECT SQL for SQL Server. Return ONLY the SQL, no markdown fences." + ) + sql = client.complete(sql_system, sql_user, max_tokens=1024).strip() + if sql.startswith("```"): + sql = sql.split("\n", 1)[-1] + if sql.endswith("```"): + sql = sql.rsplit("\n", 1)[0] + state["executed_sql"] = sql + state["tables_touched"] = plan.get("tables") or [] + + t0 = time.perf_counter() + try: + columns, rows = read_only_query(sql, row_limit=row_limit) + latency_ms = int((time.perf_counter() - t0) * 1000) + state["executed_columns"] = columns + state["executed_rows"] = rows + state["executed_row_count"] = len(rows) + state["latency_ms"] = latency_ms + state["result_table"] = {"columns": columns, "rows": rows} + except Exception as exc: + state["error"] = str(exc) + state["status"] = "failed" + + answer_prompt = load_prompt("live_db_analyst_answer") + answer_user = ( + f"QUESTION: {question}\n\n" + f"SQL: {sql}\n\n" + f"COLUMNS: {state['executed_columns']}\n\n" + f"ROW_COUNT: {state['executed_row_count']}\n\n" + f"SAMPLE_ROWS (JSON):\n{json.dumps((state['executed_rows'] or [])[:10], default=str, indent=2)}\n\n" + "Return ONLY JSON: {\"answer\": \"...\", \"followups\": [...], \"anomalies\": [...], \"sensitive_warning\": null}\n" + ) + answer_raw = client.complete(answer_prompt, answer_user, max_tokens=1024).strip() + try: + answer_obj = json.loads(answer_raw) + state["answer_text"] = answer_obj.get("answer") or answer_raw + except Exception: + state["answer_text"] = answer_raw + + run = RunRow(input_text=question, instruction="", status=state.get("status", "completed"), output_text=state.get("answer_text") or "", provider=client.provider_name, model=client.model, error_message=state.get("error")) + session.add(run) + session.flush() + run_id = run.id + audit = AuditRow(run_id=run_id, question=question, sql=sql, tables_touched=json.dumps(state.get("tables_touched") or []), row_count=state.get("executed_row_count"), latency_ms=state.get("latency_ms"), token_usage="{}") + session.add(audit) + session.commit() + return run_id diff --git a/src/live_db_path.py b/src/live_db_path.py new file mode 100644 index 0000000..0e8d810 --- /dev/null +++ b/src/live_db_path.py @@ -0,0 +1,25 @@ +"""Helpers — live database query path decoding and safe routing.""" +from __future__ import annotations + +import re + +from src.graph.state import AgentState + + +LIVE_DB_PATH_PREFIX = "/live-db" + + +def decode_live_db_path(raw: str) -> str: + text = (raw or "").strip().lower() + text = re.sub(r"[^a-z0-9/_-]+", "-", text) + text = re.sub(r"/{2,}", "/", text).strip("/") + return f"{LIVE_DB_PATH_PREFIX}/{text}" if text else LIVE_DB_PATH_PREFIX + + +def classify_live_db_path(path: str) -> str: + normalized = (path or "").strip().lower() + if normalized.startswith(f"{LIVE_DB_PATH_PREFIX}/query"): + return "query" + if normalized.startswith(f"{LIVE_DB_PATH_PREFIX}/runs/"): + return "run_detail" + return "root" diff --git a/src/llm/providers/factory.py b/src/llm/providers/factory.py index bd9e6a0..505594c 100644 --- a/src/llm/providers/factory.py +++ b/src/llm/providers/factory.py @@ -1,8 +1,8 @@ """Provider factory — resolves provider + model from settings. ``auto`` picks whichever key is set. With no key at all we raise a clear, -actionable error: the real provider is the default and the only gated path — -there is no silent stub fallback (harness/rules/ai-agents.md rule 7). +actionable error: the real provider is the default and the only gated path — there +is no silent stub fallback (harness/rules/ai-agents.md rule 7). """ from __future__ import annotations @@ -10,24 +10,27 @@ from src.llm.providers.anthropic import AnthropicProvider from src.llm.providers.base import LLMError, LLMProvider from src.llm.providers.gemini import GeminiProvider +from src.llm.providers.nim import NIMProvider from src.llm.providers.openrouter import OpenRouterProvider def create_llm_provider() -> LLMProvider: - s = get_settings() - provider = s.resolve_provider() - model = s.resolve_model() + s = get_settings() + provider = s.resolve_provider() + model = s.resolve_model() - if provider == "anthropic": - return AnthropicProvider(api_key=s.anthropic_api_key, model=model) - if provider == "gemini": - return GeminiProvider(api_key=s.gemini_api_key, model=model) - if provider == "openrouter": - return OpenRouterProvider( - api_key=s.openrouter_api_key, model=model, base_url=s.openrouter_base_url - ) - raise LLMError( - "No LLM API key configured. Set exactly one of AGENT_ANTHROPIC_API_KEY, " - "AGENT_GEMINI_API_KEY, or AGENT_OPENROUTER_API_KEY in .env " - "(see .env.example)." - ) + if provider == "anthropic": + return AnthropicProvider(api_key=s.anthropic_api_key, model=model) + if provider == "gemini": + return GeminiProvider(api_key=s.gemini_api_key, model=model) + if provider == "nim": + return NIMProvider(api_key=s.key_for("nim"), model=model, base_url=s.openai_base_url) + if provider == "openrouter": + return OpenRouterProvider( + api_key=s.openrouter_api_key, model=model, base_url=s.openrouter_base_url + ) + raise LLMError( + "No LLM API key configured. Set one of AGENT_ANTHROPIC_API_KEY, " + "AGENT_GEMINI_API_KEY, AGENT_OPENROUTER_API_KEY, OPENAI_API_KEY, or " + "OPENAI_COMPAT_API_KEY in .env (see .env.example)." + ) diff --git a/src/llm/providers/nim.py b/src/llm/providers/nim.py new file mode 100644 index 0000000..ec72448 --- /dev/null +++ b/src/llm/providers/nim.py @@ -0,0 +1,50 @@ +"""NVIDIA NIM provider — OpenAI-compatible chat/completions over httpx. + +This adapter covers NVIDIA NIM endpoints as well as any other OpenAI-compatible +service that exposes a `/chat/completions` endpoint. +""" +from __future__ import annotations + +import httpx + +from src.llm.providers.base import LLMError, LLMProvider +from src.llm.retry import with_retries + + +class NIMProvider(LLMProvider): + name = "nim" + + def __init__(self, api_key: str, model: str, base_url: str = "https://integrate.api.nvidia.com/v1") -> None: + self._api_key = api_key + self.model = model + self._base_url = base_url.rstrip("/") + + def complete(self, system: str, user: str, *, max_tokens: int = 1024) -> str: + def _call() -> str: + resp = httpx.post( + f"{self._base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self._api_key}", + "content-type": "application/json", + }, + json={ + "model": self.model, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + }, + timeout=120.0, + ) + resp.raise_for_status() + data = resp.json() + try: + text = (data["choices"][0]["message"]["content"] or "").strip() + except (KeyError, IndexError) as exc: + raise LLMError(f"nim returned no choices: {list(data)}") from exc + if not text: + raise LLMError("nim returned an empty completion") + return text + + return with_retries(_call, provider=self.name) diff --git a/src/prompts/csv_analyst_answer.md b/src/prompts/csv_analyst_answer.md new file mode 100644 index 0000000..1d2a995 --- /dev/null +++ b/src/prompts/csv_analyst_answer.md @@ -0,0 +1,11 @@ +You turn a structured query result into a concise analyst answer for a police data analyst agent. + +Constraints: +- Base the answer ONLY on the provided SQL result. +- State the answer first, then 1-2 sentences of context. +- Include follow-up suggestions and any obvious anomalies. +- If the result is empty, say so directly instead of inventing rows. +- Never expose secrets, raw keys, or policy values. + +Return ONLY a JSON object: +{"answer": "...", "followups": ["..."], "anomalies": ["..."], "sensitive_warning": null} diff --git a/src/prompts/csv_analyst_plan.md b/src/prompts/csv_analyst_plan.md new file mode 100644 index 0000000..d518b05 --- /dev/null +++ b/src/prompts/csv_analyst_plan.md @@ -0,0 +1,11 @@ +You are a precise analyst planner for structured police data stored in local SQLite tables. + +Given a QUESTION and a SCHEMA summary, identify: +- which tables to query +- which columns to use +- a short rationale + +Return ONLY a compact JSON object like: +{"tables": ["upload_1"], "columns": ["col1", "col2"], "notes": "..."} + +Never return SQL here. Keep it under 200 words. Use lower_snake_case column names exactly as listed. diff --git a/src/prompts/csv_analyst_sql.md b/src/prompts/csv_analyst_sql.md new file mode 100644 index 0000000..9e9ce61 --- /dev/null +++ b/src/prompts/csv_analyst_sql.md @@ -0,0 +1,10 @@ +You write read-only SQL for SQLite over local analyst tables. + +Requirements: +- Emit exactly ONE SELECT statement. +- Do NOT use CTEs, window functions, or mutating statements. +- If the question cannot be answered from the provided schema, return a short SQL comment explaining what is missing instead of guessing. +- Respect a row cap of at most 100000 rows; use LIMIT when needed. +- Use lower_snake_case table and column names exactly as given in the schema. + +Return ONLY the SQL. No markdown fences, no commentary. diff --git a/src/prompts/live_db_analyst_answer.md b/src/prompts/live_db_analyst_answer.md new file mode 100644 index 0000000..30dac97 --- /dev/null +++ b/src/prompts/live_db_analyst_answer.md @@ -0,0 +1,11 @@ +You turn a structured live database query result into a concise analyst answer for a police data analyst agent. + +Constraints: +- Base the answer ONLY on the provided SQL result. +- State the answer first, then 1-2 sentences of context. +- Include follow-up suggestions and any obvious anomalies. +- If the result is empty, say so directly instead of inventing rows. +- Never expose secrets, raw keys, or policy values. + +Return ONLY a JSON object: +{"answer": "...", "followups": ["..."], "anomalies": ["..."], "sensitive_warning": null} diff --git a/src/prompts/live_db_analyst_plan.md b/src/prompts/live_db_analyst_plan.md new file mode 100644 index 0000000..848cb9d --- /dev/null +++ b/src/prompts/live_db_analyst_plan.md @@ -0,0 +1,12 @@ +You are a precise analyst planner for live SQL Server police databases. + +Given a QUESTION and a SCHEMA summary for available tables, identify: +- which tables to query +- which columns to use +- a short rationale (2-4 sentences) +- any joins needed and the join keys + +Return ONLY a compact JSON object with this shape: +{"tables": ["table1", "table2"], "columns": ["col1", "col2"], "join_conditions": "table1.id = table2.table1_id", "rationale": "..."} + +Do not include any conversational text outside the JSON. diff --git a/src/prompts/live_db_analyst_sql.md b/src/prompts/live_db_analyst_sql.md new file mode 100644 index 0000000..5ee9ba1 --- /dev/null +++ b/src/prompts/live_db_analyst_sql.md @@ -0,0 +1,12 @@ +You write read-only SQL for SQL Server over live police data tables. + +Requirements: +- Emit exactly ONE SELECT statement. +- Use T-SQL syntax appropriate for Microsoft SQL Server. +- Do NOT use CTEs, window functions, mutating statements, or system tables. +- If the question cannot be answered with the provided schema, state that clearly. +- Use the provided table and column names exactly as given. +- Add TOP instead of LIMIT for row limits, and always include ORDER BY when ranking is implied. +- Wrap sensitive identifiers in square brackets when needed. + +Return ONLY the SQL, no markdown fences or commentary. diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 0b3b1a3..1723610 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -47,10 +47,34 @@ def test_run_without_key_fails_gracefully(no_keys): def test_frontend_served_at_app(): - with _client() as client: - res = client.get("/app/") - assert res.status_code == 200 - assert "Zero-Shot Agent" in res.text - # styles + js referenced (single-origin) - assert "styles.css" in res.text - assert "app.js" in res.text + with _client() as client: + res = client.get("/app/") + assert res.status_code == 200 + # updated branding for Phase 3 analyst UI + assert "UP Police Data Analyst" in res.text + # styles + js referenced (single-origin) + assert "styles.css" in res.text + assert "app.js" in res.text + + +def test_list_runs_returns_empty_list_when_no_runs(): + with _client() as client: + res = client.get("/runs") + assert res.status_code == 200 + data = res.json()["data"] + assert data == [] + + +def test_get_run_history_404_for_missing_run(): + with _client() as client: + res = client.get("/runs/nope") + assert res.status_code == 404 + + +def test_run_audit_trace_returns_404_for_missing_run(): + with _client() as client: + res = client.get("/runs/nope/audit") + assert res.status_code == 404 + payload = res.json()["detail"] + assert isinstance(payload, dict) + assert payload.get("code") == "run_not_found" diff --git a/tests/unit/test_csv_api.py b/tests/unit/test_csv_api.py new file mode 100644 index 0000000..10434db --- /dev/null +++ b/tests/unit/test_csv_api.py @@ -0,0 +1,49 @@ +"""Unit tests for the CSV analyst API endpoints.""" +from __future__ import annotations + +import csv +import io + +import pytest +from fastapi.testclient import TestClient + +from src.api import create_app + + +@pytest.fixture() +def client(): + with TestClient(create_app()) as c: + yield c + + +def test_upload_csv_returns_file_id_and_schema(client): + payload = b"district,offence,count\nLucknow,Theft,10\nKanpur,Theft,7\n" + files = {"file": ("test.csv", io.BytesIO(payload), "text/csv")} + res = client.post("/csv/upload", files=files) + assert res.status_code == 200 + body = res.json()["data"] + assert body["file_name"] == "test.csv" + assert body["row_count"] == 2 + assert set(body["columns"]) == {"district", "offence", "count"} + assert isinstance(body["file_id"], int) + + +def test_upload_rejects_non_csv(client): + payload = b"hello world" + files = {"file": ("notes.txt", io.BytesIO(payload), "text/plain")} + res = client.post("/csv/upload", files=files) + assert res.status_code == 400 + + +def test_query_csv_returns_run_id(client): + upload_payload = b"district,offence,count\nLucknow,Theft,10\nKanpur,Theft,7\n" + upload_res = client.post("/csv/upload", files={"file": ("q.csv", io.BytesIO(upload_payload), "text/csv")}) + assert upload_res.status_code == 200 + upload = upload_res.json()["data"] + file_id = upload["file_id"] + + res = client.post("/csv/query", json={"question": "Which district has the highest count?", "data_source": "csv", "csv_file_ids": [file_id]}) + assert res.status_code == 200 + body = res.json()["data"] + assert "run_id" in body + assert body["status"] in {"completed", "failed"} diff --git a/tests/unit/test_live_db_api.py b/tests/unit/test_live_db_api.py new file mode 100644 index 0000000..b06466f --- /dev/null +++ b/tests/unit/test_live_db_api.py @@ -0,0 +1,44 @@ +"""Unit tests for the live DB API.""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from src.api import create_app + + +@pytest.fixture() +def client(): + with TestClient(create_app()) as c: + yield c + + +def test_live_db_query_rejects_empty_question(client): + res = client.post("/live-db/query", json={"question": "", "schema_summary": "t1(c1)"}) + assert res.status_code == 422 + + +def test_live_db_query_rejects_missing_schema(client): + res = client.post("/live-db/query", json={"question": "count rows"}) + assert res.status_code == 400 + + +def test_live_db_query_missing_schema_returns_400(client): + res = client.post("/live-db/query", json={"question": "Count total rows."}) + assert res.status_code == 400 + + +def test_live_db_query_validates_http_error_shape(client): + payload = { + "question": "Count rows", + "schema_summary": "crime(offence text, district text, year int)", + } + res = client.post("/live-db/query", json=payload) + assert res.status_code in {200, 400, 422, 502} + body = res.json() + assert "data" in body or "detail" in body + + +def test_live_db_run_detail_404_for_missing_run(client): + res = client.get("/live-db/runs/nope") + assert res.status_code == 404 diff --git a/tests/unit/test_runs_api.py b/tests/unit/test_runs_api.py new file mode 100644 index 0000000..32f743d --- /dev/null +++ b/tests/unit/test_runs_api.py @@ -0,0 +1,35 @@ +"""Unit tests for the runs API — history + audit trace.""" +from __future__ import annotations + +import datetime + +import pytest +from fastapi.testclient import TestClient + +from src.api import create_app + + +@pytest.fixture() +def client(): + with TestClient(create_app()) as c: + yield c + + +def test_list_runs_returns_empty_when_no_runs(client): + res = client.get("/runs") + assert res.status_code == 200 + body = res.json()["data"] + assert body == [] + + +def test_get_run_history_404_for_missing_run(client): + res = client.get("/runs/does-not-exist") + assert res.status_code == 404 + + +def test_run_audit_trace_404_for_missing_run(client): + res = client.get("/runs/does-not-exist/audit") + assert res.status_code == 404 + body = res.json()["detail"] + assert isinstance(body, dict) + assert body.get("code") == "run_not_found" diff --git a/uv.lock b/uv.lock index 5d86f08..074e4b9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,17 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "alembic" @@ -560,6 +571,149 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -685,6 +839,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -850,6 +1065,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -941,6 +1168,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1050,6 +1286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1501,6 +1746,7 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "langgraph" }, + { name = "pandas" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, @@ -1519,6 +1765,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "langgraph", specifier = ">=0.2.28" }, + { name = "pandas", specifier = ">=2.0" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.3" }, { name = "sqlalchemy", specifier = ">=2.0" },