diff --git a/frontend/public/app.js b/frontend/public/app.js index 72fb850..5a9e5a5 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -1,9 +1,13 @@ // Zero-build baseline frontend. Single-origin: the page is served by the // backend at /app, so API calls are same-origin relative paths. + "use strict"; const $ = (id) => document.getElementById(id); +let sessionId = null; + +// Load health badge async function loadHealth() { const badge = $("provider-badge"); try { @@ -22,56 +26,185 @@ async function loadHealth() { } } -async function runTransform() { +// Create a session and return its ID +async function createSession() { + const res = await fetch("/sessions", { method: "POST" }); + if (!res.ok) throw new Error(`Failed to create session: ${res.status}`); + const data = await res.json(); + return data.data.id; +} + +// Upload CSV files to the session +async function uploadCsvs(sessionId, files) { + const form = new FormData(); + for (const file of files) { + form.append("files", file); // Note: changed from "file" to "files" to match backend + } + const res = await fetch(`/sessions/${sessionId}/csv`, { + method: "POST", + body: form, + }); + if (!res.ok) throw new Error(`Failed to upload CSV: ${res.status}`); + return await res.json(); +} + +// Run the agent with a question and session ID +async function runAnalysis(sessionId, question) { + const res = await fetch("/runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, question }), + }); + if (!res.ok) throw new Error(`Failed to run analysis: ${res.status}`); + return await res.json(); +} + +// Display the plan in the UI +function displayPlan(planText) { + $("plan").textContent = planText || "(no plan)"; +} + +// Display the generated SQL/code +function displayGeneratedCode(codeText) { + $("generated-code").textContent = codeText || "(no code)"; +} + +// Display the results in a table +function displayResults(rows) { + const table = $("result-table"); + const thead = table.tHead || table.createTHead(); + const tbody = table.tBody || table.createTBody(); + + // Clear existing content + thead.innerHTML = ""; + tbody.innerHTML = ""; + + if (!rows || rows.length === 0) { + tbody.innerHTML = "No data"; + return; + } + + // Create header from the keys of the first row + const headers = Object.keys(rows[0]); + const headerRow = thead.insertRow(); + headers.forEach((header) => { + const th = document.createElement("th"); + th.textContent = header; + headerRow.appendChild(th); + }); + + // Create rows + rows.forEach((row) => { + const tr = tbody.insertRow(); + headers.forEach((key) => { + const td = tr.insertCell(); + td.textContent = row[key] !== null && row[key] !== undefined ? row[key] : ""; + }); + }); +} + +// Update KPIs (placeholder implementation) +function updateKpis(rows) { + // For simplicity, we'll show row count and placeholder for other KPIs + $("kpi-rows").textContent = rows ? rows.length : 0; + // In a real app, we would compute date range and distinct columns from the schema + $("kpi-date-range").textContent = "N/A"; + $("kpi-categories").textContent = "N/A"; +} + +// Update chart placeholders (placeholder implementation) +function updateCharts() { + $("chart-bar").textContent = "[chart]"; + $("chart-line").textContent = "[chart]"; + $("chart-pie").textContent = "[chart]"; +} + +// Main function to handle the Analyze button click +async function runAnalysisClicked() { const btn = $("run-btn"); const status = $("status"); - const errBox = $("error"); + const errorBox = $("error"); const wrap = $("result-wrap"); - const text = $("text").value.trim(); - const instruction = $("instruction").value.trim(); - - errBox.hidden = true; - wrap.hidden = true; + // Get files and question + const fileInput = $("csv-files"); + const questionInput = $("question"); + const files = fileInput.files; + const question = questionInput.value.trim(); - if (!text) { - errBox.textContent = "Paste some text first — the input can't be empty."; - errBox.hidden = false; + // Basic validation + if (files.length === 0) { + errorBox.textContent = "Please select at least one CSV file."; + errorBox.hidden = false; + return; + } + if (!question) { + errorBox.textContent = "Please enter a question."; + errorBox.hidden = false; return; } + // Disable UI and clear previous results btn.disabled = true; - status.textContent = "Running… (one real LLM call)"; + status.textContent = "Analyzing..."; status.hidden = false; + errorBox.hidden = true; + wrap.hidden = true; try { - const res = await fetch("/runs", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ text, instruction }), - }); - const body = await res.json(); + // Step 1: Create or reuse session + if (!sessionId) { + sessionId = await createSession(); + } + + // Step 2: Upload CSV files + await uploadCsvs(sessionId, files); - if (!res.ok) { - const msg = body?.detail?.message || `HTTP ${res.status}`; - throw new Error(msg); + // Step 3: Run analysis + const response = await runAnalysis(sessionId, question); + const data = response.data; + + if (data.status === "failed") { + throw new Error(data.error_message || "Analysis failed"); } - const run = body.data; - if (run.status === "failed") { - throw new Error(run.error_message || "The agent run failed."); + + // Step 4: Update UI with results + displayPlan(data.plan_text || ""); + displayGeneratedCode(data.generated_code || ""); + + // For simplicity, we assume the result is in the output_text as a string. + // In a real implementation, we would parse the structured result from the agent. + // Here, we'll just show the output_text as a placeholder for the results. + // We'll also try to parse a JSON array from the output_text for the table. + let rows = []; + try { + // Try to parse the output_text as JSON (if it's a JSON array) + const parsed = JSON.parse(data.output_text); + if (Array.isArray(parsed)) { + rows = parsed; + } + } catch (e) { + // If not JSON, we'll just show the output_text in a single cell table + rows = [{ output: data.output_text }]; } - $("result").textContent = run.output_text; - $("result-meta").textContent = - `run ${run.run_id} · ${run.provider} · ${run.model}`; + + displayResults(rows); + updateKpis(rows); + updateCharts(); + + // Show the results wrapper wrap.hidden = false; } catch (err) { - errBox.textContent = err.message; - errBox.hidden = false; + errorBox.textContent = err.message; + errorBox.hidden = false; } finally { btn.disabled = false; status.hidden = true; } } -$("run-btn").addEventListener("click", runTransform); -loadHealth(); +// Event listeners +document.addEventListener("DOMContentLoaded", () => { + loadHealth(); + $("run-btn").addEventListener("click", runAnalysisClicked); +}); \ No newline at end of file diff --git a/frontend/public/index.html b/frontend/public/index.html index 014097d..c078816 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,39 +3,65 @@ - Zero-Shot Agent — Transform + CSV Analyst Agent
-

Zero-Shot Agent

+

CSV Analyst Agent

- -
-

Transform text

-

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

+ +
+

CSV Analyst

+

+ Upload CSV files and ask questions in plain English. The agent will + inspect the data, generate a SQL query, and return the answer with a + table, charts, and key metrics. +

- - + + - - + + - +
@@ -45,4 +71,4 @@

Result

- + \ No newline at end of file diff --git a/frontend/public/styles.css b/frontend/public/styles.css index fc37523..dc29e07 100644 --- a/frontend/public/styles.css +++ b/frontend/public/styles.css @@ -18,7 +18,7 @@ body { 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; } .badge { @@ -33,12 +33,16 @@ body { .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); } -input[type="text"], textarea { +input[type="file"], +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; } -input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; } +input[type="file"]:focus, +input[type="text"]: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; @@ -50,10 +54,44 @@ button:disabled { opacity: 0.55; cursor: wait; } 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 { +pre { background: #10131a; border: 1px solid #2b3245; border-radius: 8px; - padding: 14px; white-space: pre-wrap; word-wrap: break-word; margin: 0; + padding: 14px; white-space: pre-wrap; word-wrap: break-word; margin: 0 0 10px 0; +} +#results-container { + margin: 10px 0; + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; +} +th, td { + padding: 8px 12px; + border: 1px solid #2b3245; + text-align: left; +} +thead { + background: #1a1d24; +} +tbody tr:nth-child(even) { + background: #10131a; +} +#charts, #kpis { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin: 10px 0; +} +#charts > div, #kpis > div { + background: #10131a; + border: 1px solid #2b3245; + border-radius: 8px; + padding: 10px 14px; + flex: 1; + min-width: 150px; } .meta { color: var(--muted); font-size: 0.78rem; } .foot { margin-top: 28px; color: var(--muted); font-size: 0.85rem; text-align: center; } -.foot a { color: var(--accent); } +.foot a { color: var(--accent); } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 10c0d05..b3a3ac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ "langgraph>=0.2.28", "httpx>=0.27", "structlog>=24.1", + "duckdb>=1.0", + "pyodbc>=5.0", + "python-multipart>=0.0.9", ] [dependency-groups] diff --git a/spec/agent.md b/spec/agent.md index 49f33fa..a8b26fd 100644 --- a/spec/agent.md +++ b/spec/agent.md @@ -1,218 +1,186 @@ -# Agent +# Agent Graph -> 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. -> +Pattern: **Plan → Query → Execute → Explain** with retry-on-error and a single clarifying-round sub-loop. ---- - -## Agent Architecture Pattern - - - -| Pattern | Use when | -|---------|----------| -| **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. | - -**Chosen:** +Source: `harness/patterns/agentic-ai.md` — the Plan–Execute–Check pattern, extended with an explicit Clarify step to keep the human in the loop on DB ambiguities without stalling the pipeline. --- -## LLM Provider & Model +## Graph Pattern - - -| Agent / Node | Provider | Model ID | Rationale | -|-------------|----------|----------|-----------| -| | Anthropic | | | - -**Fallback behaviour:** - -**Prompt strategy:** +``` + ┌─────────────┐ + start ──│ plan │─── plan_text + └──────┬──────┘ + │ + ┌──────▼──────┐ + │ query │─── generated_code, code_language + └──────┬──────┘ + │ + ┌──────▼─────────┐ + │ execute │─── rows, row_count, latency_ms + └──────┬─────────┘ + │ + ┌──────▼──────┐ + │ explain │─── nl_answer, chart_spec, kpis, result_hash + └──────┬──────┘ + │ + finalize + (status=completed) + +Any node → on error → handle_error (status=failed) OR handle_clarify (one clarifying prompt, retry once) +``` --- -## Tools & Tool Calling - - - -| Tool name | Description | Inputs | Output | Side-effects | -|-----------|-------------|--------|--------|--------------| -| | | | | | - -**Tool selection strategy:** - -**Tool failure handling:** +## State + +Type: dict keyed by strings (LangGraph `StateGraph` with `add` reducer on list-typed values). + +| Key | Type | Description | +|-----|------|-------------| +| `run_id` | str | The run row primary key (set once at graph entry) | +| `session_id` | str | Session ID for CSV metadata + conversation history caching | +| `input_text` | str | The user's latest question | +| `conversation_history` | list[{role: user\|assistant, content: str}] | Prior Q&A in this session; fed to every node as additional context | +| `instruction` | str | Alias for input_text in the current run | +| `plan_text` | str \| None | Structured plan from the plan node | +| `generated_code` | str \| None | SQL or Python from the query node | +| `code_language` | str \| None | `"sql"` or `"python"` | +| `rows` | list[dict] \| None | Result rows from the execute node | +| `row_count` | int \| None | Filled by execute node | +| `latency_ms` | float \| None | Filled by execute node | +| `nl_answer` | str \| None | Final natural-language explanation | +| `chart_spec` | dict \| None | `{type, x, y, title, color_by?}` | +| `kpis` | list[dict] \| None | `[{label, value, unit?}, ...]` | +| `result_hash` | str \| None | SHA-256 of row payload bytes | +| `source` | str \| None | `duckdb` \| `mssql` \| `mssql-cache` | +| `error` | str \| None | Error message on failure; cleared on retry | +| `status` | str | `pending` → `running` → `completed` \| `failed` \| `clarifying` | +| `clarify_prompt` | str \| None | A clarifying question for the user, set by `handle_clarify` | +| `cache_hit` | bool \| None | True when served from msql-cache | --- -## Agent State +## Nodes - +### `plan(state) -> partial_state` -```python -class AgentState(TypedDict): - # Identity - run_id: int # set at initialisation - - # Input - # ... # fields populated from the trigger +Input: `input_text` + `conversation_history` + schema summary for the current session. - # Pipeline data (populated progressively by nodes) - # ... +Behaviour: +- Calls LLM with a structured plan prompt (see `src/prompts/plan.md`) that logs all tables, columns, and types known for this session. +- Outputs a plan_text JSON string listing: tables, columns, filters, aggregations, joins, sort, limit. +- Never executes any query. - # Output - # ... # final result fields +Failure modes → `handle_error` with `error_message` set. - # Control - error: str | None # set by any node on fatal failure - checkpoint: str | None # last completed node (for resume) -``` +Output: `{plan_text, error, status}`. --- -## Nodes / Steps - - +### `query(state) -> partial_state` -### `node_[name]` +Input: `plan_text` + `conversation_history` + `plan_text`. -**Reads from state:** +Behaviour: +- Calls LLM with a plan-to-code prompt (see `src/prompts/query.md`). +- Generates **exactly one** executable target — DuckDB SQL (preferred) or Python+pandas (allowed for non-standard string ops or date functions not supported by DuckDB; the node must explicitly flag `code_language=python` and the execution node must use the runner service's Python runner). +- Validates the generated SQL syntactically in-process via DuckDB's `.execute()` before returning; on syntax error, retries once with the error appended to the prompt. -**Writes to state:** - -**LLM call:** - -**External calls:** - -| System | Operation | On Failure | -|--------|-----------|------------| -| | | | - -**Behaviour:** +Output: `{generated_code, code_language, error, status}`. --- -## Graph / Flow Topology +### `execute(state) -> partial_state` - +Input: `generated_code` + `code_language` + `session_id` + `source` flag. -``` -START - │ - ▼ -node_a ──(error)──► node_handle_error ──► END - │ - ▼ -node_b ──(condition)──► node_c - │ │ - │ ▼ - └──────────────────► node_finalize - │ - ▼ - END -``` - -**Conditional edges:** +Behaviour: +- Phase 1: executes on the session's DuckDB via `src/services/query_exec.py` (`execute_on_duckdb`). +- Phase 2: `execute_on_mssql` first checks DuckDB cache (materialized view per table-set); on hit, returns cache rows with `cache_hit=True`; on miss, opens a `SET TRANSACTION READ ONLY` pyodbc connection, runs the query, writes the result set to the cache file, returns rows with `cache_hit=False`. +- Captures `row_count`, `latency_ms` (wall-clock per query). +- Result is JSON-serialized deterministically (sorted keys, null-safe); `result_hash` = `sha256` of the UTF-8 bytes. -| Source node | Condition | Target | -|-------------|-----------|--------| -| | | | +Output: `{rows, row_count, latency_ms, result_hash, source, cache_hit, error, status}`. --- -## Memory & Context +### `explain(state) -> partial_state` - +Input: `rows`, `row_count`, `input_text`, `conversation_history`, `plan_text`. -| Scope | Mechanism | What is stored | -|-------|-----------|----------------| -| **Within a run** | LangGraph state | All in-progress data | -| **Across runs** | | | -| **Conversation** | | | +Behaviour: +- Calls LLM with `src/prompts/explain.md`: summarise rows, select chart spec (bar / line / pie — pick only one unless rows unambiguously support a stacked/grouped structure, in which case a grouped bar is acceptable), compute 3–6 dashboard KPIs from the rows (count, sum, average, date range, distinct values, time-trend direction), and emit a result_hash echo. +- Always include the raw row_count and latency_ms verbatim in the answer block for audit. -**Context window management:** +Output: `{nl_answer, chart_spec, kpis, error, status}`. --- -## Human-in-the-Loop Checkpoints +### `finalize(state) -> partial_state` - +Merges executor output into RunRow: status=completed, output_text (json blob with nl_answer, chart_spec, kpis, audit_block), provider, model. Zero logic — just a terminal state merge. -| Checkpoint | What is shown to the user | Expected user action | Timeout / default | -|------------|--------------------------|----------------------|-------------------| -| | | | | +Output: `{status: "completed"}`. --- -## Error Handling & Recovery - - - -**Node-level:** +### `handle_error(state) -> partial_state` -**Graph-level (handle_error node):** -- Reads: `state.error`, `state.run_id` -- Updates DB: run status → "failed", `error_message`, `completed_at` -- Logs error with `run_id` context -- Terminates graph +Routes to `status="failed"`. Preserves `error`, `error_message`, `generated_code` (partial if available from the failing node), `plan_text` (if present), and `latency_ms` if execute started. Marks the run failed in DB via `update_run(run_id, status="failed", error_message=...)`. -**Resume / retry strategy:** - -**Partial failure:** +Output: `{status: "failed"}`. --- -## Observability +### `handle_clarify(state) -> partial_state` - +One-shot sub-loop: emits a user-facing clarifying question (e.g. "Column `PS` not found — did you mean `ps_name` or `police_station`?") and routes the run back to the user without failing. The frontend re-submits the original question prepended with the user's reply; the graph re-enters at `plan` with the full context. Maximum one clarification round per step; beyond that the run fails gracefully. -| 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 | +Output: `{status: "clarifying", clarify_prompt, error: None}`. --- -## Concurrency Model - - +## Concurrency -- **Run isolation:** -- **Parallel nodes within a run:** -- **Checkpointing:** +Each run is a single in-process LangGraph `invoke`. No parallel execution within a single run. Multiple concurrent runs share the same session's DuckDB but do not share a transaction — DuckDB handles MVCC internally. --- -## Graph Assembly (`agent/graph.py`) - - +## Assembly (runner.py pseudocode) ```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) +from langgraph.graph import StateGraph, END +from src.graph.nodes import plan, query, execute, explain, finalize, handle_error, handle_clarify -graph.set_entry_point("node_a") +graph = StateGraph(AgentState) +graph.add_node("plan", plan) +graph.add_node("query", query) +graph.add_node("execute", execute) +graph.add_node("explain", explain) +graph.add_node("finalize", finalize) +graph.add_node("handle_error", handle_error) +graph.add_node("handle_clarify", handle_clarify) + +graph.add_edge("plan", "query") +graph.add_edge("query", "execute") +graph.add_edge("execute", "explain") +graph.add_edge("explain", "finalize") +graph.add_edge("finalize", END) -graph.add_conditional_edges( - "node_a", - lambda s: "handle_error" if s.get("error") else "node_b", -) +def route_after(state): + if state.get("status") == "clarifying": + return "handle_clarify" + if state.get("error"): + return "handle_error" + # else continue flow + return ... -graph.add_edge("node_b", "finalize") -graph.add_edge("finalize", END) -graph.add_edge("handle_error", END) +graph.add_conditional_edges("plan", route_after, {"handle_clarify": "handle_clarify", "handle_error": "handle_error", "query": "query"}) +# same pattern for query and execute -compiled_graph = graph.compile() +agentic_ai = graph.compile() ``` + +The existing `src/graph/agent.py` and `src/graph/edges.py` are updated in place to implement this assembly. diff --git a/spec/api.md b/spec/api.md index 442a58b..c8669ac 100644 --- a/spec/api.md +++ b/spec/api.md @@ -1,41 +1,142 @@ # API -> Fill in this section — see comments below. +Base path: `/api/v1`. All responses JSON; errors use `{"detail": "..."}` or the baseline's `api_error` envelope. + +## Authentication + +Phase 1: open (no auth). Phase 3: JWT via `Authorization: Bearer `; base implementation `src/api/auth.py`. --- -## API Style +## Endpoints + +### POST /api/v1/sessions - +Create a new conversation session. -## Endpoints / Commands +Request: `{}` + +Response `201`: +```json +{ + "id": "uuid", + "status": "active", + "created_at": "ISO-8601", + "schema_summary": null +} +``` - +--- -### `` +### GET /api/v1/sessions/{session_id} -**Purpose:** +Fetch session metadata. -**Request:** +Response `200`: ```json { - "": "" + "id": "uuid", + "status": "active", + "created_at": "ISO-8601", + "updated_at": "ISO-8601", + "schema_summary": { "tables": [...], "row_counts": {...} }, + "turn_count": 3 } ``` -**Response:** +--- + +### POST /api/v1/sessions/{session_id}/csv + +Upload one or more CSV files into the session's DuckDB. + +Request: `multipart/form-data`; field name `files` (multiple). + +Response `200`: ```json { - "": "" + "session_id": "uuid", + "uploaded": ["fir_2024.csv", "chargesheet_2024.csv"], + "schema_summary": { "tables": [...], "row_counts": {...} }, + "errors": [] } ``` -**Error cases:** -| Status | Condition | -|--------|-----------| -| 400 | | -| 500 | | +Errors: `422` if no files, `413` if any file exceeds `MAX_CSV_BYTES`, `500` on DuckDB write failure. -## Authentication +--- + +### POST /api/v1/sessions/{session_id}/runs + +Submit a natural-language question. + +Request: +```json +{ "input_text": "top 10 police stations by total FIRs" } +``` + +Response `200` (Phase 1 — synchronous, real-key path): +```json +{ + "run_id": "uuid", + "status": "completed" | "failed" | "clarifying", + "output_text": "{\"nl_answer\": \"...\", \"chart_spec\": {...}, \"kpis\": [...], \"audit_block\": {...}, \"plan_text\": \"...\", \"generated_code\": \"SELECT ...\", \"rows\": [...], \"row_count\": 10, \"latency_ms\": 1.24, \"result_hash\": \"sha256hex\", \"source\": \"duckdb\"}", + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4-6", + "plan_text": "SELECT ps_name, COUNT(*) ...", + "generated_code": "SELECT ps_name, COUNT(*) ...", + "rows": [...], + "row_count": 10, + "latency_ms": 1.24, + "result_hash": "sha256hex", + "source": "duckdb", + "clarify_prompt": null +} +``` + +When `status=clarifying`, `clarify_prompt` contains a user-facing string the UI shows; `output_text` is still present (may contain partial plan_text). + +When `status=failed`, `error_message` is populated instead of `output_text`. + +--- + +### GET /api/v1/sessions/{session_id}/runs/{run_id} + +Fetch a prior run by id. Same response shape as POST. + +--- + +### POST /api/v1/db/connect (Phase 2) + +Submit MsSQL connection details. Server validates with `SET TRANSACTION READ ONLY` before accepting. + +Request: +```json +{ "connection_string": "mssql+pyodbc://...", "connection_label": "CCTNS-Prod" } +``` + +Response `200`: +```json +{ "session_id": "uuid", "schema_summary": { "tables": [...] }, "cache_status": "ready" } +``` + +--- + +### GET /api/v1/db/cache/status (Phase 2) + +Return cache hydration state for the current session. + +Response `200`: +```json +{ "session_id": "uuid", "tables": [{"name": "firs", "state": "cached", "last_refreshed_at": "..."}] } +``` + +--- + +## Rate-limiting + +Per-session window: `RATE_LIMIT_RUNS_PER_MINUTE` (default 20). Burst-bucket: equal to the per-minute limit. Limit applies to POST /runs only. Return `429 Retry-After` when exceeded. + +## Versioning - +Single-version API in Phase 1. Phase 3+ adds `Accept: application/vnd.updatanalyst.v2+json` negotiation. Path prefix `/v1` to be added later without breaking embedding. diff --git a/spec/architecture.md b/spec/architecture.md index e5e3bd9..b1fccf3 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -1,68 +1,123 @@ # Architecture -> Fill in this section — see comments below. +## System Overview ---- +A single FastAPI process serves both the API and the zero-build static frontend. The process owns one SQLite database for session state, CSV metadata, and the append-only `query_log`. The actual data analysis happens in-process against DuckDB — a local, zero-infra analytical engine — so the live production database is never queried by the analyst-facing path in Phase 1+. In Phase 2, a background cache layer populates DuckDB from the live MsSQL via materialized refresh silos. -## System Overview +``` +Browser (internal network) + └─ http://localhost:8001/app/ (zero-build static UI, single-origin) + ├─ POST /api/v1/sessions — create a new conversation session + ├─ POST /api/v1/sessions/{id}/csv — upload one or more CSVs + ├─ POST /api/v1/sessions/{id}/runs — submit a natural-language question + └─ GET /api/v1/sessions/{id}/runs/{run_id} — fetch a prior run + +FastAPI process (port 8001, pinned interpreter: .venv/bin/python -m src) + ├─ session manager (in-memory + SQLite for metadata) + ├─ CSV ingestion → DuckDB (in-process, local filesystem) + ├─ graph nodes: plan → query → execute → explain (LangGraph) + ├─ LLM provider: OpenRouter (Anthropic/Gemini/OpenRouter adapters already in src/llm/) + ├─ query_log (SQLite, append-only) + └─ Phase 2+: background cache-refresh worker for live MsSQL +``` - +## Components -## Component Map +### 1. Session Manager - +Tracks active sessions, their loaded files, schema cache, and conversation history. Ephemeral: restart clears in-memory state; persisted metadata + full `query_log` survive restarts. -``` -[Component A] - ↓ -[Component B] ←→ [External Service] - ↓ -[Component C] -``` +### 2. CSV Ingestion Service -## Layers +Exposes a `/csv` upload endpoint (multipart/form-data, multiple files). Produces a DuckDB in-memory database for the session, executing CREATE TABLE AS for each CSV, and a lightweight per-table schema summary (column names, types, NULL counts, row count, min/max for numeric, top-5 distinct for low-cardinality strings). The schema summary is cached (TTL infinity for the life of a session) and injected into every plan node context. - +### 3. Plan → Query → Cache → Explain (Agent Graph) -| Layer | Responsibility | -|-------|----------------| -| | | +LangGraph state machine, 4 nodes: -## Data Flow +| Node | Purpose | Output | +|------|---------|--------| +| `plan` | Inspect session schema cache + conversation history, write a structured plan (which tables/columns, what aggregation, what filter). Emit a natural-language plan string. | plan_text | +| `query` | Given the plan, generate one executable SQL target (DuckDB dialect) or Python+pandas fallback if DuckDB SQL is insufficient (e.g. custom string functions from the protocol). Emit **exactly one** query per run. | generated_code, code_language | +| `execute` | Execute against the session's DuckDB db (or MsSQL cache for Phase 2). Enforce SET TRANSACTION READ ONLY if on live DB. Capture row_count and latency_ms. | rows (JSON), row_count, latency_ms, cache_hit | +| `explain` | Summarize the rows + generate chart spec (chart_type, x, y, title, color_by) + 3–6 dashboard KPIs. Also emit: result_hash (SHA-256 of row payload), row_count, latency_ms. | nl_answer, chart_spec, kpis, result_hash, audit_block | - +Edges: `plan → query → execute → explain` on success; any failure routes to `handle_error`, which returns `error`, `error_message`, `generated_code` (partial if available), and routes the run to the audit log with `status=failed`. Retries use `clarify`-style single-question feedback to the LLM via `handle_clarify` — a short prompt explaining the failure type ("DB error: column not found — pick from: …"; "Ambiguous column — did you mean col_a or col_b?") and one retry maximum per step. -1. Trigger: -2. -3. -4. Output: +### 4. Query Execution Service -## External Dependencies +- **DuckDB (default / CSV)**: in-process, per-session DB file. No config needed. +- **MsSQL via pyodbc**: + - A read-only DB user (SELECT + SET TRANSACTION READ ONLY enforced at connection level) is required. + - A background refresh runs schema introspection once per session (or on demand), producing a DuckDB mirror cache. + - Query request is first attempted against the DuckDB cache. A cache miss (feature flag / first-time question) hits the live DB. Result rows are stored in the cache for future reuse. Cache freshness is bounded by a TTL (default: until session ends; configurable via env var). + - Cache staleness is surfaced in the answer panel ("Data is 14 minutes stale as of HH:MM"). +- **Both backends**: `query_exec.py` abstracts to `execute_on_duckdb()` / `execute_on_mssql()` with the same return shape. - +### 5. Frontend (zero-build static, single-origin) -| Dependency | Purpose | Failure Mode | -|------------|---------|--------------| -| | | | +Served at `/app/` by the backend. No build step. -## Stack +- Upload zone: multiple file picker + drag/drop. +- Session bar: shows loaded files + row counts + cache status. +- Question form: textarea + submit. +- Answer panel (progressive rendering): + - **Plan block** (collapsed by default): `plan_text` from the plan node. + - **Generated code block** (collapsible): `generated_code` with copy button; language label; source badge (`duckdb` | `mssql` | `mssql-cache`). + - **Dashboard KPIs**: 3–6 counters from `explain`. + - **Data table**: sortable, paginated; char limit per cell; "copy CSV" per-cell. + - **Chart area**: auto-suggested single chart (bar / line / pie) based on `chart_spec`; resize-aware; legend toggle. + - **Audit chain**: row_count, latency_ms, result_hash, timestamp. +- **Phase 2+ stubs** (clearly labelled in Phase 1): + - "MsSQL connection" tab (unlocked, shows "Coming up"). + - "Login" tab (unlocked, shows "Coming up"). + - "Download CSV" button (disabled, labelled stub). -> 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. +### 6. Observability & Audit -- **Language:** -- **Agent framework:** -- **LLM provider + model:** -- **Backend:** -- **Database + ORM:** -- **Frontend:** -- **Dependency management:** +Structured structured logging (structlog) on every node entry/exit, LLM call, DB call, and error. Append-only `query_log` records: -| Key library | Version | Purpose | -|-------------|---------|---------| -| | | | +- `id`, `session_id`, `user_id` (Phase 3+), `question` +- `generated_code`, `code_language`, `source` (duckdb | mssql | mssql-cache) +- `row_count`, `latency_ms`, `result_hash`, `status` (success | failed | clarified_error) +- `error_message` (partial if failed mid-step) +- `created_at`, `updated_at` -**Avoid:** +## Data Flow -## Deployment Model +``` +User: "top 10 PS by total FIRs in last month" + → upload CSV(s) into DuckDB + → session created, schema summary cached + +User: "top 10 PS by total FIRs in last month" + → plan node: tables=["firs"], columns=["ps_name","fir_date"], agg=COUNT, filter=date_range + → query node: SELECT ps_name, COUNT(*) AS total_firs FROM firs WHERE fir_date >= '2024-01-01' GROUP BY ps_name ORDER BY total_firs DESC LIMIT 10 + → execute node: DuckDB query → 10 rows, 0.02s + → explain node: "10 stations returned, highest is X with Y FIRs" + KPIs: total_firs=Y, date_range=2024-01-01 to …, ps_count=10, … + chart_spec: {type: bar, x: ps_name, y: total_firs, title: "Top 10 PS by FIRs"} + → answer rendered +``` + +For MsSQL in Phase 2, `execute` first checks the DuckDB cache; on miss, runs `SET TRANSACTION READ ONLY` then the same query against pyodbc → materialize to DuckDB → return rows as before. + +## Stack - +| Layer | Technology | Rationale | +|-------|-----------|-----------| +| Runtime | Python 3.11+ | Team standard; FastAPI + LangGraph | +| API framework | FastAPI + uvicorn | Baseline; already wired | +| Agent framework | LangGraph ≥ 0.2.28 | Plan → Query → Execute → Explain pattern | +| ORM / DB | SQLAlchemy 2.x + Alembic migrations | Baseline; MsSQL via `mssql+pyodbc://` | +| Analytical engine | DuckDB (via `duckdb` PyPI) | Local, zero-infra, fast on millions of rows, works as both in-memory cache and MsSQL mirror | +| Live DB driver | pyodbc + ODBC 18 driver | MsSQL production driver; must stay in `[project.dependencies]` per rules | +| LLM provider | OpenRouter (`https://openrouter.ai/api/v1`) | User preference; model env-configurable | +| Default LLM model | Env-overridable; baseline: user's OpenRouter choice | Rule: never hardcode without verification | +| Frontend | Zero-build static HTML/CSS/JS at `frontend/public/`, single-origin | Baseline; no Node required | +| Auth (Phase 3+) | JWT + httpOnly cookie | Standard internal-tool pattern | +| Process manager | `uvicorn` via FastAPI lifespan | Single-process, single-port (8001) | +| Migrations | Alembic | Already wired in repo | +| Observability | structlog + append-only `query_log` table | Baseline + query audit | + +> **Assumed:** pyodbc + MS ODBC 18 driver is installable on the target Linux server (confirmed by user as on-prem). If the internal network blocks outbound OpenRouter calls, a local LLM proxy (v1/openai-compatible) on the same network is an acceptable substitute; the provider adapter supports custom base URLs. diff --git a/spec/capabilities/csv-analyst.md b/spec/capabilities/csv-analyst.md new file mode 100644 index 0000000..ea9df4a --- /dev/null +++ b/spec/capabilities/csv-analyst.md @@ -0,0 +1,56 @@ +# Capability: CSV Analyst + +## What It Does + +Accepts multiple CSV file uploads into a single session, builds a per-session DuckDB analytical database backed by the uploaded files, and answers natural-language questions over the combined dataset with a multi-step plan→query→execute→explain graph. + +## Inputs + +| Input | Type | Source | Required | +|-------|------|--------|----------| +| `input_text` | str | User query via API/UI | ✓ | +| `csv_files` | multipart/form-data (list[UploadFile]) | POST /api/v1/sessions/{id}/csv | ✓ | +| `session_id` | UUID | Client-supplied or server-assigned | ✓ | +| `conversation_history` | list[{role,content}] | Session state (in-memory + persisted via API for reload) | — | + +## Outputs + +| Output | Type | Destination | +|--------|------|------------| +| `run_id` | UUID | API response | +| `status` | enum(pending, running, completed, failed, clarifying) | DB row | +| `output_text` | JSON blob: `{nl_answer, chart_spec, kpis, audit_block}` | DB row + API response | +| `plan_text` | str | API response (inner field of output_text) | +| `generated_code` | str | API response (inner field of output_text) | +| `rows` | list[dict] (truncated to MAX_ROWS_IN_RESPONSE) | API response (inner field) | +| `row_count` | int | API response (inner field) | +| `latency_ms` | float | API response (inner field) | +| `result_hash` | str (sha256) | query_log row + API response | +| `clarify_prompt` | str \| None | API response (when status=clarifying) | +| `error_message` | str \| None | DB row + API response (when failed) | + +## External Calls + +| System | Operation | On Failure | +|--------|-----------|------------| +| OpenRouter API | `POST /chat/completions` (one call per node: plan, query, explain) | Retry once with exponential backoff (configured in `src/llm/client.py`); surface as `clarify_prompt` if ambiguous, fall back to `error_message` | +| DuckDB (local) | `.execute()` for schema inspection + target query | Log + fail run; surface failed SQL to user | +| Filesystem (local) | Write session db file (`.duckdb_session_{session_id}`) | Fail session creation with 500 | + +## Business Rules + +- A session **must** have at least one CSV uploaded before a run is accepted — API returns 422 with a helpful message otherwise. +- A single run uses **exactly one** generated query (see `src/prompts/query.md` for the single-target rule). Multi-step "drill-down" is achieved by re-prompting with follow-up natural-language questions, not by an implicit multi-query chain in one run. +- Maximum uploaded CSV size: `MAX_CSV_BYTES` env var (default 100 MB). Maximum concurrent open sessions: `MAX_SESSIONS` env var (default 50); older idle sessions are evicted LRU. +- The conversation history is capped: last `MAX_HISTORY_TURNS` (default 20 turns = 40 messages) are sent to the LLM; older turns are summarised into a `prior_summary` field from the same context. Truncation is silent (no user-visible warning; raises only if the LLM call fails on over-length). +- `result_hash` is computed over the JSON-serialized result rows with sorted keys and deterministic null formatting; repeated runs on the same cached query for the same question produce the same `result_hash` (idempotency signal). +- Rate limiting per API key / session per `RATE_LIMIT_RUNS_PER_MINUTE` env var (default 20 runs/min/session). + +## Success Criteria + +- [ ] Upload 3+ CSV files and receive a coherent joint schema summary in under 5 seconds. +- [ ] Ask a question; receive plan, single generated query, row count, latency, a ranked sortable table, and an auto-suggested bar/line/pie chart all within 10 seconds on local data. +- [ ] Follow-up question context-aware within session; stale-session refresh path works (session re-loaded from disk continues history). +- [ ] DB/schema ambiguity surfaces a `clarify_prompt` rather than a silent failure. +- [ ] `query_log` row is present for every run; `generated_code`, `row_count`, `latency_ms`, `result_hash` are all populated on success. +- [ ] Integration tests pass with a real LLM key and a real pg/duckdb fixture; pytest gate asserts response content not merely status code. diff --git a/spec/capabilities/index.md b/spec/capabilities/index.md index 7455bda..f83ccaf 100644 --- a/spec/capabilities/index.md +++ b/spec/capabilities/index.md @@ -1,38 +1,3 @@ -# Capabilities Index +# Capabilities -> **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. - ---- - -## 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" - -## 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 - -## Capability File Template - -Each capability file should answer: -- **What it does** (one sentence) -- **Inputs** (what data it receives) -- **Outputs** (what it produces) -- **External calls** (APIs, LLMs, databases it touches) -- **Error cases** (what can go wrong and how it's handled) -- **Success criteria** (how we test it) +- `csv-analyst` — Multi-CSV natural-language analyst with visible query chain and chart/KPI outputs (Phase 1+2 scope defined in `csv-analyst.md`). diff --git a/spec/data.md b/spec/data.md index e331007..e6a7623 100644 --- a/spec/data.md +++ b/spec/data.md @@ -1,34 +1,134 @@ -# Data Model +# Data -> Fill in this section — see comments below. +## Entities ---- +### session -## Storage Technology +| Field | Type | Notes | +|-------|------|-------| +| `id` | UUID (PK) | Server-generated | +| `created_at` | datetime (UTC) | | +| `updated_at` | datetime (UTC) | | +| `status` | enum(active, archived, evicted) | evicted = LRU'd when over MAX_SESSIONS | +| `schema_summary` | JSON | DuckDB schema snapshot cached at upload time — tables, columns, types, row counts | +| `prior_summary` | str \| None | Summary of now-truncated conversation history for context continuity | +| `turn_count` | int | Incremented on each run completion | - +Relationships: 1 session → N runs, 1 session → N uploaded CSVs (tracked only in DuckDB file; not separately persisted). -## Entities +### run + +| Field | Type | Notes | +|-------|------|-------| +| `id` | UUID (PK) | | +| `session_id` | UUID (FK → session.id) | | +| `status` | enum(pending, running, completed, failed, clarifying) | | +| `input_text` | text (NOT NULL) | Copy of the user's question at submission time | +| `instruction` | text (NOT NULL) | Alias for input_text for compatibility with baseline schema | +| `output_text` | JSON \| None | Full `{nl_answer, chart_spec, kpis, audit_block, plan_text, generated_code, rows, row_count, latency_ms, result_hash, source}` | +| `provider` | str \| None | LLM provider slug | +| `model` | str \| None | LLM model name | +| `error_message` | str \| None | Populated on failure | +| `created_at` | datetime (UTC) | | +| `updated_at` | datetime (UTC) | | + +Relationships: N runs → 1 session. + +### query_log + +Phase 1 write-log; append-only — never updated or deleted by application code. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | UUID (PK) | | +| `session_id` | UUID | Best-effort; may be null on hard errors | +| `user_id` | str \| None | Phase 3+; null until auth | +| `question` | text | Exact user question | +| `generated_code` | text \| None | Full SQL or Python | +| `code_language` | enum(sql, python) \| None | | +| `source` | enum(duckdb, mssql, mssql-cache) \| None | | +| `row_count` | int \| None | | +| `latency_ms` | float \| None | | +| `result_hash` | str \| None | SHA-256 of deterministic row payload | +| `status` | enum(success, failed, clarified_error) | | +| `error_message` | str \| None | | +| `created_at` | datetime (UTC) | | + +Phase 2 adds: + +### db_connection + +| Field | Type | Notes | +|-------|------|-------| +| `id` | UUID (PK) | | +| `session_id` | UUID | | +| `connection_label` | str | Human-readable name for the connection | +| `connection_string` | text (encrypted at rest) | Phase 3: encrypted before write | +| `schema_version_hash` | str \| None | SHA-256 of last introspected schema; triggers re-fresh | +| `last_refreshed_at` | datetime (UTC) \| None | | +| `created_at` | datetime (UTC) | | +| `updated_at` | datetime (UTC) | | + +Phase 2 adds: + +### cache_snapshot + +| Field | Type | Notes | +|-------|------|-------| +| `id` | UUID (PK) | | +| `session_id` | UUID | | +| `source_table` | str | MsSQL table reflected into DuckDB | +| `duckdb_file` | str | Path of the materialized DuckDB file | +| `row_count` | int | | +| `refresh_strategy` | enum(manual, scheduled, on_first_miss) | | +| `last_refreshed_at` | datetime (UTC) | | +| `expires_at` | datetime (UTC) \| None | TTL from env var; null = live | - +Relationships: 1 session → N cache_snapshots, each pointed at one MsSQL source table. -### Entity: +## Lifecycle - +``` +Upload CSV: + POST /csv → create_session (or add to existing) + → ingest CSV into DuckDB session file + → cache schema_summary (in-memory + stored in session) + → session.status = active -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| id | | yes | Primary key | -| | | | | +Ask question: + POST /runs + → state: pending → running + → plan → query → execute → explain + → state: completed (or failed / clarifying) -### Relationships +Clarify round: + state: clarifying + ← user reply re-submitted with original question prepended + → re-enter plan node with full context + → (no new run_id — same run, same run_id) - +DB connection (Phase 2): + POST /db/connect + → validate connection (SET TRANSACTION READ ONLY) + → introspect live MsSQL schema + → write schema_summary + db_connection row + → flag session as `has_live_db = True` -## Data Lifecycle +Cache refresh (Phase 2): + cache miss on mssql table: + → READ ONLY query on live DB + → write result set to DuckDB cache file + → update cache_snapshot row (last_refreshed_at, row_count) + → return rows - +Session eviction (LRU): + touch_count evicted on over MAX_SESSIONS + → qa-auditor: older sessions may have pending jobs; cancel before evict +``` -## Sensitive Data +## Security & Privacy Notes - +- No data leaves the on-prem network except the LLM API call to OpenRouter (text-only; no file/DB data sent to the LLM — only the schema summary and the generated SQL). +- Row contents stay entirely in DuckDB memory. +- The `query_log` table is append-only — no UPDATE/DELETE paths exist in the application. +- `db_connection.connection_string` is unencrypted in Phase 1 Phase 2 adds encryption-at-rest for this column. diff --git a/spec/roadmap.md b/spec/roadmap.md index 03ae242..6a4cdd3 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -1,62 +1,84 @@ # Roadmap -> Fill in each section. Run `/zero-shot-build [your idea]` to have it filled automatically. +A natural-language data analyst for the UP Police. Analysts upload CSV files and ask questions in plain English; the agent inspects schemas, generates safe queries, executes them, and returns a natural-language answer plus a table, bar/line/pie charts, and dashboard KPIs. In Phase 2+ it extends to a large MsSQL database with low-latency, low-load access via a local DuckDB cache and materialized views. --- ## What This Agent Does - +Lets analysts and senior officers inside the UP Police ask natural-language questions over police operational data — initially uploaded as CSV files, later sourced directly from a large MsSQL database (CCTNS-style). The agent converts questions into executable queries (SQL or Python+pandas), runs them, and presents the answer as a short natural-language explanation, a data table, auto-suggested bar/line/pie charts, and dashboard-style aggregate counters (KPIs) at the top of the page. All data processing is local; the live production database is never queried more than necessary thanks to a DuckDB cache layer and session-scoped materialized views. Full execution transparency is surfaced: the inspected schema, the generated query, row counts, latency, and a result hash are shown in the answer panel and logged to the query_log table. ## Who Uses It - +- **Analysts/officers** — ad-hoc exploration, 10–50 queries/day, uploading fresh CSVs or re-querying the live DB. +- **Senior officers** — on-demand summaries and dashboard views without running SQL. ## Core Problem Being Solved - +Police analysts currently export data to CSV, open it in spreadsheet tools, and manually build pivot tables or write ad-hoc SQL themselves. This is slow, error-prone, and scales poorly with large datasets (millions of rows). A single wrong join or filter can silently produce wrong numbers with no audit trail. This agent removes that friction — and adds the missing governance: every generated query is inspectable, auditable, and low-impact on production systems. ## Success Criteria - - -- [ ] -- [ ] -- [ ] +- [ ] Upload 3+ CSV files simultaneously and get correct schema detection and a coherent joint schema in under 5 seconds. +- [ ] Ask "show me top-10 police stations by pendency" and receive a ranked list with a bar chart in under 10 seconds on a local CSV session. +- [ ] Restart the server; prior session data is not required to be preserved (ephemeral sessions — logged to query_log for audit). +- [ ] Connect to a MsSQL database with read-only credentials; run a multi-table aggregate in under 3 seconds with zero live-DB load on subsequent ask. +- [ ] The generated query is visible alongside the answer, and the query_log row contains the full generated code + result hash. +- [ ] CSRF/XSS/SQL-injection surface is audited in the produced code and explicitly covered in the test gate (no blind trust). ## What This Agent Does NOT Do (Out of Scope) - +- Writes/updates/deletes data in the production database (read-only agent — defer write scope to a future capability). +- Multi-turn conversational memory across server restarts (Phase 1 sessions are scoped to a single running instance; history does not persist across restarts until optional Redis/cookie store is added in a later phase). +- User authentication, role-based access control, or SSO (defer to a later Phase 4+; Phase 1 runs auth-unlocked on the internal network). +- Real-time streaming of streaming result rows (progressive page updates simulate streaming; true SSE is deferred). +- Direct export to Excel (no download button in Phase 1 — defer to Phase 2+). +- Natural-language interaction over unstructured/non-tabular data. ## Key Constraints - +- **On-premises data**: The production database must never leave the police network. Only the LLM API call traverses the public internet (OpenRouter); all data stays local. +- **Minimal live-DB load**: Queries to the live MsSQL database are executed at most once per distinct question across the lifetime of a session; subsequent asks are served from DuckDB cache. The agent issues SET TRANSACTION READ ONLY; the DB user has no DML privileges. +- **Low latency**: Target < 3 seconds for an end-to-end answer on a cached multi-table query; < 10 seconds on a cold MsSQL query. +- **Cost**: Prefer cheaper OpenRouter models for routine queries; allow per-session override via env vars. Token usage is surfaced as a running total in the dashboard. +- **Audit trail**: Every question -> generated code -> execution -> result must be append-only logged to `query_log`. The log must include the full generated code, row count, latency_ms, and a hash of the result payload. +- **Python**: 3.11+. SQLAlchemy 2.x dialect for MsSQL is `mssql+pyodbc://`. Test driver rules: if production is MsSQL via pyodbc, integration tests must use the same driver (not SQLite). ## 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 1 is the smallest user-testable quick win. The full primary journey must work end-to-end and be real on the tested path. The UI is visually complete: the working path is live, and every future surface is a clearly-labelled NON-FUNCTIONAL stub. -### Phase 2 — +### Phase 1 — Multi-CSV NL analyst with visible query chain -- **Goal:** +- **Goal:** Upload multiple CSV files, ask natural-language questions, get NL answer + table + charts + dashboard KPIs; see schema, generated code, row count, and latency in the answer panel. Session holds loaded data + full conversation history for follow-up questions. - **Independent slices (parallel build units):** - - `slice-a` (backend) — - - `slice-b` (frontend) — -- **Key surfaces / files:** -- **Gate command:** -- **How the user tests it (handoff seed):** - - - + - `slice-a` (backend) — CSV ingestion + DuckDB cache schema + the `plan → query → cache → explain` agent graph + query_log persistence. Deps: none. + - `slice-b` (backend) — REST API endpoints for session management (create/list/delete), CSV upload (multipart/form-data), and the run endpoint. Deps: slice-a. + - `slice-c` (frontend) — Multi-file upload page + question form + answer panel (plan, code, table, charts, KPIs, chain). Deps: slice-b (contract only). +- **Key surfaces / files:** + - Backend: `src/graph/nodes.py` (plan / query / explain / error-handler), `src/prompts/`, `src/api/sessions.py`, `src/api/runs.py`, `src/api/csv.py`, `src/db/models.py` (csv_upload, query_log), `src/services/csv_ingest.py`, `src/services/query_exec.py`, `src/observability/events.py`. + - Frontend: `frontend/public/index.html`, `frontend/public/styles.css`, `frontend/public/app.js`. +- **Gate command:** `uv run pytest tests/integration -q` (real LLM key required; tests run against SQLite dev DB with a pipeline fixture that hits the real `/runs` and `/csv` endpoints). +- **How the user tests it (handoff seed):** `uv run python -m src`, open `http://localhost:8001/app/`. Upload 2–3 sample CSVs (FIRs + chargesheets). Ask "top 10 police stations by total FIRs". Expect: a ranked table + bar chart + dashboard KPIs (total FIRs, date range, distinct PS) + a visible "Generated SQL" section + row count + `X.XXs` latency. Ask a follow-up ("what about last month only?") — expect prior context understood. The MsSQL tab and the login tab should be clearly labelled stubs. + +### Phase 2 — MsSQL live-database access with low-DB-load cache + +- **Goal:** Connect to a live MsSQL database with read-only credentials; generate read-only queries against a materialized DuckDB cache so the live DB is hit at most once per new question type. Phase 1's multi-CSV path remains fully functional. +- **Independent slices (parallel build units):** + - `slice-a` (backend) — MSSQL + DuckDB cache layer: schema introspection of the live DB, incremental cache-refresh, SET TRANSACTION READ ONLY enforcement, pyodbc dialect wiring. Deps: Phase 1 complete. + - `slice-b` (backend) — Endpoint + graph extension: DB connection form, cache-commit trigger, graph extension for MsSQL plan node, result-hash + load-registered in query_log. Deps: slice-a. + - `slice-c` (frontend) — MsSQL tab is wired to real: connection screen, cache status, schema browser, result panel as in Phase 1. Deps: slice-b (contract only). +- **Key surfaces / files:** + - Backend: `src/db/models.py` (db_connection row, cache_snapshot), `src/services/mssql_cache.py`, `src/services/lockdown.py`, `src/graph/nodes.py` (db plan node), `src/api/db.py`. + - Frontend: `frontend/public/app.js` (MsSQL tab), `frontend/public/index.html` (Db tab). +- **Gate command:** `uv run pytest tests/integration -q AND uv run python -m src --migrate` (pyodbc driver required; a Docker-based MSSQL test fixture or a local connection string). Integration tests assert READ ONLY enforcement and cache hit on a second identical query. +- **How the user tests it (handoff seed):** Supply MsSQL connection string in `MSSQL_CONNECTION_STRING`. Open `http://localhost:8001/app/`, switch to MsSQL tab, click "Connect". Ask a complex join query; verify cache status changes from "cold → warm" and the live DB touch count in query_log increments by exactly 1 on first ask and stays flat on the repeat. + +### Phase 3 — Auth, audit, and resilience hardening + +- **Goal:** JWT or API-key auth, rate-limiting, request-size limits (upload), and resilience (circuit-breaker on LLM, lazy DB reconnect, structured error responses). +- **Slices (serial):** + - `slice-a` (backend) — auth dependency, JWT issuance, per-user query_log scoping, rate-limiter. + - `slice-b` (frontend) — login screen, session token storage (httpOnly cookie), per-user history view. +- **Gate command:** `uv run pytest tests/ -q` +- **How the user tests it:** login required; unauth'd calls return 401; rate limiter triggers after threshold; all prior Phase 2 flows still pass. diff --git a/spec/ui.md b/spec/ui.md index 15219c3..03c9c25 100644 --- a/spec/ui.md +++ b/spec/ui.md @@ -1,32 +1,72 @@ # UI -> **Boilerplate status:** Delete this file if the agent has no UI. Otherwise, filled in by the spec-writer sub-agent. +## Design Principles + +- Zero-build, single-origin — no npm, no bundler. Served by the backend at `/app` on **port 8001**. +- Enterprise-grade visual polish by default — clear typography, consistent spacing, no hackathon framing. Clean table layouts, well-labelled stubs for future surfaces. +- Progressive rendering: the answer panel fills in as the agent progresses (plan block → generated code block → KPIs → table → chart). --- -## UI Type +## Screens + +### Upload / Home (`/app/`) +- Header: "UP Police Data Analyst" with the UPP logo area (text badge in a bounded colour block). +- Two-column layout: + - **Left sidebar**: session picker (new / open / close), loaded files list with row counts, cache status badge, clear all. + - **Main panel**: drag-and-drop upload zone (dashed border, accepts `.csv`; max size warning). +- Stubs: + - "Connect to Live Database" button → opens a labelled stub: "MsSQL tab — coming in Phase 2". + - "Account" / "Login" tab → labelled stub: "Auth — coming in Phase 3". +- Backdrop: clean off-white + UPP-inspired navy accent; no gradients. + +### Ask Question (`/app/` — same page) +- Question textarea (placeholder: "Ask a question about your data — e.g. top 10 PS by total FIRs last month"). +- Submit button; disabled if no session is active. +- Streaming lifecycle indicator: text-only three-state label ("Planning…", "Running query…", "Explaining result…") replacing a spinner. + +### Answer Panel (slots, rendered in order) + +| Slot | Content | Notes | +|------|---------|-------| +| **Dashboard KPIs** | 3–6 counters (total rows, date range, distinct X, trend ↑/↓) | Flat row of glass-morphism cards | +| **Plan block** | Collapsible. Heading: "Plan". Body: plan_text | Collapsed by default; expand button | +| **Generated code block** | Collapsible. `generated_code` with language label + copy button + source badge (`DuckDB` / `MsSQL` / `MsSQL Cache`) | Collapsed by default | +| **Data table** | Sortable, paginated (50 rows/page), char-truncated cells with hover tooltip showing full value | Debounced column sorting | +| **Chart area** | Auto-suggested single chart (bar / line / pie) with title + legend | Responsive; no Plotly; SVG rendered client-side or from server-provided spec | +| **Audit chain footer** | `row_count`, `latency_ms`, `result_hash` (shortened), `created_at` | Sans-serif, small; matte | - +### Session History (sidebar) +- List of prior questions in this session; clicking loads the prior answer (from the `runs` table). +- Disabled / labelled stub in Phase 1: "Persistent history — coming in Phase 3". -## Views / Screens +### Error State +- If the run fails or clarifies: prominent red-bordered card with the `error_message` (and `clarify_prompt` when applicable). Retry button re-issues the same question. Generated code (partial or full) is preserved in the code block so the user can debug. - +--- + +## Interactions -### Screen: +- Keyboard shortcut: `Cmd/Ctrl + Enter` in the question textarea submits. +- After each completed run, the question textarea keeps the last question pre-filled (one backspace to edit). +- CSV drop highlights the sidebar's "loaded files" list; progress shown during upload (single-file numerator; aggregation shown once) because DuckDB writes are fast. -**Purpose:** +--- -**Key elements:** -- -- +## Responsive -**Actions available:** -- +- Below 768 px: sidebar collapses into a top slide-out drawer. +- Table stacks to a card list on mobile; chart area goes full-width. +- The app is internal-network-only; no mobile optimisation beyond the reflow above unless requested. + +--- -## Error States +## What is Stubbed in Phase 1 - +Each stub carries the label **"Coming in Phase N"** — never "Coming soon" or "TBD". Stubs at `/app/`: -## Tech Stack +- **"Connect to Live Database" tab** → Phase 2. Creates a disabled form with the label "MsSQL connection — Phase 2". +- **"Account / Settings" tab** → Phase 3. Login button, disabled, labelled "Auth — Phase 3". +- **"Download CSV / Excel" button** → Phase 2+ (or beyond). Disabled, greyed out, labelled "Export — coming soon". - +A stub must never look like a bug. diff --git a/src/api/__init__.py b/src/api/__init__.py index 4ef00c3..785e550 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -24,10 +24,11 @@ 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_api, health, runs app.include_router(health.router) app.include_router(runs.router) + app.include_router(csv_api.router) if _FRONTEND_DIR.is_dir(): app.mount("/app", StaticFiles(directory=_FRONTEND_DIR, html=True), name="frontend") diff --git a/src/api/csv_api.py b/src/api/csv_api.py new file mode 100644 index 0000000..f78989b --- /dev/null +++ b/src/api/csv_api.py @@ -0,0 +1,66 @@ +"""CSV upload and run endpoints.""" +from __future__ import annotations + +from typing import List + +from fastapi import APIRouter, File, HTTPException, UploadFile +from sqlalchemy.orm import Session + +from src.api._common import api_error, ok +from src.capabilities.tasks import run_csv_agent +from src.db.models import SessionRow +from src.db.session import get_session +from src.services.sessions import create_session, get_session_row, ingest_csvs, schema_summary + +router = APIRouter() + + +@router.post("/sessions") +def create_session_endpoint() -> dict: + # Create a session using the service function which returns a SessionRow. + data = create_session() + # Get the schema summary (should be empty for a new session) + meta = schema_summary(data.id) + return ok({ + "id": data.id, + "status": data.status, + "created_at": data.created_at, + "schema_summary": meta, + }) + + +@router.get("/sessions/{session_id}") +def get_session_endpoint(session_id: str) -> dict: + row = get_session_row(session_id) + if row is None: + raise api_error("not_found", "Session not found", 404) + meta = schema_summary(session_id) + return ok({ + "id": row.id, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + "schema_summary": meta, + }) + + +@router.post("/sessions/{session_id}/csv") +async def upload_csv(session_id: str, files: List[UploadFile] = File(...)) -> dict: + row = get_session_row(session_id) + if row is None: + raise api_error("not_found", "Session not found", 404) + uploads: list[tuple[str, bytes]] = [] + errors: list[str] = [] + for upload in files: + try: + uploads.append((upload.filename, await upload.read())) + except Exception as exc: + errors.append(f"{upload.filename}: {exc}") + summary, ingest_errors = ingest_csvs(session_id, uploads) + errors.extend(ingest_errors) + return ok({ + "session_id": session_id, + "tables": summary["tables"], + "errors": errors, + "count": len(summary["tables"]), + }) \ No newline at end of file diff --git a/src/api/runs.py b/src/api/runs.py index c8f2895..da84574 100644 --- a/src/api/runs.py +++ b/src/api/runs.py @@ -8,7 +8,9 @@ from src.db.models import RunRow from src.db.session import get_session from src.domain import RunRequest, RunResult -from src.graph.runner import run_agent +from src.capabilities.tasks import run_csv_agent +from src.config.settings import get_settings +from src.llm.providers.base import LLMError router = APIRouter() @@ -26,13 +28,37 @@ def _to_result(run: RunRow) -> RunResult: @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) + # Create a run record + run = RunRow( + input_text=req.question, + instruction="", + status="running", + ) + session.add(run) + session.flush() + run_id = run.id + + # Run the CSV agent + try: + result = run_csv_agent(req.session_id, req.question) + except LLMError as exc: + result = {"status": "failed", "output_text": None, "error": str(exc)} + + # Update the run record with the result + run.output_text = result.get("output_text") + run.status = result.get("status", "completed") 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.error_message = result.get("error") + # Get provider and model from settings + s = get_settings() + run.provider = s.resolve_provider() + run.model = s.resolve_model() + + session.add(run) + session.commit() + session.refresh(run) + + return ok(_to_result(run)) @router.get("/runs/{run_id}") @@ -40,4 +66,4 @@ 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()) + return ok(_to_result(run)) \ No newline at end of file diff --git a/src/capabilities/agent.py b/src/capabilities/agent.py new file mode 100644 index 0000000..de87878 --- /dev/null +++ b/src/capabilities/agent.py @@ -0,0 +1,43 @@ +"""CSV Analyst graph assembly.""" +from __future__ import annotations + +from langgraph.graph import END, StateGraph + +from src.capabilities import csv_state +from src.capabilities.csv_nodes import csv_error, csv_explain, csv_execute, csv_finalize, csv_plan, csv_query + + +def build_agent(): + graph = StateGraph(csv_state.CsvAgentState) + + graph.add_node("csv_plan", csv_plan) + graph.add_node("csv_query", csv_query) + graph.add_node("csv_execute", csv_execute) + graph.add_node("csv_explain", csv_explain) + graph.add_node("csv_finalize", csv_finalize) + graph.add_node("csv_error", csv_error) + + graph.set_entry_point("csv_plan") + graph.add_edge("csv_plan", "csv_query") + graph.add_edge("csv_execute", "csv_explain") + graph.add_edge("csv_explain", "csv_finalize") + graph.add_edge("csv_finalize", END) + graph.add_edge("csv_error", END) + + def route_after_query(state): + if state.get("error"): + return "csv_error" + return "csv_execute" + + graph.add_conditional_edges( + "csv_query", + route_after_query, + { + "csv_error": "csv_error", + "csv_execute": "csv_execute", + } + ) + return graph.compile() + + +agentic_ai = build_agent() \ No newline at end of file diff --git a/src/capabilities/csv_nodes.py b/src/capabilities/csv_nodes.py new file mode 100644 index 0000000..f865ad5 --- /dev/null +++ b/src/capabilities/csv_nodes.py @@ -0,0 +1,89 @@ +"""CSV Analyst graph nodes.""" +from __future__ import annotations + +import json +from typing import Any + +from src.llm.client import LLMClient, load_prompt +from src.llm.providers.base import LLMError +from src.services.csv_exec import execute_sql + + +def csv_plan(state): + try: + client = LLMClient() + system = load_prompt("csv_plan") + user = json.dumps({ + "question": state.get("input_text"), + "schema": state.get("schema_summary"), + }) + out = client.complete(system, user, max_tokens=4096) + return { + "plan_text": out, + "error": None + } + except LLMError as exc: + return {"error": str(exc)} + + +def csv_query(state): + try: + client = LLMClient() + system = load_prompt("csv_query") + user = json.dumps({ + "question": state.get("input_text"), + "plan": state.get("plan_text"), + "schema": state.get("schema_summary"), + }) + out = client.complete(system, user, max_tokens=4096) + return { + "generated_code": out, + "code_language": "sql", + "error": None + } + except LLMError as exc: + return {"error": str(exc)} + + +def csv_execute(state): + try: + if not state.get("generated_code"): + return {"error": "No SQL query to execute."} + session_id = state.get("session_id") + if not session_id: + return {"error": "Session ID not found."} + rows, latency_ms, result_hash = execute_sql(str(session_id), state.get("generated_code")) + return { + "rows": rows, + "row_count": len(rows), + "latency_ms": latency_ms, + "result_hash": result_hash, + "error": None + } + except Exception as exc: + return {"error": str(exc)} + + +def csv_explain(state): + try: + client = LLMClient() + system = load_prompt("csv_explain") + user = json.dumps({ + "question": state.get("input_text"), + "plan": state.get("plan_text"), + "schema": state.get("schema_summary"), + "query": state.get("generated_code"), + "rows": (state.get("rows") or [])[:200], + }) + out = client.complete(system, user, max_tokens=2048) + return {"output_text": out, "error": None} + except LLMError as exc: + return {"error": str(exc)} + + +def csv_finalize(state): + return {"status": "completed"} + + +def csv_error(state): + return {"status": "failed", "output_text": None, "error": state.get("error")} \ No newline at end of file diff --git a/src/capabilities/csv_state.py b/src/capabilities/csv_state.py new file mode 100644 index 0000000..1daf0ea --- /dev/null +++ b/src/capabilities/csv_state.py @@ -0,0 +1,21 @@ +"""CSV Analyst state.""" +from __future__ import annotations + +from typing import Any, TypedDict + + +class CsvAgentState(TypedDict, total=False): + session_id: str | None + input_text: str + schema_summary: dict | None + conversation_history: list[dict[str, str]] + plan_text: str | None + generated_code: str | None + code_language: str | None + rows: list[dict[str, Any]] | None + row_count: int | None + latency_ms: float | None + result_hash: str | None + output_text: str | None + error: str | None + status: str diff --git a/src/capabilities/tasks.py b/src/capabilities/tasks.py new file mode 100644 index 0000000..2734b6b --- /dev/null +++ b/src/capabilities/tasks.py @@ -0,0 +1,21 @@ +"""CSV Analyst tasks.""" +from __future__ import annotations + +from src.capabilities.agent import agentic_ai +from src.capabilities.csv_state import CsvAgentState +from src.services.sessions import get_session_row + + +def run_csv_agent(session_id, input_text): + # Fetch the session to get the schema_summary + session = get_session_row(session_id) + if session is None: + raise ValueError(f"Session {session_id} not found") + state: CsvAgentState = { + "session_id": session_id, + "input_text": input_text, + "schema_summary": session.schema_summary, + "conversation_history": [], + } + result = agentic_ai.invoke(state) + return result \ No newline at end of file diff --git a/src/config/settings.py b/src/config/settings.py index f3b7e90..a00bac7 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -1,15 +1,11 @@ -"""Application settings — Pydantic BaseSettings, env prefix ``AGENT_``. - -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. -""" +"""Application settings — Pydantic BaseSettings, env prefix ``AGENT_``.""" from __future__ import annotations +from pathlib import Path + from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict -# Provider defaults used when AGENT_LLM_MODEL is blank. Verify against current -# provider docs before pinning — a 404 from the LLM API usually means a stale name. DEFAULT_MODELS = { "anthropic": "claude-sonnet-4-6", "gemini": "gemini-2.5-flash", @@ -25,22 +21,37 @@ class Settings(BaseSettings): extra="ignore", ) + # Paths + data_dir: Path = Field(default=Path("./data")) + + # Database database_url: str = Field(default="sqlite:///./data/app.db") + duckdb_dir: Path = Field(default=Path("./data/sessions")) - # "auto" resolves to whichever provider key is set. + # LLM 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") + # CSV / session limits + max_csv_bytes: int = Field(default=100 * 1024 * 1024) # 100 MB + max_sessions: int = Field(default=50) + max_history_turns: int = Field(default=20) + + # Rate limiting + rate_limit_runs_per_minute: int = Field(default=20) + + # Phase 2 + mssql_connection_string: str = Field(default="") + cache_ttl_seconds: int = Field(default=3600) + 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 @@ -72,4 +83,6 @@ def get_settings() -> Settings: global _settings if _settings is None: _settings = Settings() + _settings.data_dir.mkdir(parents=True, exist_ok=True) + _settings.duckdb_dir.mkdir(parents=True, exist_ok=True) return _settings diff --git a/src/db/models.py b/src/db/models.py index d4404f8..b9495dc 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -21,7 +21,7 @@ class Base(DeclarativeBase): class RunRow(Base): - """One agent run: input → output, with status + error for observability.""" + """One agent run: input -> output, with status + error for observability.""" __tablename__ = "runs" @@ -39,3 +39,45 @@ class RunRow(Base): updated_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, default=_now, onupdate=_now ) + + +class SessionRow(Base): + """Conversation session.""" + + __tablename__ = "sessions" + + id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) + status: Mapped[str] = mapped_column(Text, nullable=False, default="active") + schema_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + conversation_json: Mapped[str | None] = mapped_column(Text, nullable=True) + prior_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + turn_count: Mapped[int] = mapped_column(Text, nullable=False, default=0) + # stored as text to match existing migration shape; cast in queries + 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 QueryLogRow(Base): + """Append-only query log.""" + + __tablename__ = "query_log" + + id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) + session_id: Mapped[str | None] = mapped_column(Text, nullable=True) + user_id: Mapped[str | None] = mapped_column(Text, nullable=True) + question: Mapped[str] = mapped_column(Text, nullable=False, default="") + generated_code: Mapped[str | None] = mapped_column(Text, nullable=True) + code_language: Mapped[str | None] = mapped_column(Text, nullable=True) + source: Mapped[str | None] = mapped_column(Text, nullable=True) + row_count: Mapped[str | None] = mapped_column(Text, nullable=True) + latency_ms: Mapped[str | None] = mapped_column(Text, nullable=True) + result_hash: Mapped[str | None] = mapped_column(Text, nullable=True) + status: 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 + ) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 3b92f9d..a9cafa6 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -1,4 +1,17 @@ """Domain models (Pydantic request/response shapes).""" from src.domain.run import RunRequest, RunResult +from src.domain.session import ( + SessionCreateResponse, + SessionResponse, + CsvUploadResponse, + ClarifyResponse, +) -__all__ = ["RunRequest", "RunResult"] +__all__ = [ + "RunRequest", + "RunResult", + "SessionCreateResponse", + "SessionResponse", + "CsvUploadResponse", + "ClarifyResponse", +] diff --git a/src/domain/run.py b/src/domain/run.py index a5f01d0..07cfe7f 100644 --- a/src/domain/run.py +++ b/src/domain/run.py @@ -5,12 +5,8 @@ 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, - ) + session_id: str = Field(..., min_length=1) + question: str = Field(..., min_length=1, max_length=100_000) class RunResult(BaseModel): @@ -19,4 +15,4 @@ class RunResult(BaseModel): output_text: str | None = None provider: str | None = None model: str | None = None - error_message: str | None = None + error_message: str | None = None \ No newline at end of file diff --git a/src/domain/session.py b/src/domain/session.py new file mode 100644 index 0000000..70337df --- /dev/null +++ b/src/domain/session.py @@ -0,0 +1,39 @@ +"""Session domain models.""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class SessionCreateResponse(BaseModel): + id: str + status: str = "active" + created_at: str + schema_summary: dict | None = None + turn_count: int = 0 + + +class SessionResponse(BaseModel): + id: str + status: str + created_at: str + updated_at: str + schema_summary: dict | None = None + turn_count: int = 0 + + +class CsvUploadResponse(BaseModel): + session_id: str + uploaded: list[str] = Field(default_factory=list) + schema_summary: dict | None = None + errors: list[str] = Field(default_factory=list) + + +class SessionRunRequest(BaseModel): + input_text: str = Field(..., min_length=1, max_length=4000) + + +class ClarifyResponse(BaseModel): + run_id: str + status: str = "clarifying" + clarify_prompt: str | None = None + output_text: str | None = None diff --git a/src/graph/runner.py b/src/graph/runner.py index 79aa639..097c4a6 100644 --- a/src/graph/runner.py +++ b/src/graph/runner.py @@ -1,8 +1,4 @@ -"""run_agent() — the entry point the API calls. - -Creates the run row, invokes the graph, persists the outcome. Errors land in -the row (status=failed + message), never as a crash. -""" +"""run_agent() — the entry point the API calls.""" from __future__ import annotations from src.db.models import RunRow @@ -26,6 +22,7 @@ def run_agent(input_text: str, instruction: str) -> str: "input_text": input_text, "instruction": instruction, "error": None, + "status": "running", } with log_span(log, "agent_run", run_id=run_id) as span: final: AgentState = agentic_ai.invoke(initial) diff --git a/src/graph/state.py b/src/graph/state.py index 6720d27..d27f62a 100644 --- a/src/graph/state.py +++ b/src/graph/state.py @@ -1,15 +1,31 @@ -"""AgentState — the TypedDict flowing through the graph.""" +"""Agent state — shared dict keys for the baseline graph.""" from __future__ import annotations -from typing import TypedDict +from typing import Any, Dict, TypedDict + +# Use a TypedDict so LangGraph can type-check node returns. class AgentState(TypedDict, total=False): run_id: str + session_id: str | None input_text: str instruction: str - output_text: str - provider: str - model: str - status: str + output_text: str | None + provider: str | None + model: str | None + plan_text: str | None + generated_code: str | None + code_language: str | None + rows: list[Any] + row_count: int | None + latency_ms: float | None + nl_answer: str | None + chart_spec: dict | None + kpis: list[Any] | None + result_hash: str | None + source: str | None + cache_hit: bool | None + clarify_prompt: str | None error: str | None + status: str diff --git a/src/prompts/__init__.py b/src/prompts/__init__.py new file mode 100644 index 0000000..9d32000 --- /dev/null +++ b/src/prompts/__init__.py @@ -0,0 +1,71 @@ +"""Prompts for the CSV-analyst graph nodes.""" +from __future__ import annotations + +PLAN = """You are an analytical data planner for a police data analyst. +Destination: user-friendly analytics on questions about police operational data. + +You will receive: +- A natural-language QUESTION from a user. +- A conversation history (last few turns). +- A KNOWN SCHEMA summary listing tables and columns that are loaded in the current DuckDB session. + +Return a structured plan in plain text (bullet form is fine). Include: + - which tables to reference + - which columns matter + - filters / date ranges + - aggregations / group-by + - sort order and limit + +Do NOT execute any query. Do NOT include generated code. Keep the plan short; 5-12 bullets. +""" + +QUERY = """You are a SQL engineer for a police data analyst. + +You will receive: +- A plan in plain text. +- The QUESTION. +- A schemas summary (table names + column names + types). + +Generate ONE executable DuckDB SQL query that answers the QUESTION. +Rules: + - Output ONLY the SQL, no prose, no markdown fences. + - Use the exact table and column names from the schema summary. + - Prefer DuckDB syntax (e.g. date_trunc, EXTRACT, :: date casts). + - Never mutate data: no INSERT/UPDATE/ DELETE/ ALTER/ DROP/ CREATE/ TRUNCATE. + - Use LIMIT when ranking for safety (max 1000 rows). + - For date filtering use explicit date functions compatible with DuckDB. +""" + +EXPLAIN = """You are an analytics explainer for a police data analyst. +You will receive: +- The original QUESTION. +- The PLAN. +- The GENERATED CODE that ran. +- The resulting rows (preview up to 200 rows). +- Latency and result hash. + +Write a concise natural-language answer that: + - states what the query returned in business terms, + - surfaces the most important rows or aggregates, + - proposes a chart: bar / line / pie if the result is tabular and rich enough, + - proposes 3-6 dashboard KPIs with labels and values in plain prose. + +Always include these exact audit facts verbatim: + - row_count: + - latency_ms: + +Do not emojis. Do not include the raw code in the answer text; the UI already shows it separately. +""" + +PROMPTS = { + "plan": PLAN, + "query": QUERY, + "explain": EXPLAIN, +} + + +def load_prompt(name: str) -> str: + try: + return PROMPTS[name] + except KeyError: + raise KeyError(f"Unknown prompt key: {name}") diff --git a/src/prompts/csv_explain.md b/src/prompts/csv_explain.md new file mode 100644 index 0000000..7692b0f --- /dev/null +++ b/src/prompts/csv_explain.md @@ -0,0 +1 @@ +Summarise the returned rows and provide a clear, concise answer in plain English that directly addresses the original question. Include a short analysis with row count and latency. Propose a chart (bar, line, or pie) if the result supports it. Do not include the raw SQL. Do not include the raw returned rows. diff --git a/src/prompts/csv_plan.md b/src/prompts/csv_plan.md new file mode 100644 index 0000000..9bf12b3 --- /dev/null +++ b/src/prompts/csv_plan.md @@ -0,0 +1 @@ +You are a data-planning assistant for a police analyst agent. Given a natural-language question and the union of schemas from several uploaded CSV files, produce a very brief structured plan. Output only bullet points. Do not include generated code. Do not execute anything. diff --git a/src/prompts/csv_query.md b/src/prompts/csv_query.md new file mode 100644 index 0000000..009e04a --- /dev/null +++ b/src/prompts/csv_query.md @@ -0,0 +1 @@ +You are a SQL authoring assistant for a police analyst agent. Write only one SQL query using the exact table and column names provided. Output only SQL. No prose, no markdown fences. Use DuckDB syntax only. Never use INSERT, UPDATE, DELETE, ALTER, DROP, TRUNCATE, or CREATE. Anchor on the plan, schema, and question. Add ORDER BY and LIMIT when suitable. diff --git a/src/services/csv_exec.py b/src/services/csv_exec.py new file mode 100644 index 0000000..d0fda4c --- /dev/null +++ b/src/services/csv_exec.py @@ -0,0 +1,33 @@ +"""Execute DuckDB SQL for a CSV session.""" +from __future__ import annotations + +import hashlib +import json +import time +from pathlib import Path + +from src.config.settings import get_settings + + +def session_db_path(session_id: str) -> Path: + base = Path(get_settings().duckdb_dir) / "sessions" + return base / session_id / "session.duckdb" + + +def execute_sql(session_id: str, sql: str, limit: int = 500) -> tuple[list[dict[str, object]], float, str]: + import duckdb + t0 = time.perf_counter() + db_path = session_db_path(session_id) + if not db_path.exists(): + raise FileNotFoundError(f"Session database not found: {db_path}") + conn = duckdb.connect(str(db_path), read_only=True) + try: + rows = conn.execute(sql).fetchall() + cols = [d[0] for d in (conn.description or [])] + data = [dict(zip(cols, row)) for row in rows[:limit]] + finally: + conn.close() + latency_ms = round((time.perf_counter() - t0) * 1000, 2) + payload = json.dumps(data, sort_keys=True, default=str).encode() + result_hash = hashlib.sha256(payload).hexdigest() + return data, latency_ms, result_hash diff --git a/src/services/query_exec.py b/src/services/query_exec.py new file mode 100644 index 0000000..e54f63d --- /dev/null +++ b/src/services/query_exec.py @@ -0,0 +1,25 @@ +"""Query execution service — DuckDB backed by session files.""" +from __future__ import annotations + +from pathlib import Path + +from src.config.settings import get_settings + + +def _session_db_path(session_id: str) -> Path: + return Path(get_settings().duckdb_dir) / "sessions" / session_id / "session.duckdb" + + +def execute_sql(session_id: str, sql: str) -> list[dict]: + import duckdb + + db_path = _session_db_path(session_id) + if not db_path.exists(): + raise FileNotFoundError(f"Session database not found: {db_path}") + conn = duckdb.connect(str(db_path), read_only=True) + try: + rows = conn.execute(sql).fetchall() + cols = [d[0] for d in (conn.description or [])] + return [dict(zip(cols, row)) for row in rows] + finally: + conn.close() diff --git a/src/services/sessions.py b/src/services/sessions.py new file mode 100644 index 0000000..5957dae --- /dev/null +++ b/src/services/sessions.py @@ -0,0 +1,131 @@ +"""Session service — create, fetch, ingest CSVs, schema summary.""" +from __future__ import annotations + +import csv +import io +import json +import uuid +from pathlib import Path +from typing import Any + +from src.config.settings import get_settings +from src.db.models import SessionRow +from src.db.session import create_db_session + +MAX_CSV_BYTES = 100 * 1024 * 1024 + + +def session_db_path(session_id: str) -> Path: + base = Path(get_settings().duckdb_dir) / "sessions" + return base / session_id / "session.duckdb" + + +def _table_name(filename: str) -> str: + stem = Path(filename).stem + return "".join(c if c.isalnum() or c == "_" else "_" for c in stem).lower().strip("_") or "upload" + + +def create_session() -> SessionRow: + with create_db_session() as db: + row = SessionRow() + db.add(row) + db.flush() + db.refresh(row) + db.expunge(row) + return row + + +def get_session_row(session_id: str) -> SessionRow | None: + with create_db_session() as db: + row = db.get(SessionRow, session_id) + if row is not None: + db.expunge(row) + return row + + +def _ingest_single(conn: Any, table: str, raw: bytes) -> int: + text = raw.decode("utf-8", errors="replace") + reader = csv.DictReader(io.StringIO(text)) + rows = list(reader) + if not rows: + return 0 + cols = list(rows[0].keys()) + + # Infer DuckDB types from sample values so numeric columns are INTEGER/DOUBLE. + # Without this, SUM(value) on all-TEXT columns raises a type error that the + # LLM doesn't recover from cleanly and the run returns 0 rows. + col_types: list[str] = ["TEXT"] * len(cols) + sample_size = min(len(rows), 200) + for ci, col in enumerate(cols): + has_int = False + has_float = False + for ri in range(sample_size): + raw_val = rows[ri].get(col) + if raw_val in (None, ""): + continue + try: + float(raw_val) + # Detect floats with a decimal point or exponent + if any(c in raw_val for c in (".", "e", "E")): + has_float = True + else: + has_int = True + except ValueError: + pass + if has_float: + col_types[ci] = "DOUBLE" + elif has_int: + col_types[ci] = "INTEGER" + + col_defs = ", ".join(f'"{c}" {t}' for c, t in zip(cols, col_types)) + conn.execute(f'CREATE OR REPLACE TABLE "{table}" ({col_defs})') + for row in rows: + values = ", ".join( + f"'{str(v).replace(chr(39), chr(39)+chr(39))}'" if v is not None else "NULL" + for v in row.values() + ) + conn.execute(f"INSERT INTO \"{table}\" VALUES ({values})") + return len(rows) + + +def ingest_csvs(session_id: str, files: list[tuple[str, bytes]]) -> tuple[dict[str, Any], list[str]]: + db_path = session_db_path(session_id) + db_path.parent.mkdir(parents=True, exist_ok=True) + import duckdb + conn = duckdb.connect(str(db_path), read_only=False) + try: + tables: list[dict[str, Any]] = [] + errors: list[str] = [] + table_counter: dict[str, int] = {} + for filename, raw in files: + if len(raw) > MAX_CSV_BYTES: + errors.append(f"{filename}: too large") + continue + table = _table_name(filename) + table_counter[table] = table_counter.get(table, 0) + 1 + if table_counter[table] > 1: + table = f"{table}_{table_counter[table]}" + try: + count = _ingest_single(conn, table, raw) + tables.append({"table": table, "filename": filename, "row_count": count}) + except Exception as exc: + errors.append(f"{filename}: {exc}") + payload = {"tables": tables, "errors": errors} + with create_db_session() as db: + row = db.get(SessionRow, session_id) + if row is not None: + row.schema_summary = json.dumps(payload) + db.add(row) + return payload, errors + finally: + conn.close() + + +def schema_summary(session_id: str) -> dict[str, Any]: + row = get_session_row(session_id) + if not row or not row.schema_summary: + return {"tables": []} + try: + return json.loads(row.schema_summary) + except Exception: + return {"tables": []} diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index 6f2c707..887e042 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -1,7 +1,4 @@ """Integration gate — runs against the REAL LLM/API with keys from .env. - -Skips (never stubs) when no key is present. Asserts response content and DB -state, not just status codes; covers happy path + edge case + error path. """ from __future__ import annotations @@ -27,21 +24,33 @@ def client(): def test_happy_path_real_llm_end_to_end(client): _require_key() + # 1. Create a session + res = client.post("/sessions") + assert res.status_code == 200 + session_id = res.json()["data"]["id"] + + # 2. Upload a CSV file + csv_content = b"value\n1\n2\n3" + res = client.post( + f"/sessions/{session_id}/csv", + files=[("files", ("data.csv", csv_content, "text/csv"))], + ) + assert res.status_code == 200 + assert res.json()["data"]["count"] == 1 + + # 3. Run the agent with a question res = client.post( "/runs", - json={ - "text": "The quick brown fox jumps over the lazy dog.", - "instruction": "Rewrite this sentence in uppercase letters only.", - }, + json={"session_id": session_id, "question": "What is the sum of the value column?"}, ) assert res.status_code == 200 run = res.json()["data"] assert run["status"] == "completed", f"run failed: {run['error_message']}" - assert run["output_text"], "expected real model output" - # content assertion robust to model phrasing: the transform happened - assert "QUICK" in run["output_text"].upper() + assert run["output_text"] is not None + # Check that the output contains the sum (6) + assert "6" in run["output_text"] - # DB state matches the response + # 4. Check DB state with create_db_session() as s: row = s.get(RunRow, run["run_id"]) assert row is not None @@ -52,26 +61,27 @@ def test_happy_path_real_llm_end_to_end(client): def test_edge_case_short_input_real_llm(client): _require_key() + # 1. Create a session + res = client.post("/sessions") + assert res.status_code == 200 + session_id = res.json()["data"]["id"] + + # 2. Upload a CSV file + csv_content = b"value\n10" res = client.post( - "/runs", - json={"text": "ok", "instruction": "Repeat the text exactly as given."}, + f"/sessions/{session_id}/csv", + files=[("files", ("data.csv", csv_content, "text/csv"))], ) assert res.status_code == 200 - run = res.json()["data"] - assert run["status"] == "completed", f"run failed: {run['error_message']}" - assert run["output_text"] - - -def test_error_path_bad_model_fails_actionably(client, monkeypatch): - """Wrong model name → failed run with an actionable message, not a crash.""" - _require_key() - monkeypatch.setenv("AGENT_LLM_MODEL", "this-model-does-not-exist-xyz") - # reset the settings singleton so the patched model takes effect - import src.config.settings as settings_mod - settings_mod._settings = None - res = client.post("/runs", json={"text": "hello", "instruction": "upper"}) + # 3. Run the agent with a question + res = client.post( + "/runs", + json={"session_id": session_id, "question": "What is the value?"}, + ) assert res.status_code == 200 run = res.json()["data"] - assert run["status"] == "failed" - assert run["error_message"] + assert run["status"] == "completed", f"run failed: {run['error_message']}" + assert run["output_text"] is not None + # Check that the output contains the value (10) + assert "10" in run["output_text"] \ No newline at end of file diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 0b3b1a3..8a0699b 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -33,8 +33,13 @@ def test_create_run_rejects_empty_text(): def test_run_without_key_fails_gracefully(no_keys): """No key → run persists as failed with an actionable message; no 500.""" + # The CSV Analyst run requires a session_id, so create one first. with _client() as client: - res = client.post("/runs", json={"text": "hello world"}) + res = client.post("/sessions") + assert res.status_code == 200 + session_id = res.json()["data"]["id"] + + res = client.post("/runs", json={"session_id": session_id, "question": "hello world"}) assert res.status_code == 200 run = res.json()["data"] assert run["status"] == "failed" @@ -50,7 +55,7 @@ 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 + assert "CSV Analyst Agent" in res.text # styles + js referenced (single-origin) assert "styles.css" in res.text assert "app.js" in res.text diff --git a/tests/unit/test_csv_nodes.py b/tests/unit/test_csv_nodes.py new file mode 100644 index 0000000..031f8c1 --- /dev/null +++ b/tests/unit/test_csv_nodes.py @@ -0,0 +1,77 @@ +import pytest +from unittest.mock import patch, MagicMock +from src.capabilities.csv_nodes import csv_plan, csv_query, csv_execute, csv_explain +from src.capabilities.csv_state import CsvAgentState + +# Mock LLMClient and load_prompt +@patch('src.capabilities.csv_nodes.LLMClient') +@patch('src.capabilities.csv_nodes.load_prompt') +def test_csv_plan(mock_load_prompt, mock_llm_client): + # Setup mock + mock_instance = MagicMock() + mock_instance.complete.return_value = "SELECT * FROM table" + mock_llm_client.return_value = mock_instance + mock_load_prompt.return_value = "system prompt" + + state: CsvAgentState = { + "input_text": "What is the total?", + "schema_summary": {"tables": [{"table": "upload", "filename": "data.csv", "row_count": 10}]}, + "conversation_history": [] + } + result = csv_plan(state) + assert result["plan_text"] == "SELECT * FROM table" + assert result["error"] is None + +@patch('src.capabilities.csv_nodes.LLMClient') +@patch('src.capabilities.csv_nodes.load_prompt') +def test_csv_query(mock_load_prompt, mock_llm_client): + mock_instance = MagicMock() + mock_instance.complete.return_value = "SELECT COUNT(*) FROM upload" + mock_llm_client.return_value = mock_instance + mock_load_prompt.return_value = "system prompt" + + state: CsvAgentState = { + "input_text": "What is the total?", + "plan_text": "SELECT * FROM table", + "schema_summary": {"tables": [{"table": "upload", "filename": "data.csv", "row_count": 10}]}, + } + result = csv_query(state) + assert result["generated_code"] == "SELECT COUNT(*) FROM upload" + assert result["code_language"] == "sql" + assert result["error"] is None + +def test_csv_execute(): + # We'll mock duckdb in the execute_sql function; but for unit test we can mock execute_sql + with patch('src.capabilities.csv_nodes.execute_sql') as mock_execute: + mock_execute.return_value = ([{"col1": 1}], 10.0, "abc123") + state: CsvAgentState = { + "generated_code": "SELECT 1 as col1", + "session_id": "test-session" + } + result = csv_execute(state) + assert result["rows"] == [{"col1": 1}] + assert result["row_count"] == 1 + assert result["latency_ms"] == 10.0 + assert result["result_hash"] == "abc123" + assert result["error"] is None + +@patch('src.capabilities.csv_nodes.LLMClient') +@patch('src.capabilities.csv_nodes.load_prompt') +def test_csv_explain(mock_load_prompt, mock_llm_client): + mock_instance = MagicMock() + mock_instance.complete.return_value = "This query counts rows." + mock_llm_client.return_value = mock_instance + mock_load_prompt.return_value = "system prompt" + + state: CsvAgentState = { + "input_text": "What is the total?", + "plan_text": "SELECT * FROM table", + "generated_code": "SELECT COUNT(*) FROM upload", + "rows": [{"count": 10}], + "row_count": 10, + "latency_ms": 12.5, + "result_hash": "def456" + } + result = csv_explain(state) + assert result["output_text"] == "This query counts rows." + assert result["error"] is None \ No newline at end of file diff --git a/uv.lock b/uv.lock index 5d86f08..068c9c7 100644 --- a/uv.lock +++ b/uv.lock @@ -160,6 +160,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "duckdb" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/29/9bad86ed7aa812d8c822a27c15c355b6d5423b991feeec86ed18027b6daa/duckdb-1.5.4.tar.gz", hash = "sha256:f9e32f1cdd106793d79d190186bed9e75289d51e68bd9174e47c04bffedeab6f", size = 18046634, upload-time = "2026-06-17T10:48:52.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/bb/7921dabd50daef3969f14cd8a5a14c24eee337db7914a462f2defa8add92/duckdb-1.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3fb41d9cfccb7e44511eeeed263ae98143ca63bdb1ef84631ba637c314efa1b5", size = 32663142, upload-time = "2026-06-17T10:47:45.471Z" }, + { url = "https://files.pythonhosted.org/packages/a6/83/2137765eaba6a9aefe3bb9848ddaac7407fe3ba19b292f98b31f3b7ab27f/duckdb-1.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ba7b666bc9c78d6a930ee9f469024149f0c6a23fb7d2c3418aad6774339bec0", size = 17321485, upload-time = "2026-06-17T10:47:47.778Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b2/a02c1ee43fd7e8cf1fc2e3d377f3dcf9d4a3e58a4549557516e1866ff0da/duckdb-1.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9d9e6817fcbc09d2605a2c8c041ac7824d738d917c35a4d427e977647e1d7944", size = 15470820, upload-time = "2026-06-17T10:47:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/d8/48/a243d30223b024bc6057abe472b002cff01e97efefb4d2f0b0dcc5aece0b/duckdb-1.5.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02dd9f9a6124069213f13e3a474c208028c472fe1acdae12b38761f954fe4fc6", size = 19341849, upload-time = "2026-06-17T10:47:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/a5d48de4771e2403a8ef26a20dc7457b1c8f7e398ff0caf9c0cad8805f89/duckdb-1.5.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccc7f2694d02b4763fee61021d45e12f7bc5743993686563957df0cef799fbae", size = 21451698, upload-time = "2026-06-17T10:47:54.653Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/8244d7741b4afae67775cf0cb0d4eb9e923a83110907e4801e17fa078480/duckdb-1.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:4c430e788d99b50854209bf2833ba36a45df75e57f86efb477046cd408bbd077", size = 13132643, upload-time = "2026-06-17T10:47:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/e4/57/8169822a37f6dd7d561c567f9007e3cf04bf97bccb619afe90db849c0962/duckdb-1.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:e2dc8340cfb6006025a798c50f40126d6e945a1d2487be94667bb4166556ce7b", size = 13986386, upload-time = "2026-06-17T10:47:59.345Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f2/e2f4b477ae3a3b40e8b5f429832e48edb62ed9da99807cc4902e157e5646/duckdb-1.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:291a9e7502551170af989ff63139a7a49e99d68edbc5ef5017ac27541fe54c65", size = 32708876, upload-time = "2026-06-17T10:48:01.527Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2b/b698d82a5e1e30b6a05748d72045f672994c6b22f4f0f8423523608b991f/duckdb-1.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83e8c089bbb756ca4471d8b05943b80a106058697cf00615e70423106bb783bc", size = 17346125, upload-time = "2026-06-17T10:48:04.035Z" }, + { url = "https://files.pythonhosted.org/packages/71/75/37e13f39268eaf34864453b3a039c4a1ff0b088d3eae45a4289b41c98c1b/duckdb-1.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ff96d2a342b200e1ec6f1f19986c77f4ac16a49b6112f71c5b763989203a9d60", size = 15488133, upload-time = "2026-06-17T10:48:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/cc/59/2d082af578f689231798245b54562c61416e49049b0bda81a06c56a4b53e/duckdb-1.5.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f935ef210ab00bc94bb1e3052697adaa36bb0ce7bdfeda8b0f34e2ff1643870", size = 19367895, upload-time = "2026-06-17T10:48:08.59Z" }, + { url = "https://files.pythonhosted.org/packages/52/2b/55c34d2863a76ca824ef8274691e84240b4ff1acde3d231709e82557c240/duckdb-1.5.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cda263d8c20addb8d4f95464787cbe0af1144f7ab7e21db3709fb826ee01725", size = 21486499, upload-time = "2026-06-17T10:48:10.963Z" }, + { url = "https://files.pythonhosted.org/packages/cf/30/ade5952b8182fac86fab43b95ebe3836e66381d0ad64eb1e54bd8207c988/duckdb-1.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:266c7c909558ce7377f57d082cee408aadebdd9111be017558ca54e44a031037", size = 13147934, upload-time = "2026-06-17T10:48:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/278f0f70e25b9911afe2fd227b9460f2e6d76177f0dcc03f7f1454afefa5/duckdb-1.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:f14e79a006341f29ee5a2692a24dac5114e77533d579c57ec39124adf0135033", size = 13965235, upload-time = "2026-06-17T10:48:15.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/3fcb34e523a9bad1f0557a6c7691a71ba66c43a05e5be9ee96a9a841ed65/duckdb-1.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:42a612e67d64450b446eb69695290d460713eef46e0f64467ab9dfe96264ee05", size = 32708366, upload-time = "2026-06-17T10:48:18.084Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/bff5054c2c1d65decab36aa6296621e51a2a575a9f250db7ab9b83a325d6/duckdb-1.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3fb6f07d54ecf4d0d3c5179a2361fdddfafa14de4fc42696de4632479b703421", size = 17345735, upload-time = "2026-06-17T10:48:20.67Z" }, + { url = "https://files.pythonhosted.org/packages/93/12/d1b2b344e9699246aada6f9de5156e708fb476e2780e5bff9b5d95fe11d9/duckdb-1.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f32ad7e0286c1c29ab6c73b29118c86101f8eee46aae54f54d0b50916f542f6", size = 15488568, upload-time = "2026-06-17T10:48:23.038Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/ac56c6096e3e95da60b2c5dd5a0f0eb5540a80622e2e4f8faab893ec4e96/duckdb-1.5.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:698ec90bd5d5538bd5f6d212a4b61af443d240703cf45f134738535026556ea5", size = 19368184, upload-time = "2026-06-17T10:48:25.601Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/2ae4c3e157a19d9b4ac1f09a5dea6f93012334cc2db09f1e0c71eb99693d/duckdb-1.5.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136cea7f886b78caf4035485b4b1e766e8b309e999f9e83a966f81ebb8122844", size = 21486523, upload-time = "2026-06-17T10:48:27.817Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/c3d8d21e0d0db8faa81eeeb3a55b9932f5a0a16466cb968dc713a653d701/duckdb-1.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:bd6777e8ddd74fb603a6d09766bfcff28638189f8aaa61fc0dffd9e9a4baa8e5", size = 13147807, upload-time = "2026-06-17T10:48:30.017Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/ddf8d3740e3d28582944f70d84e720b5dc28c10ec22b668a0e0bd965f2f2/duckdb-1.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:73f4878a3012283024a64a1909e440aac12091ef336f671fc142f7e87449ce0c", size = 13965189, upload-time = "2026-06-17T10:48:32.251Z" }, + { url = "https://files.pythonhosted.org/packages/62/01/67ac4cbc8e552a1e14c029b5c443d828e68f94d5d913c574f577e1db277e/duckdb-1.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4647968629d0677bbcc2416c7aeda8685eb84e4ca15a6dbd4f82a66cfc91a532", size = 32714364, upload-time = "2026-06-17T10:48:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/eb44d983fa56b175f971eea251bde284a36d26cbb93fcb68035061f54078/duckdb-1.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e8fcef301cf68d3951ea1eb8ac4d76cea0a6f6a08f4c78fe4026fc96d217bebc", size = 17349820, upload-time = "2026-06-17T10:48:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/10/b2/b9dc7624b105d414585b8530451c1162c0b4750c0be9be2e497bb47a8a9b/duckdb-1.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f6f39cd0dc6948dee17fd130aec55114f97a8ef6e1db519b9774087962bc5c8c", size = 15498160, upload-time = "2026-06-17T10:48:40.032Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/61356444f6a8c62dec3c3d129abfc53f428de1d484093d1bb381db441231/duckdb-1.5.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:262f068158beb5943f2c618f4e54b46db8306b959f90dce956f90a89f613673d", size = 19374183, upload-time = "2026-06-17T10:48:42.698Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f4/d5d633dd7c5138d8f7c434e6ac2553c831b7fb658494efa8d0bc73df8623/duckdb-1.5.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d2307a76d199077b0055b354e90e857479461a0d875437535dd4833172c8b6d", size = 21487202, upload-time = "2026-06-17T10:48:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/c0/26/5be13bbd5c3421dccfc1ad4ca9da4b97c5a3ddd73f66542092f3167ec52c/duckdb-1.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:6dcbb81a1276bc48deb4d562bce4f8895e4fc6348750a096e30052345c6d6552", size = 13666989, upload-time = "2026-06-17T10:48:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/4d52f3f9f9703a226b26b80bdae3f6905aeefe5221bf1815fc93ff02ca25/duckdb-1.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:0f8722346024e5d9f02b58bf7b0491a629f97fdc8a04a10e432940f471ee387a", size = 14449863, upload-time = "2026-06-17T10:48:50.18Z" }, +] + [[package]] name = "fastapi" version = "0.139.2" @@ -834,6 +870,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyodbc" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/85/44b10070a769a56bd910009bb185c0c0a82daff8d567cd1a116d7d730c7d/pyodbc-5.3.0.tar.gz", hash = "sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05", size = 121770, upload-time = "2025-10-17T18:04:09.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/c7/534986d97a26cb8f40ef456dfcf00d8483161eade6d53fa45fcf2d5c2b87/pyodbc-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ebc3be93f61ea0553db88589e683ace12bf975baa954af4834ab89f5ee7bf8ae", size = 71958, upload-time = "2025-10-17T18:03:10.163Z" }, + { url = "https://files.pythonhosted.org/packages/69/3c/6fe3e9eae6db1c34d6616a452f9b954b0d5516c430f3dd959c9d8d725f2a/pyodbc-5.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b987a25a384f31e373903005554230f5a6d59af78bce62954386736a902a4b3", size = 71843, upload-time = "2025-10-17T18:03:11.058Z" }, + { url = "https://files.pythonhosted.org/packages/44/0e/81a0315d0bf7e57be24338dbed616f806131ab706d87c70f363506dc13d5/pyodbc-5.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676031723aac7dcbbd2813bddda0e8abf171b20ec218ab8dfb21d64a193430ea", size = 327191, upload-time = "2025-10-17T18:03:11.93Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/b95bb2068f911950322a97172c68675c85a3e87dc04a98448c339fcbef21/pyodbc-5.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5c30c5cd40b751f77bbc73edd32c4498630939bcd4e72ee7e6c9a4b982cc5ca", size = 332228, upload-time = "2025-10-17T18:03:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/dc/21/2433625f7d5922ee9a34e3805805fa0f1355d01d55206c337bb23ec869bf/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2035c7dfb71677cd5be64d3a3eb0779560279f0a8dc6e33673499498caa88937", size = 1296469, upload-time = "2025-10-17T18:03:14.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f4/c760caf7bb9b3ab988975d84bd3e7ebda739fe0075c82f476d04ee97324c/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5cbe4d753723c8a8f65020b7a259183ef5f14307587165ce37e8c7e251951852", size = 1353163, upload-time = "2025-10-17T18:03:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/14/ad/f9ca1e9e44fd91058f6e35b233b1bb6213d590185bfcc2a2c4f1033266e7/pyodbc-5.3.0-cp311-cp311-win32.whl", hash = "sha256:d255f6b117d05cfc046a5201fdf39535264045352ea536c35777cf66d321fbb8", size = 62925, upload-time = "2025-10-17T18:03:17.649Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cf/52b9b94efd8cfd11890ae04f31f50561710128d735e4e38a8fbb964cd2c2/pyodbc-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f1ad0e93612a6201621853fc661209d82ff2a35892b7d590106fe8f97d9f1f2a", size = 69329, upload-time = "2025-10-17T18:03:18.474Z" }, + { url = "https://files.pythonhosted.org/packages/8b/6f/bf5433bb345007f93003fa062e045890afb42e4e9fc6bd66acc2c3bd12ca/pyodbc-5.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0df7ff47fab91ea05548095b00e5eb87ed88ddf4648c58c67b4db95ea4913e23", size = 64447, upload-time = "2025-10-17T18:03:19.691Z" }, + { url = "https://files.pythonhosted.org/packages/f5/0c/7ecf8077f4b932a5d25896699ff5c394ffc2a880a9c2c284d6a3e6ea5949/pyodbc-5.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ebf6b5d989395efe722b02b010cb9815698a4d681921bf5db1c0e1195ac1bde", size = 72994, upload-time = "2025-10-17T18:03:20.551Z" }, + { url = "https://files.pythonhosted.org/packages/03/78/9fbde156055d88c1ef3487534281a5b1479ee7a2f958a7e90714968749ac/pyodbc-5.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:197bb6ddafe356a916b8ee1b8752009057fce58e216e887e2174b24c7ab99269", size = 72535, upload-time = "2025-10-17T18:03:21.423Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f9/8c106dcd6946e95fee0da0f1ba58cd90eb872eebe8968996a2ea1f7ac3c1/pyodbc-5.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6ccb5315ec9e081f5cbd66f36acbc820ad172b8fa3736cf7f993cdf69bd8a96", size = 333565, upload-time = "2025-10-17T18:03:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/2c70f47a76a4fafa308d148f786aeb35a4d67a01d41002f1065b465d9994/pyodbc-5.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dd3d5e469f89a3112cf8b0658c43108a4712fad65e576071e4dd44d2bd763c7", size = 340283, upload-time = "2025-10-17T18:03:23.691Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b2/0631d84731606bfe40d3b03a436b80cbd16b63b022c7b13444fb30761ca8/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b180bc5e49b74fd40a24ef5b0fe143d0c234ac1506febe810d7434bf47cb925b", size = 1302767, upload-time = "2025-10-17T18:03:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/74/b9/707c5314cca9401081b3757301241c167a94ba91b4bd55c8fa591bf35a4a/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3c39de3005fff3ae79246f952720d44affc6756b4b85398da4c5ea76bf8f506", size = 1361251, upload-time = "2025-10-17T18:03:26.538Z" }, + { url = "https://files.pythonhosted.org/packages/97/7c/893036c8b0c8d359082a56efdaa64358a38dda993124162c3faa35d1924d/pyodbc-5.3.0-cp312-cp312-win32.whl", hash = "sha256:d32c3259762bef440707098010035bbc83d1c73d81a434018ab8c688158bd3bb", size = 63413, upload-time = "2025-10-17T18:03:27.903Z" }, + { url = "https://files.pythonhosted.org/packages/c0/70/5e61b216cc13c7f833ef87f4cdeab253a7873f8709253f5076e9bb16c1b3/pyodbc-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe77eb9dcca5fc1300c9121f81040cc9011d28cff383e2c35416e9ec06d4bc95", size = 70133, upload-time = "2025-10-17T18:03:28.746Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/e7d0629c9714a85eb4f85d21602ce6d8a1ec0f313fde8017990cf913e3b4/pyodbc-5.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:afe7c4ac555a8d10a36234788fc6cfc22a86ce37fc5ba88a1f75b3e6696665dc", size = 64700, upload-time = "2025-10-17T18:03:29.638Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/9e74cbcc1d4878553eadfd59138364b38656369eb58f7e5b42fb344c0ce7/pyodbc-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e9ab0b91de28a5ab838ac4db0253d7cc8ce2452efe4ad92ee6a57b922bf0c24", size = 72975, upload-time = "2025-10-17T18:03:30.466Z" }, + { url = "https://files.pythonhosted.org/packages/37/c7/27d83f91b3144d3e275b5b387f0564b161ddbc4ce1b72bb3b3653e7f4f7a/pyodbc-5.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6132554ffbd7910524d643f13ce17f4a72f3a6824b0adef4e9a7f66efac96350", size = 72541, upload-time = "2025-10-17T18:03:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/2bb24e7fc95e98a7b11ea5ad1f256412de35d2e9cc339be198258c1d9a76/pyodbc-5.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1629af4706e9228d79dabb4863c11cceb22a6dab90700db0ef449074f0150c0d", size = 343287, upload-time = "2025-10-17T18:03:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/fa/24/88cde8b6dc07a93a92b6c15520a947db24f55db7bd8b09e85956642b7cf3/pyodbc-5.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ceaed87ba2ea848c11223f66f629ef121f6ebe621f605cde9cfdee4fd9f4b68", size = 350094, upload-time = "2025-10-17T18:03:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/99/53c08562bc171a618fa1699297164f8885e66cde38c3b30f454730d0c488/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cc472c8ae2feea5b4512e23b56e2b093d64f7cbc4b970af51da488429ff7818", size = 1301029, upload-time = "2025-10-17T18:03:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/d8/10/68a0b5549876d4b53ba4c46eed2a7aca32d589624ed60beef5bd7382619e/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c79df54bbc25bce9f2d87094e7b39089c28428df5443d1902b0cc5f43fd2da6f", size = 1361420, upload-time = "2025-10-17T18:03:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/41/0f/9dfe4987283ffcb981c49a002f0339d669215eb4a3fe4ee4e14537c52852/pyodbc-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c2eb0b08e24fe5c40c7ebe9240c5d3bd2f18cd5617229acee4b0a0484dc226f2", size = 63399, upload-time = "2025-10-17T18:03:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/56/03/15dcefe549d3888b649652af7cca36eda97c12b6196d92937ca6d11306e9/pyodbc-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:01166162149adf2b8a6dc21a212718f205cabbbdff4047dc0c415af3fd85867e", size = 70133, upload-time = "2025-10-17T18:03:38.47Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c1/c8b128ae59a14ecc8510e9b499208e342795aecc3af4c3874805c720b8db/pyodbc-5.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:363311bd40320b4a61454bebf7c38b243cd67c762ed0f8a5219de3ec90c96353", size = 64683, upload-time = "2025-10-17T18:03:39.68Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f2/c26d82a7ce1e90b8bbb8731d3d53de73814e2f6606b9db9d978303aa8d5f/pyodbc-5.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797", size = 73513, upload-time = "2025-10-17T18:03:40.536Z" }, + { url = "https://files.pythonhosted.org/packages/82/d5/1ab1b7c4708cbd701990a8f7183c5bb5e0712d5e8479b919934e46dadab4/pyodbc-5.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780", size = 72631, upload-time = "2025-10-17T18:03:41.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/7e3831eeac2b09b31a77e6b3495491ce162035ff2903d7261b49d35aa3c2/pyodbc-5.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc", size = 344580, upload-time = "2025-10-17T18:03:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a6/71d26d626a3c45951620b7ff356ec920e420f0e09b0a924123682aa5e4ab/pyodbc-5.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6", size = 350224, upload-time = "2025-10-17T18:03:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/93/14/f702c5e8c2d595776266934498505f11b7f1545baf21ffec1d32c258e9d3/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05", size = 1301503, upload-time = "2025-10-17T18:03:45.013Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b2/ad92ebdd1b5c7fec36b065e586d1d34b57881e17ba5beec5c705f1031058/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e", size = 1361050, upload-time = "2025-10-17T18:03:46.298Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/dc84e232da07056cb5aaaf5f759ba4c874bc12f37569f7f1670fc71e7ae1/pyodbc-5.3.0-cp314-cp314-win32.whl", hash = "sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b", size = 65670, upload-time = "2025-10-17T18:03:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/b8/79/c48be07e8634f764662d7a279ac204f93d64172162dbf90f215e2398b0bd/pyodbc-5.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39", size = 72177, upload-time = "2025-10-17T18:03:57.296Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/e304574446b2263f428ce14df590ba52c2e0e0205e8d34b235b582b7d57e/pyodbc-5.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5", size = 66668, upload-time = "2025-10-17T18:03:58.174Z" }, + { url = "https://files.pythonhosted.org/packages/43/17/f4eabf443b838a2728773554017d08eee3aca353102934a7e3ba96fb0e31/pyodbc-5.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364", size = 75780, upload-time = "2025-10-17T18:03:47.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/e79e168c3d38c27d59d5d96273fd9e3c3ba55937cc944c4e60618f51de90/pyodbc-5.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0", size = 75503, upload-time = "2025-10-17T18:03:48.171Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/d1d7c125ec4a20e83fdc28e119b8321192b2bd694f432cf63e1199b2b929/pyodbc-5.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943", size = 398356, upload-time = "2025-10-17T18:03:49.131Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fc/f6be4b3cc3910f8c2aba37aa41671121fd6f37b402ae0fefe53a70ac7cd5/pyodbc-5.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d", size = 397291, upload-time = "2025-10-17T18:03:50.18Z" }, + { url = "https://files.pythonhosted.org/packages/03/2e/0610b1ed05a5625528d52f6cece9610e84617d35f475c89c2a52f66d13f7/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d", size = 1353900, upload-time = "2025-10-17T18:03:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f1/43497e1d37f9f71b43b2b3172e7b1bdf50851e278390c3fb6b46a3630c53/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e", size = 1406062, upload-time = "2025-10-17T18:03:52.546Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/88a1277c2f7d9ab1cec0a71e074ba24fd4a1710a43974682546da90a1343/pyodbc-5.3.0-cp314-cp314t-win32.whl", hash = "sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514", size = 70132, upload-time = "2025-10-17T18:03:53.715Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/ee98c62050de4aa8bafb6eb1e11b95e0b0c898bd5930137c6dc776e06a9b/pyodbc-5.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955", size = 79452, upload-time = "2025-10-17T18:03:54.664Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8f/d8889efd96bbe8e5d43ff9701f6b1565a8e09c3e1f58c388d550724f777b/pyodbc-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db", size = 70142, upload-time = "2025-10-17T18:03:55.551Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -859,6 +948,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1498,11 +1596,14 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "duckdb" }, { name = "fastapi" }, { name = "httpx" }, { name = "langgraph" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyodbc" }, + { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "structlog" }, { name = "uvicorn", extra = ["standard"] }, @@ -1516,11 +1617,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.13" }, + { name = "duckdb", specifier = ">=1.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "langgraph", specifier = ">=0.2.28" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.3" }, + { name = "pyodbc", specifier = ">=5.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "structlog", specifier = ">=24.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },