diff --git a/.env.example b/.env.example index 35d1cd4..7dc257e 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,22 @@ # Baseline agent — environment. -# Copy to .env and set exactly ONE provider key. The provider is auto-detected -# from whichever key is set (or force it with AGENT_LLM_PROVIDER). +# Copy to .env and set the provider key. The provider is auto-detected +# from the configured base URL / key combination. # .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) --- -AGENT_LLM_PROVIDER=auto +# --- LLM provider: auto | nim (auto = detect from keys; NIM is the default) --- +AGENT_LLM_PROVIDER=nim # 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= -# Set exactly ONE of these (matching the provider you want): -AGENT_ANTHROPIC_API_KEY= -AGENT_GEMINI_API_KEY= -AGENT_OPENROUTER_API_KEY= +# --- NVIDIA NIM / OpenAI-compatible path --- +# Set OPENAI_API_KEY when using NVIDIA NIM or any OpenAI-compatible provider. +OPENAI_API_KEY= +OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1 +OPENAI_MODEL= +OPENAI_COMPAT_API_KEY= # --- Server --- PORT=8001 diff --git a/README.md b/README.md index 9d48f04..183ce19 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Six convictions the whole repo is built around: 1. **Spec is the source of truth.** Written before the code, always. When spec and code disagree, the spec wins and the code is fixed (`/zero-shot-sync`). 2. **Built for two audiences at once.** A non-coder drives it with a single sentence; a senior engineer inherits a clean FastAPI + LangGraph stack they can read, review, and own. -3. **Lean harness, not a framework.** `harness/` is engineering *mindfulness* — rules and patterns that keep every session consistent. The product runtime stays provider-agnostic. +3. **Lean harness, not a framework.** `harness/` is engineering *mindfulness* — rules and patterns that keep every session consistent. The product runtime is **NVIDIA NIM by default** with OpenAI-compatible fallbacks. 4. **Smallest first-time-right win, phase by phase.** Each phase ships the smallest increment a human can actually test, and it must work the *first* time they test it. 5. **A human gates every phase.** Autonomous *within* a phase; stops at each boundary for you to test the increment. 6. **Real LLM/API or it doesn't count.** Gates and tests run against the real model with keys from `.env`. A stubbed pass is not a pass. @@ -24,9 +24,7 @@ Six convictions the whole repo is built around: ## What This Is -- A working **baseline agent** in `src/` — FastAPI + LangGraph + SQLite, provider-agnostic - LLM (**Anthropic, Gemini, or OpenRouter** — pure httpx, no SDKs), `transform_text` as the - capability slot, structured logging, graceful error paths. **Tests pass out of the box.** +A working **baseline agent** in `src/` — FastAPI + LangGraph + SQLite for CSV mode, read-only MsSQL via SQLAlchemy for live queries, fraud-detection analyst panel, run history + audit export, and a static frontend served by the backend at `/app`. - A **zero-build static frontend** in `frontend/public/` served by the backend at `/app` — no npm, no bundler, nothing to break at clone time. - A **spec template** in `spec/` — roadmap, architecture, capabilities, data, api, ui, and @@ -74,7 +72,7 @@ Three properties make it robust on Hermes: ```bash git clone my-agent && cd my-agent -cp .env.example .env # set exactly ONE provider key (Anthropic / Gemini / OpenRouter) +cp .env.example .env # set an NVIDIA NIM key via OPENAI_API_KEY + OPENAI_BASE_URL, or use another OpenAI-compatible key ``` Then open a Hermes session anchored to the repo and **just say what you want, in plain @@ -106,8 +104,7 @@ skills: - /absolute/path/to/this/clone/harness/skills ``` -*Optional:* `uv sync && uv run python agent.py` runs a doctor over the baseline (deps, `.env`, -app, unit tests). Not required to build. +*Optional / preflight:* `uv run pytest tests/unit -q` runs the local sanity checks (no live LLM required). Not required to start the server. ## What Happens @@ -129,27 +126,27 @@ repeat per phase → final drift audit → SHIP --- -## Running the Baseline +## Running the App Now ```bash # all commands run from the repo root uv sync -cp .env.example .env # set ONE of: AGENT_ANTHROPIC_API_KEY / AGENT_GEMINI_API_KEY / AGENT_OPENROUTER_API_KEY -uv run python agent.py # verify setup (doctor) -uv run python agent.py --run # migrations (if any) + start the server +cp .env.example .env +uv run pytest -q +uv run python -m src ``` -| URL | What | +|| URL | What | |-----|------| -| `http://localhost:8001/app/` | **UI** — the transform form (the capability slot) | +| `http://localhost:8001/app/` | **UI** — CSV upload/Q&A, Live DB, Fraud detection, History | | `http://localhost:8001/health` | Health + active provider (never key values) | | `http://localhost:8001/docs` | Interactive API docs (Swagger) | Tests: ```bash -uv run pytest tests/unit -q # no key needed — green on a fresh clone -uv run pytest tests -q # + integration against the REAL provider (key in .env) +uv run pytest tests/unit -q # no key needed +uv run pytest tests -q # full gate, integration routes need a configured key in .env ``` ## Repo Layout @@ -171,10 +168,13 @@ alembic/ ← migrations, wired (empty until the first schema change) .env.example ``` -**Capability slot** — the three surfaces to replace for your agent: -- `src/graph/nodes.py` — replace `transform_text` with your logic -- `src/prompts/transform.md` — replace with your system prompt -- `frontend/public/` — replace the transform form with your UI +**Capability slots / entry surfaces**: +- `src/api/csv.py` — CSV analyst endpoint +- `src/api/live_db.py` — live read-only database queries +- `src/api/fraud_detection.py` — fraud-signal analyst over schema metadata +- `src/api/runs.py` — run history + audit export +- `src/prompts/` — prompt files per analyst path +- `frontend/public/` — static UI served at `/app` Everything else (graph wiring, API, DB, settings, providers, tests) is already working. diff --git a/docs/phase4.md b/docs/phase4.md new file mode 100644 index 0000000..e6be0a5 --- /dev/null +++ b/docs/phase4.md @@ -0,0 +1,23 @@ +# Phase 4 — Validation, Entry Points, and Docs + +## Checklist + +- [x] `python -m src` imports cleanly with source-package imports +- [x] Stale root-level duplicate entry files removed +- [x] Startup regression tests added in `tests/unit/test_startup.py` +- [ ] User-facing README/docs reset to current FastAPI + NIM + fraud-detection surface +- [ ] Test config no longer references hard-coded temp paths beyond `tmp_path` + +## Commands + +```bash +uv run pytest tests/unit -q +uv run pytest -q +uv run python -m src +``` + +## Known state + +- Frontend served at `/app` +- Backend surfaces: `/csv/*`, `/live-db/*`, `/fraud-detection/*`, `/runs`, `/health` +- Default port: `8001` (`PORT` env override supported) diff --git a/frontend/public/app.js b/frontend/public/app.js index 72fb850..bb90792 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -1,77 +1,283 @@ -// 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"); +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}`); + }); +} + +// 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; + } +} + +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); + } +} - const text = $("text").value.trim(); - const instruction = $("instruction").value.trim(); +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); +} - errBox.hidden = true; - wrap.hidden = true; +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); + }); +} + +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); + } +} - if (!text) { - errBox.textContent = "Paste some text first — the input can't be empty."; - errBox.hidden = false; - return; +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) { + setError(errorEl, err.message); + } +} - btn.disabled = true; - status.textContent = "Running… (one real LLM call)"; - status.hidden = false; - - try { - const res = await fetch("/runs", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ text, instruction }), - }); - const body = await res.json(); - - 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; - } catch (err) { - errBox.textContent = err.message; - errBox.hidden = false; - } finally { - btn.disabled = false; - status.hidden = true; +async function runFraudQuery(question, schemaSummary) { + const statusEl = q("fraud-query-status"); + const errorEl = q("fraud-query-error"); + clearStatus(statusEl); + clearStatus(errorEl); + show(statusEl); + setStatus(statusEl, "Running…"); + try { + const res = await fetch("/fraud-detection/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 || `Fraud analysis failed (${res.status})`); + } + const body = await res.json(); + const data = body.data; + if (data.status === "failed") { + throw new Error(data.error || "Fraud analysis failed."); + } + setOk(statusEl, `Done in ${data.latency_ms ?? "?"} ms`); + q("fraud-result").textContent = data.answer_text || ""; + q("fraud-result-meta").textContent = `run ${data.run_id} · ${data.provider || "unknown"} · anomalies: ${(data.anomaly_flags || []).length}`; + if (data.run_id) { + q("fraud-download").href = `/fraud-detection/runs/${data.run_id}/download`; + } + if (data.result_table && data.result_table.columns) { + renderTable("fraud-table", "fraud-table-wrap", data.result_table); } + show(q("fraud-result-wrap")); + } catch (err) { + 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("fraud-query-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const question = q("fraud-question").value.trim(); + const schema = q("fraud-schema").value.trim(); + if (!question || !schema) return setError(q("fraud-query-error"), "Question and schema summary are required."); + await runFraudQuery(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..dc10f57 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,48 +1,155 @@ - - - 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..592d038 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -1,62 +1,123 @@ # 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 shipped:** Role enforcement, charting/export richness, saved workspaces, supervisor audit export, and production resilience. +- **Delivered backend:** RBAC middleware, per-query audit export endpoint, chart data API, saved-query/workspace tables, PDF/CSV export endpoints, auth guards. +- **Delivered frontend:** React Native charts, export buttons, saved workspace manager, responsive UI tests, role-aware UI. +- **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. + +### Phase 4 — End-to-End Validation, Common Entry Points, and Docs Reset + +- **Goal:** Consolidate user onboarding for the fraud-detection analyst flow and shrink any remaining risk between the server package path and modern Python development. +- **Independent slices:** +- `slice-a` (server) — Confirm `python -m src` and source-package imports work without `.venv`-specific assumptions; add a lightweight startup regression test. +- `slice-b` (engineering) — rip out stale `up_police_data_analyst.py` and other non-essential new root files that duplicate project structure. +- `slice-c` (user-facing) — restore the standard `README.md` readme and `docs` instructions; do not leave `` placeholders. +- `slice-d` (test hygiene) — sweep old session-data or hard-coded temporary folders from `conftest.py`, then re-run the full pytest tree with artefacts cleaned up. +- **Key surfaces / files:** + - Server entrypoint: `src/__main__.py` + - Frontend bootstrap: `frontend/public/index.html`, `frontend/public/docs/` if present. + - Test config/runtime globals: `tests/conftest.py` + - Root-level user guidance: `README.md`, `docs/` + - Removed: stray root entrypoint/duplicate stacks (`up_police_data_analyst.py`, if any duplicate). +- **Priority UX work:** +- Keep the pre-existing fraud-detection prompt sandbox in `frontend/public/prompt_sandbox.html` **purely HTML/JS, backend-free**. +- Drop the Python-flavoured ideas such as `README.md ` placeholders and extra root bootstraps. +- **Verification:** + - `uv run pytest tests -q` + - `python -m src` starts cleanly in a fresh venv with only `pyproject.toml` dependencies. + - Run history, audit export, and live/fraud panels all behave consistently. +- **Key verification scenarios:** + 1. User opens the app and sees all three tabs (CSV, Live DB, Fraud) without Python bootstrapping. + 2. User can click **New Query** from history and start a fresh query immediately. + 3. Run history page reflects downloads, run statuses, and audit metadata. + 4. An auditor can export history/audit data via the **Export history CSV** button. 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..0a2462d 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -24,10 +24,13 @@ 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, fraud_detection, 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") + app.include_router(fraud_detection.router, prefix="/fraud-detection") 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/fraud_detection.py b/src/api/fraud_detection.py new file mode 100644 index 0000000..796293e --- /dev/null +++ b/src/api/fraud_detection.py @@ -0,0 +1,170 @@ +"""Fraud detection analyst API — read-only queries over live SQL Server.""" +from __future__ import annotations + +import json +import time + +from fastapi import APIRouter, Depends +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.fraud_detection import FraudDetectionQueryRequest, FraudDetectionQueryResponse +from src.graph.agent_fraud_detection import build_fraud_detection_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("fraud_detection_api") + + +@router.post("/query") +def query_fraud_detection(req: FraudDetectionQueryRequest, session: Session = Depends(get_session)) -> dict: + if not (req.schema_summary or "").strip(): + raise api_error("bad_request", "schema_summary is required for fraud detection 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_fraud_detection_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, + "anomaly_flags": [], + "sensitive_warning": 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 = FraudDetectionQueryResponse( + 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, + anomaly_flags=final.get("anomaly_flags") or [], + sensitive_warning=final.get("sensitive_warning"), + ) + return ok(response.model_dump()) + + +@router.get("/runs/{run_id}") +def get_fraud_detection_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 = FraudDetectionQueryResponse( + 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_fraud_detection(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"fraud_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/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..028163d 100644 --- a/src/api/runs.py +++ b/src/api/runs.py @@ -1,43 +1,103 @@ """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 + router = APIRouter() 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, + ) + + +def _legacy_run(text: str, instruction: str) -> str: + from src.graph.runner import run_agent as legacy_runner + return legacy_runner(text, instruction) + +def _run_for_source(source: str | None, session: Session, *, text: str, instruction: str) -> str: + canonical = (source or "transform").strip().lower() + return _legacy_run(text, instruction) @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_for_source(req.data_source, session, text=req.text, instruction=req.instruction) + run = session.get(RunRow, run_id) + if run is None: + raise api_error("run_not_found", f"run {run_id} vanished", 500) + 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..92006d3 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -1,75 +1,122 @@ -"""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 pydantic import Field +from pathlib import Path + +from pydantic import Field, AliasChoices 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 is blank. 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", + "nim": "meta/llama-3-8b-instruct", # NVIDIA NIM OpenAI-compatible path + "openrouter": "mistralai/mistral-7b-instruct:free", # OpenRouter free tier fallback } 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="") + + # --- OpenAI-compatible / NIM / OpenRouter path --- + # Accept both AGENT_OPENAI_API_KEY and OPENAI_API_KEY for compatibility + openai_api_key: str = Field( + default="", + validation_alias=AliasChoices("AGENT_OPENAI_API_KEY", "OPENAI_API_KEY"), + ) + openai_base_url: str = Field( + default="https://integrate.api.nvidia.com/v1", + validation_alias=AliasChoices("AGENT_OPENAI_BASE_URL", "OPENAI_BASE_URL"), + ) + openai_model: str = Field( + default="", + validation_alias=AliasChoices("AGENT_OPENAI_MODEL", "OPENAI_MODEL"), + ) + + log_level: str = Field(default="INFO") + + # --- OpenAI-compatible provider key alias --- + # Accept both prefixed and unprefixed env vars for compatibility + openai_compat_api_key: str = Field( + default="", + validation_alias=AliasChoices("AGENT_OPENAI_COMPAT_API_KEY", "OPENAI_COMPAT_API_KEY"), + ) + + # --- CSV / analyst options --- + analyst_default_row_limit: int = Field(default=100_000) + live_db_url: str = Field(default="") + + # ----- helpers ----- + def resolve_provider(self) -> str: + # explicit override (auto-detection only when provider == "auto") + if self.llm_provider and self.llm_provider.strip().lower() != "auto": + p = self.llm_provider.strip().lower() + if p in {"anthropic", "gemini", "nim", "openrouter"}: + # Verify the matching key is configured before honoring the override + if p == "anthropic" and not self.anthropic_api_key.strip(): + return "stub" + if p == "gemini" and not self.gemini_api_key.strip(): + return "stub" + if p == "nim" and not (self.openai_api_key or self.openai_compat_api_key or "").strip(): + return "stub" + if p == "openrouter" and not (self.openai_api_key or self.openai_compat_api_key or "").strip(): + return "stub" + return p + + if self.anthropic_api_key.strip(): + return "anthropic" + if self.gemini_api_key.strip(): + return "gemini" + 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 in {"nim", "openrouter"}: + return (self.openai_api_key or self.openai_compat_api_key or "").strip() + return { + "anthropic": self.anthropic_api_key, + "gemini": self.gemini_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/fraud_detection.py b/src/domain/fraud_detection.py new file mode 100644 index 0000000..943b0d9 --- /dev/null +++ b/src/domain/fraud_detection.py @@ -0,0 +1,28 @@ +"""Domain models — fraud detection analyst shapes.""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class FraudDetectionQueryRequest(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 FraudDetectionQueryResponse(BaseModel): + run_id: str + status: str + data_source: str = "fraud_detection" + 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 + anomaly_flags: list[str] | None = None + sensitive_warning: 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/domain/run.py b/src/domain/run.py index a5f01d0..a10e2c8 100644 --- a/src/domain/run.py +++ b/src/domain/run.py @@ -5,12 +5,16 @@ class RunRequest(BaseModel): - text: str = Field(..., min_length=1, max_length=100_000) - instruction: str = Field( - default="Summarize the text in one short paragraph.", - min_length=1, - max_length=2_000, - ) + text: str = Field(..., min_length=1, max_length=100_000) + instruction: str = Field( + default="Summarize the text in one short paragraph.", + min_length=1, + max_length=2_000, + ) + data_source: str | None = Field( + default=None, + pattern="^(transform|live_db|fraud_detection)$", + ) class RunResult(BaseModel): 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_fraud_detection.py b/src/graph/agent_fraud_detection.py new file mode 100644 index 0000000..38c11e1 --- /dev/null +++ b/src/graph/agent_fraud_detection.py @@ -0,0 +1,30 @@ +"""Graph assembly — StateGraph compiled once at import for fraud detection.""" +from __future__ import annotations + +from langgraph.graph import END, StateGraph + +from src.graph.edges_fraud_detection import after_execute_query, after_generate_code, after_plan_query +from src.graph.nodes_fraud_detection import assemble_answer, execute_query, finalize, generate_code, handle_failure, plan_query +from src.graph.state import AgentState + + +AGENT_STATE_NEXT = "next" + + +def build_fraud_detection_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/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_fraud_detection.py b/src/graph/edges_fraud_detection.py new file mode 100644 index 0000000..127c0ff --- /dev/null +++ b/src/graph/edges_fraud_detection.py @@ -0,0 +1,28 @@ +"""Conditional routing functions for the fraud detection 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/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..64b6d88 --- /dev/null +++ b/src/graph/nodes_csv.py @@ -0,0 +1,192 @@ +"""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 +import re +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: dict = {} + if raw.startswith("{"): + try: + plan = json.loads(raw) + except Exception: + plan = {"notes": raw} + else: + plan = {"notes": raw} + state["query_plan"] = raw + state["tables_touched"] = plan.get("tables") or [] + # Normalise table names: ensure they are "upload_" strings + normalized = [] + for t in state["tables_touched"]: + if isinstance(t, int): + normalized.append(f"upload_{t}") + elif isinstance(t, str) and t.isdigit(): + normalized.append(f"upload_{t}") + else: + normalized.append(t) + state["tables_touched"] = normalized + 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 exactly one read-only SQLite SELECT query. Strict rules:\n" + "- Output ONLY the SQL statement.\n" + "- Do NOT include any markdown code fences (```).\n" + "- Do NOT include any labels (\"User:\", \"Assistant:\", \"SQL:\", \"Here is the SQL:\", etc.).\n" + "- Do NOT include comments.\n" + "- The SQL must start with SELECT and end with a semicolon.\n" + "- Return nothing else.\n" + ) + raw_sql = client.complete(system, user, max_tokens=1024).strip() + print(f"[DEBUG] LLM raw SQL output: {raw_sql!r}") + state["raw_generated_sql"] = raw_sql + + sanitized = raw_sql + if sanitized.startswith("```"): + sanitized = sanitized.split("\n", 1)[-1] + if sanitized.endswith("```"): + sanitized = sanitized.rsplit("\n", 1)[0] + + match = re.search(r'(SELECT\s.*?;)', sanitized, re.IGNORECASE | re.DOTALL) + if match: + sanitized = match.group(1).strip() + + print(f"[DEBUG] Sanitized SQL: {sanitized!r}") + print(f"[DEBUG] Provider: {client.provider_name}, Model: {client.model}") + + cleaned = sanitized.strip() + if not cleaned.upper().startswith("SELECT") or not cleaned.rstrip().endswith(";"): + return { + "error": ( + "Invalid SQL generated by LLM. " + f"raw_output: {raw_sql!r}, " + f"sanitized_sql: {sanitized!r}, " + "reason: 'Does not start with SELECT and end with ;'" + ) + } + + state["generated_code"] = sanitized + state["executed_sql"] = sanitized + 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: + raw = state.get("raw_generated_sql", "") + executed_sql = state.get("executed_sql", "") + error_msg = ( + f"execute_query failed: {exc}. " + f"raw_output: {raw!r}, sanitized_sql: {executed_sql!r}" + ) + return {"error": error_msg} + 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_fraud_detection.py b/src/graph/nodes_fraud_detection.py new file mode 100644 index 0000000..1437979 --- /dev/null +++ b/src/graph/nodes_fraud_detection.py @@ -0,0 +1,131 @@ +"""Graph nodes — fraud detection analyst path. + +Reuses the live-DB execution adapter. Stores anomaly-specific metadata +so the API response can expose anomaly flags and sensitive warnings. +""" +from __future__ import annotations + +import json +import time + +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("fraud_detection_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 = {} + if raw.startswith("{"): + try: + plan = json.loads(raw) + except Exception: + plan = {"notes": raw} + else: + plan = {"notes": 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("fraud_detection_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: + 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("fraud_detection_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 "fraud detection analyst failed" + return state + + +def finalize(state: AgentState) -> AgentState: + if not state.get("status"): + state["status"] = "completed" + return state diff --git a/src/graph/nodes_live_db.py b/src/graph/nodes_live_db.py new file mode 100644 index 0000000..bff21a1 --- /dev/null +++ b/src/graph/nodes_live_db.py @@ -0,0 +1,139 @@ +"""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 = {} + if raw.startswith("{"): + try: + plan = json.loads(raw) + except Exception: + plan = {"notes": raw} + else: + plan = {"notes": 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/graph/state.py b/src/graph/state.py index 6720d27..02d0ea4 100644 --- a/src/graph/state.py +++ b/src/graph/state.py @@ -5,11 +5,32 @@ class AgentState(TypedDict, total=False): - run_id: str - input_text: str - instruction: str - output_text: str - provider: str - model: str - status: str - error: str | None + run_id: str + input_text: str + instruction: str + output_text: str + provider: str + model: str + status: str + error: str | None + question: str + schema_summary: str | None + data_source: str | None + row_limit: int | None + csv_file_ids: list[int] | None + memory_context: str | None + saved_workspace_id: int | None + query_plan: dict | None + tables_touched: list[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: dict | None + answer_text: str | None + followups: list[str] | None + anomaly_flags: list[str] | None + sensitive_warning: str | None + token_usage: dict | None 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..f417027 100644 --- a/src/llm/providers/factory.py +++ b/src/llm/providers/factory.py @@ -1,33 +1,27 @@ -"""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). -""" +"""Provider factory — resolves provider + model from settings.""" from __future__ import annotations from src.config.settings import get_settings 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.openrouter import OpenRouterProvider +from src.llm.providers.nim import NIMProvider 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 NIMProvider(api_key=s.key_for("openrouter"), model=model, base_url=s.openai_base_url) + raise LLMError( + "No LLM API key configured. Set one of AGENT_ANTHROPIC_API_KEY, " + "AGENT_GEMINI_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..45b7465 --- /dev/null +++ b/src/llm/providers/nim.py @@ -0,0 +1,90 @@ +"""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: + url = f"{self._base_url}/chat/completions" + payload = { + "model": self.model, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + try: + ret = httpx.post( + url, + headers={ + "Authorization": f"Bearer {self._api_key}", + "content-type": "application/json", + }, + json=payload, + timeout=120.0, + ) + except httpx.LocalProtocolError as e: + raise RuntimeError( + f"nim protocol error url={url!r} payload={_safe_payload(payload)!r} error={e!s}" + ) from e + except httpx.HTTPError as e: + raise RuntimeError(f"nim http error url={url!r} error={e!s}") from e + + if ret.status_code != httpx.codes.OK: + raise RuntimeError( + f"nim http error status={_status(ret)} url={url!r} body={_body(ret)[:1200]!r}" + ) + try: + data = ret.json() + except Exception as exc: + raise LLMError(f"nim returned non-JSON response: {_body(ret)[:500]!r}") from exc + 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) + + +def _status(response: httpx.Response) -> int: + return getattr(response, "status_code", -1) + + +def _body(response: httpx.Response) -> str: + text = getattr(response, "text", None) + if callable(text): + try: + return text() + except Exception: + pass + return getattr(response, "content", b"") or b"" + + +def _safe_payload(payload: dict) -> dict: + out = dict(payload) + msgs = out.get("messages") or [] + if msgs: + first = dict(msgs[0]) + first["content"] = (first.get("content") or "")[:120] + ("..." if len(first.get("content") or "") > 120 else "") + out["messages"] = [first] + msgs[1:] + return out diff --git a/src/llm/providers/openrouter.py b/src/llm/providers/openrouter.py deleted file mode 100644 index ed509b4..0000000 --- a/src/llm/providers/openrouter.py +++ /dev/null @@ -1,50 +0,0 @@ -"""OpenRouter provider — OpenAI-compatible chat/completions over httpx. - -Any model on OpenRouter works via the ``provider/model`` id. This adapter also -covers self-hosted OpenAI-compatible endpoints (change the base URL in .env). -""" -from __future__ import annotations - -import httpx - -from src.llm.providers.base import LLMError, LLMProvider -from src.llm.retry import with_retries - - -class OpenRouterProvider(LLMProvider): - name = "openrouter" - - def __init__(self, api_key: str, model: str, base_url: str = "https://openrouter.ai/api/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"openrouter returned no choices: {list(data)}") from exc - if not text: - raise LLMError("openrouter 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/fraud_detection_answer.md b/src/prompts/fraud_detection_answer.md new file mode 100644 index 0000000..97cdca2 --- /dev/null +++ b/src/prompts/fraud_detection_answer.md @@ -0,0 +1,8 @@ +You turn a structured fraud-detection query result into a concise analyst answer. + +Constraints: +- Base the answer ONLY on the provided SQL result. +- State the answer first, then note counts, key columns, and any suspicious pattern. +- If patterns look like repeated complaints, reused IDs, or timing anomalies, call them out plainly. +- Do not invent suspects, case statuses, or legal conclusions. +- Return ONLY JSON: {"answer": "...", "followups": [...], "anomalies": [...], "sensitive_warning": null} diff --git a/src/prompts/fraud_detection_plan.md b/src/prompts/fraud_detection_plan.md new file mode 100644 index 0000000..9bab1ab --- /dev/null +++ b/src/prompts/fraud_detection_plan.md @@ -0,0 +1,8 @@ +You are a precise analyst planner for fraud detection over police data. + +Given a QUESTION and a SCHEMA summary, choose: +- which tables cover transactions, complaints, FIR metadata, or case links +- which columns can indicate repeated patterns, timing anomalies, or linked entities +- a short rationale for the chosen tables and columns + +Return ONLY compact JSON: {"tables": [...], "columns": [...], "join_conditions": "...", "rationale": "..."} diff --git a/src/prompts/fraud_detection_sql.md b/src/prompts/fraud_detection_sql.md new file mode 100644 index 0000000..fc23cd8 --- /dev/null +++ b/src/prompts/fraud_detection_sql.md @@ -0,0 +1,8 @@ +You write read-only SQL for SQL Server over police data tables. + +Requirements: +- Emit exactly ONE SELECT statement. +- Use T-SQL syntax appropriate for Microsoft SQL Server. +- Do NOT use CTEs, window functions, or DML/DDL. +- Favour filters that surface repeated patterns, linked entities, or timing anomalies for fraud review. +- Return ONLY the SQL; no markdown fences. 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/conftest.py b/tests/conftest.py index 668a391..72191a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,38 +11,39 @@ @pytest.fixture(autouse=True) def _reset_singletons(): - """Reset cached settings + engine so env patches take effect per test.""" - import src.config.settings as settings_mod - import src.db.session as session_mod + """Reset cached settings + engine so env patches take effect per test.""" + import src.config.settings as settings_mod + import src.db.session as session_mod - settings_mod._settings = None - session_mod._engine = None - session_mod._SessionLocal = None - yield - settings_mod._settings = None - if session_mod._engine is not None: - session_mod._engine.dispose() - session_mod._engine = None - session_mod._SessionLocal = None + settings_mod._settings = None + session_mod._engine = None + session_mod._SessionLocal = None + yield + settings_mod._settings = None + if session_mod._engine is not None: + session_mod._engine.dispose() + session_mod._engine = None + session_mod._SessionLocal = None @pytest.fixture(autouse=True) def _isolated_db(tmp_path, monkeypatch): - """Point the app at a fresh SQLite file — never the user's real DB.""" - monkeypatch.setenv("AGENT_DATABASE_URL", f"sqlite:///{tmp_path}/test.db") - yield + """Point the app at a fresh SQLite file — never the user's real DB.""" + monkeypatch.setenv("AGENT_DATABASE_URL", f"sqlite:///{tmp_path}/test.db") + yield @pytest.fixture() def no_keys(monkeypatch): - """Simulate 'no provider key configured' regardless of the user's .env. - - Env vars override .env values in pydantic-settings, so empty strings are - enough — the file itself is never touched. - """ - monkeypatch.setenv("AGENT_LLM_PROVIDER", "auto") - monkeypatch.setenv("AGENT_LLM_MODEL", "") - monkeypatch.setenv("AGENT_ANTHROPIC_API_KEY", "") - monkeypatch.setenv("AGENT_GEMINI_API_KEY", "") - monkeypatch.setenv("AGENT_OPENROUTER_API_KEY", "") - yield + """Simulate 'no provider key configured' regardless of the user's .env. + + Env vars override .env values in pydantic-settings, so empty strings are + enough — the file itself is never touched. + """ + monkeypatch.setenv("AGENT_LLM_PROVIDER", "auto") + monkeypatch.setenv("AGENT_LLM_MODEL", "") + monkeypatch.setenv("AGENT_ANTHROPIC_API_KEY", "") + monkeypatch.setenv("AGENT_GEMINI_API_KEY", "") + monkeypatch.setenv("OPENAI_API_KEY", "") + monkeypatch.setenv("OPENAI_COMPAT_API_KEY", "") + yield 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_fraud_detection_api.py b/tests/unit/test_fraud_detection_api.py new file mode 100644 index 0000000..e36a163 --- /dev/null +++ b/tests/unit/test_fraud_detection_api.py @@ -0,0 +1,23 @@ +"""Unit tests for the fraud detection 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_fraud_query_rejects_empty_schema(client): + res = client.post("/fraud-detection/query", json={"question": "suspicious repeats", "schema_summary": ""}) + assert res.status_code == 400 + + +def test_fraud_query_missing_run_is_404(client): + res = client.get("/fraud-detection/runs/nope") + assert res.status_code == 404 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..91a5eb1 --- /dev/null +++ b/tests/unit/test_runs_api.py @@ -0,0 +1,63 @@ +"""Unit tests for the runs API — history + audit trace + source dispatch.""" +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" + + +def test_create_run_defaults_to_transform(client): + res = client.post("/runs", json={"text": "hello", "instruction": "upper"}) + assert res.status_code == 200 + body = res.json()["data"] + assert body["status"] in {"completed", "failed"} + assert "run_id" in body + + +def test_create_run_preserves_data_source_field(client): + res = client.post( + "/runs", + json={ + "text": "hello", + "instruction": "upper", + "data_source": "transform", + }, + ) + assert res.status_code == 200 + body = res.json()["data"] + assert body["status"] in {"completed", "failed"} + assert "run_id" in body + + +def test_create_run_rejects_invalid_data_source_value(client): + res = client.post("/runs", json={"text": "hello", "data_source": "whatever"}) + assert res.status_code == 422 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 49fe1cf..f8feffd 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -2,35 +2,36 @@ def test_no_key_resolves_to_stub(no_keys): - s = get_settings() - assert s.resolve_provider() == "stub" - assert s.resolve_model() == "" + s = get_settings() + assert s.resolve_provider() == "stub" + assert s.resolve_model() == "" def test_auto_detects_anthropic(no_keys, monkeypatch): - monkeypatch.setenv("AGENT_ANTHROPIC_API_KEY", "test-key-not-real") - s = get_settings() - assert s.resolve_provider() == "anthropic" - assert s.resolve_model() == "claude-sonnet-4-6" + monkeypatch.setenv("AGENT_ANTHROPIC_API_KEY", "test-key-not-real") + s = get_settings() + assert s.resolve_provider() == "anthropic" + assert s.resolve_model() == "claude-sonnet-4-6" -def test_auto_detects_openrouter(no_keys, monkeypatch): - monkeypatch.setenv("AGENT_OPENROUTER_API_KEY", "test-key-not-real") - s = get_settings() - assert s.resolve_provider() == "openrouter" - assert s.resolve_model() # provider-prefixed default +def test_auto_detects_nvidia_nim(no_keys, monkeypatch): + monkeypatch.setenv("AGENT_OPENAI_API_KEY", "test-key-not-real") + monkeypatch.setenv("AGENT_OPENAI_BASE_URL", "https://integrate.api.nvidia.com/v1") + s = get_settings() + assert s.resolve_provider() == "nim" + assert s.resolve_model() == "meta/llama-3-8b-instruct" def test_explicit_provider_and_model_win(no_keys, monkeypatch): - monkeypatch.setenv("AGENT_LLM_PROVIDER", "gemini") - monkeypatch.setenv("AGENT_LLM_MODEL", "gemini-2.5-flash") - monkeypatch.setenv("AGENT_GEMINI_API_KEY", "test-key-not-real") - s = get_settings() - assert s.resolve_provider() == "gemini" - assert s.resolve_model() == "gemini-2.5-flash" + monkeypatch.setenv("AGENT_LLM_PROVIDER", "gemini") + monkeypatch.setenv("AGENT_LLM_MODEL", "gemini-2.5-flash") + monkeypatch.setenv("AGENT_GEMINI_API_KEY", "test-key-not-real") + s = get_settings() + assert s.resolve_provider() == "gemini" + assert s.resolve_model() == "gemini-2.5-flash" def test_database_url_isolated_by_fixture(tmp_path): - # conftest points every test at a tmp sqlite file - assert get_settings().database_url.startswith("sqlite:///") - assert "test.db" in get_settings().database_url + # conftest points every test at a tmp sqlite file + assert get_settings().database_url.startswith("sqlite:///") + assert "test.db" in get_settings().database_url diff --git a/tests/unit/test_startup.py b/tests/unit/test_startup.py new file mode 100644 index 0000000..0e83343 --- /dev/null +++ b/tests/unit/test_startup.py @@ -0,0 +1,14 @@ +"""Regression test — importing the app and running server startup paths still works.""" +from __future__ import annotations + +import importlib + + +def test_import_app_package(): + mod = importlib.import_module("src.api") + assert hasattr(mod, "create_app") + + +def test_import_entrypoint(): + mod = importlib.import_module("src.__main__") + assert hasattr(mod, "main") 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" },